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
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-17 21:46 -0500
1"""Issue-related commands."""
3from typing import Annotated
5import typer
6from rich.console import Console
8from sentry_tool.output import Column, OutputFormat, render
9from sentry_tool.utils import api, get_config
11app = typer.Typer(help="Issue management commands")
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.
33 Use --all-projects/-A to list issues across all projects in the organization.
34 The -A flag is mutually exclusive with --project/-p.
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()
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)
50 params = ""
51 if status:
52 params = f"?query=is:{status}"
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 )
68 console = Console()
70 if not issues:
71 console.print("No issues found")
72 return
74 issues = issues[:max_rows]
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)
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 )
106 render(rows, format, columns=columns, footer=f"Showing {len(issues)} issues")
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()
127 issue = api(
128 f"/organizations/{config['org']}/issues/{issue_id}/",
129 token=config["auth_token"],
130 base_url=config["url"],
131 )
133 if format == OutputFormat.json:
134 render([issue], format)
135 return
137 console = Console()
139 short_id = issue.get("shortId", issue.get("id"))
140 console.print(f"\n[bold cyan]=== Issue {short_id} ===[/bold cyan]")
142 status = issue.get("status", "N/A")
143 substatus = issue.get("substatus", "")
144 status_str = f"{status} ({substatus})" if substatus else status
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 ]
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")})
164 detail_columns = [
165 Column("Field", "field", style="bold"),
166 Column("Value", "value"),
167 ]
168 render(rows, OutputFormat.table, columns=detail_columns)
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")
183 console.print("\n[dim]Use 'sentry-tool event <id>' to see the latest event[/dim]")
184 console.print()