Coverage for src / sentry_tool / commands / issues.py: 100.00%

67 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-17 21:46 -0500

1"""Issue-related commands.""" 

2 

3from typing import Annotated 

4 

5import typer 

6from rich.console import Console 

7 

8from sentry_tool.output import Column, OutputFormat, render 

9from sentry_tool.utils import api, get_config 

10 

11app = typer.Typer(help="Issue management commands") 

12 

13 

14@app.command("list") 

15def list_issues( 

16 project: Annotated[str | None, typer.Option("--project", "-p", help="Project slug")] = None, 

17 all_projects: Annotated[ 

18 bool, typer.Option("--all-projects", "-A", help="List issues across all projects") 

19 ] = False, 

20 max_rows: Annotated[int, typer.Option("--max", "-n", help="Maximum issues to show")] = 10, 

21 status: Annotated[ 

22 str | None, 

23 typer.Option("--status", "-s", help="Filter by status: resolved, unresolved, muted"), 

24 ] = None, 

25 format: Annotated[ 

26 OutputFormat, 

27 typer.Option("--format", "-f", help="Output format"), 

28 ] = OutputFormat.table, 

29) -> None: 

30 """ 

31 List recent issues in a project. 

32 

33 Use --all-projects/-A to list issues across all projects in the organization. 

34 The -A flag is mutually exclusive with --project/-p. 

35 

36 Examples: 

37 sentry-tool list 

38 sentry-tool list -p otel-collector -n 5 

39 sentry-tool list -s unresolved 

40 sentry-tool list -A 

41 sentry-tool list --format json 

42 """ 

43 config = get_config() 

44 

45 if all_projects and project: 

46 console = Console() 

47 console.print("[red]Error: --all-projects/-A and --project/-p are mutually exclusive[/red]") 

48 raise typer.Exit(1) 

49 

50 params = "" 

51 if status: 

52 params = f"?query=is:{status}" 

53 

54 if all_projects: 

55 issues = api( 

56 f"/organizations/{config['org']}/issues/{params}", 

57 token=config["auth_token"], 

58 base_url=config["url"], 

59 ) 

60 else: 

61 project = project or config["project"] 

62 issues = api( 

63 f"/projects/{config['org']}/{project}/issues/{params}", 

64 token=config["auth_token"], 

65 base_url=config["url"], 

66 ) 

67 

68 console = Console() 

69 

70 if not issues: 

71 console.print("No issues found") 

72 return 

73 

74 issues = issues[:max_rows] 

75 

76 rows = [] 

77 for issue in issues: 

78 row = { 

79 "id": str(issue.get("id", "")), 

80 "shortId": issue.get("shortId", ""), 

81 "status": issue.get("status", ""), 

82 "level": issue.get("level", ""), 

83 "count": str(issue.get("count", "")), 

84 "title": issue.get("title", ""), 

85 } 

86 if all_projects: 

87 proj = issue.get("project", {}) 

88 row["project"] = proj.get("slug", "") if isinstance(proj, dict) else str(proj) 

89 rows.append(row) 

90 

91 columns = [ 

92 Column("ID", "id", style="dim", max_width=6), 

93 Column("Short ID", "shortId", max_width=20), 

94 ] 

95 if all_projects: 

96 columns.append(Column("Project", "project", max_width=20)) 

97 columns.extend( 

98 [ 

99 Column("Status", "status", max_width=12), 

100 Column("Level", "level", max_width=8), 

101 Column("Count", "count", justify="right", max_width=8), 

102 Column("Title", "title", max_width=50), 

103 ] 

104 ) 

105 

106 render(rows, format, columns=columns, footer=f"Showing {len(issues)} issues") 

107 

108 

109@app.command("show") 

110def show_issue( 

111 issue_id: Annotated[ 

112 str, typer.Argument(help="Issue ID (numeric or short ID like OTEL-COLLECTOR-Q)") 

113 ], 

114 format: Annotated[ 

115 OutputFormat, 

116 typer.Option("--format", "-f", help="Output format"), 

117 ] = OutputFormat.table, 

118) -> None: 

119 """ 

120 Examples: 

121 sentry-tool show 24 

122 sentry-tool show OTEL-COLLECTOR-Q 

123 sentry-tool show 24 --format json 

124 """ 

125 config = get_config() 

126 

127 issue = api( 

128 f"/organizations/{config['org']}/issues/{issue_id}/", 

129 token=config["auth_token"], 

130 base_url=config["url"], 

131 ) 

132 

133 if format == OutputFormat.json: 

134 render([issue], format) 

135 return 

136 

137 console = Console() 

138 

139 short_id = issue.get("shortId", issue.get("id")) 

140 console.print(f"\n[bold cyan]=== Issue {short_id} ===[/bold cyan]") 

141 

142 status = issue.get("status", "N/A") 

143 substatus = issue.get("substatus", "") 

144 status_str = f"{status} ({substatus})" if substatus else status 

145 

146 rows = [ 

147 {"field": "Title", "value": issue.get("title", "N/A")}, 

148 {"field": "Status", "value": status_str}, 

149 {"field": "Level", "value": issue.get("level", "N/A")}, 

150 {"field": "Priority", "value": str(issue.get("priority", "N/A"))}, 

151 {"field": "Count", "value": f"{issue.get('count', 'N/A')} events"}, 

152 {"field": "First seen", "value": issue.get("firstSeen", "N/A")}, 

153 {"field": "Last seen", "value": issue.get("lastSeen", "N/A")}, 

154 {"field": "URL", "value": issue.get("permalink", "N/A")}, 

155 ] 

156 

157 first_rel = issue.get("firstRelease", {}) 

158 last_rel = issue.get("lastRelease", {}) 

159 if first_rel: 

160 rows.append({"field": "First release", "value": first_rel.get("version", "N/A")}) 

161 if last_rel: 

162 rows.append({"field": "Last release", "value": last_rel.get("version", "N/A")}) 

163 

164 detail_columns = [ 

165 Column("Field", "field", style="bold"), 

166 Column("Value", "value"), 

167 ] 

168 render(rows, OutputFormat.table, columns=detail_columns) 

169 

170 tags = issue.get("tags", []) 

171 if tags: 

172 tag_rows = [ 

173 {"key": tag.get("key", ""), "values": str(tag.get("totalValues", 0))} 

174 for tag in tags[:8] 

175 ] 

176 tag_columns = [ 

177 Column("Tag", "key"), 

178 Column("Unique Values", "values", justify="right"), 

179 ] 

180 console.print() 

181 render(tag_rows, OutputFormat.table, columns=tag_columns, footer=f"{len(tags)} tag types") 

182 

183 console.print("\n[dim]Use 'sentry-tool event <id>' to see the latest event[/dim]") 

184 console.print()