Skip to content

Commit 7611ee9

Browse files
authored
feat(repo): add repo list command (#222)
## Summary Adds `sentry repo list` command to list repositories connected to Sentry organizations. Useful for seeing which GitHub/GitLab repos are integrated with your Sentry org. ## Changes New command following the existing `project list` pattern: - Supports optional org positional argument - Falls back to config default, DSN auto-detection, or listing all accessible orgs - `--limit` and `--json` flags - `repos` shortcut alias (like `issues`, `orgs`, `projects`) ## Usage ```bash sentry repo list # auto-detect or list all orgs sentry repo list my-org # list repos in specific org sentry repo list --limit 10 # limit results sentry repo list --json # JSON output sentry repos # shortcut ``` ## Test Plan - Unit tests added and passing - Manually tested against peated and sentry-sdks orgs
1 parent 8aa1a82 commit 7611ee9

File tree

8 files changed

+597
-0
lines changed

8 files changed

+597
-0
lines changed

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,18 @@ Update the Sentry CLI to the latest version
422422
- `--check - Check for updates without installing`
423423
- `--method <value> - Installation method to use (curl, npm, pnpm, bun, yarn)`
424424

425+
### Repo
426+
427+
Work with Sentry repositories
428+
429+
#### `sentry repo list <org>`
430+
431+
List repositories
432+
433+
**Flags:**
434+
- `-n, --limit <value> - Maximum number of repositories to list - (default: "30")`
435+
- `--json - Output JSON`
436+
425437
### Log
426438

427439
View Sentry logs
@@ -564,6 +576,18 @@ List projects
564576
- `--json - Output JSON`
565577
- `-p, --platform <value> - Filter by platform (e.g., javascript, python)`
566578

579+
### Repos
580+
581+
List repositories
582+
583+
#### `sentry repos <org>`
584+
585+
List repositories
586+
587+
**Flags:**
588+
- `-n, --limit <value> - Maximum number of repositories to list - (default: "30")`
589+
- `--json - Output JSON`
590+
567591
### Logs
568592

569593
List logs from a project

src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { orgRoute } from "./commands/org/index.js";
1919
import { listCommand as orgListCommand } from "./commands/org/list.js";
2020
import { projectRoute } from "./commands/project/index.js";
2121
import { listCommand as projectListCommand } from "./commands/project/list.js";
22+
import { repoRoute } from "./commands/repo/index.js";
23+
import { listCommand as repoListCommand } from "./commands/repo/list.js";
2224
import { traceRoute } from "./commands/trace/index.js";
2325
import { listCommand as traceListCommand } from "./commands/trace/list.js";
2426
import { CLI_VERSION } from "./lib/constants.js";
@@ -33,6 +35,7 @@ export const routes = buildRouteMap({
3335
cli: cliRoute,
3436
org: orgRoute,
3537
project: projectRoute,
38+
repo: repoRoute,
3639
issue: issueRoute,
3740
event: eventRoute,
3841
log: logRoute,
@@ -41,6 +44,7 @@ export const routes = buildRouteMap({
4144
issues: issueListCommand,
4245
orgs: orgListCommand,
4346
projects: projectListCommand,
47+
repos: repoListCommand,
4448
logs: logListCommand,
4549
traces: traceListCommand,
4650
},

src/commands/repo/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { buildRouteMap } from "@stricli/core";
2+
import { listCommand } from "./list.js";
3+
4+
export const repoRoute = buildRouteMap({
5+
routes: {
6+
list: listCommand,
7+
},
8+
docs: {
9+
brief: "Work with Sentry repositories",
10+
fullDescription:
11+
"List and manage repositories connected to your Sentry organizations.",
12+
hideRoute: {},
13+
},
14+
});

src/commands/repo/list.ts

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* sentry repo list
3+
*
4+
* List repositories in an organization.
5+
*/
6+
7+
import type { SentryContext } from "../../context.js";
8+
import { listOrganizations, listRepositories } from "../../lib/api-client.js";
9+
import { buildCommand, numberParser } from "../../lib/command.js";
10+
import { getDefaultOrganization } from "../../lib/db/defaults.js";
11+
import { AuthError } from "../../lib/errors.js";
12+
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
13+
import { resolveAllTargets } from "../../lib/resolve-target.js";
14+
import type { SentryRepository, Writer } from "../../types/index.js";
15+
16+
type ListFlags = {
17+
readonly limit: number;
18+
readonly json: boolean;
19+
};
20+
21+
/** Repository with its organization context for display */
22+
type RepositoryWithOrg = SentryRepository & { orgSlug?: string };
23+
24+
/**
25+
* Fetch repositories for a single organization.
26+
*
27+
* @param orgSlug - Organization slug to fetch repositories from
28+
* @returns Repositories with org context attached
29+
*/
30+
async function fetchOrgRepositories(
31+
orgSlug: string
32+
): Promise<RepositoryWithOrg[]> {
33+
const repos = await listRepositories(orgSlug);
34+
return repos.map((r) => ({ ...r, orgSlug }));
35+
}
36+
37+
/**
38+
* Fetch repositories for a single org, returning empty array on non-auth errors.
39+
* Auth errors propagate so user sees "please log in" message.
40+
*/
41+
async function fetchOrgRepositoriesSafe(
42+
orgSlug: string
43+
): Promise<RepositoryWithOrg[]> {
44+
try {
45+
return await fetchOrgRepositories(orgSlug);
46+
} catch (error) {
47+
if (error instanceof AuthError) {
48+
throw error;
49+
}
50+
return [];
51+
}
52+
}
53+
54+
/**
55+
* Fetch repositories from all accessible organizations.
56+
* Skips orgs where the user lacks access.
57+
*
58+
* @returns Combined list of repositories from all accessible orgs
59+
*/
60+
async function fetchAllOrgRepositories(): Promise<RepositoryWithOrg[]> {
61+
const orgs = await listOrganizations();
62+
const results: RepositoryWithOrg[] = [];
63+
64+
for (const org of orgs) {
65+
try {
66+
const repos = await fetchOrgRepositories(org.slug);
67+
results.push(...repos);
68+
} catch (error) {
69+
if (error instanceof AuthError) {
70+
throw error;
71+
}
72+
// User may lack access to some orgs
73+
}
74+
}
75+
76+
return results;
77+
}
78+
79+
/** Column widths for repository list display */
80+
type ColumnWidths = {
81+
orgWidth: number;
82+
nameWidth: number;
83+
providerWidth: number;
84+
statusWidth: number;
85+
};
86+
87+
/**
88+
* Calculate column widths for repository list display.
89+
*/
90+
function calculateColumnWidths(repos: RepositoryWithOrg[]): ColumnWidths {
91+
const orgWidth = Math.max(...repos.map((r) => (r.orgSlug || "").length), 3);
92+
const nameWidth = Math.max(...repos.map((r) => r.name.length), 4);
93+
const providerWidth = Math.max(
94+
...repos.map((r) => r.provider.name.length),
95+
8
96+
);
97+
const statusWidth = Math.max(...repos.map((r) => r.status.length), 6);
98+
return { orgWidth, nameWidth, providerWidth, statusWidth };
99+
}
100+
101+
/**
102+
* Write the column header row for repository list output.
103+
*/
104+
function writeHeader(stdout: Writer, widths: ColumnWidths): void {
105+
const { orgWidth, nameWidth, providerWidth, statusWidth } = widths;
106+
const org = "ORG".padEnd(orgWidth);
107+
const name = "NAME".padEnd(nameWidth);
108+
const provider = "PROVIDER".padEnd(providerWidth);
109+
const status = "STATUS".padEnd(statusWidth);
110+
stdout.write(`${org} ${name} ${provider} ${status} URL\n`);
111+
}
112+
113+
type WriteRowsOptions = ColumnWidths & {
114+
stdout: Writer;
115+
repos: RepositoryWithOrg[];
116+
};
117+
118+
/**
119+
* Write formatted repository rows to stdout.
120+
*/
121+
function writeRows(options: WriteRowsOptions): void {
122+
const { stdout, repos, orgWidth, nameWidth, providerWidth, statusWidth } =
123+
options;
124+
for (const repo of repos) {
125+
const org = (repo.orgSlug || "").padEnd(orgWidth);
126+
const name = repo.name.padEnd(nameWidth);
127+
const provider = repo.provider.name.padEnd(providerWidth);
128+
const status = repo.status.padEnd(statusWidth);
129+
const url = repo.url || "";
130+
stdout.write(`${org} ${name} ${provider} ${status} ${url}\n`);
131+
}
132+
}
133+
134+
/** Result of resolving organizations to fetch repositories from */
135+
type OrgResolution = {
136+
orgs: string[];
137+
footer?: string;
138+
skippedSelfHosted?: number;
139+
};
140+
141+
/**
142+
* Resolve which organizations to fetch repositories from.
143+
* Uses CLI flag, config defaults, or DSN auto-detection.
144+
*/
145+
async function resolveOrgsToFetch(
146+
orgFlag: string | undefined,
147+
cwd: string
148+
): Promise<OrgResolution> {
149+
// 1. If positional org provided, use it directly
150+
if (orgFlag) {
151+
return { orgs: [orgFlag] };
152+
}
153+
154+
// 2. Check config defaults
155+
const defaultOrg = await getDefaultOrganization();
156+
if (defaultOrg) {
157+
return { orgs: [defaultOrg] };
158+
}
159+
160+
// 3. Auto-detect from DSNs (may find multiple in monorepos)
161+
try {
162+
const { targets, footer, skippedSelfHosted } = await resolveAllTargets({
163+
cwd,
164+
});
165+
166+
if (targets.length > 0) {
167+
const uniqueOrgs = [...new Set(targets.map((t) => t.org))];
168+
return {
169+
orgs: uniqueOrgs,
170+
footer,
171+
skippedSelfHosted,
172+
};
173+
}
174+
175+
// No resolvable targets, but may have self-hosted DSNs
176+
return { orgs: [], skippedSelfHosted };
177+
} catch (error) {
178+
// Auth errors should propagate - user needs to log in
179+
if (error instanceof AuthError) {
180+
throw error;
181+
}
182+
// Fall through to empty orgs for other errors (network, etc.)
183+
}
184+
185+
return { orgs: [] };
186+
}
187+
188+
export const listCommand = buildCommand({
189+
docs: {
190+
brief: "List repositories",
191+
fullDescription:
192+
"List repositories connected to an organization. If no organization is specified, " +
193+
"uses the default organization or lists repositories from all accessible organizations.\n\n" +
194+
"Examples:\n" +
195+
" sentry repo list # auto-detect or list all\n" +
196+
" sentry repo list my-org # list repositories in my-org\n" +
197+
" sentry repo list --limit 10\n" +
198+
" sentry repo list --json",
199+
},
200+
parameters: {
201+
positional: {
202+
kind: "tuple",
203+
parameters: [
204+
{
205+
placeholder: "org",
206+
brief: "Organization slug (optional)",
207+
parse: String,
208+
optional: true,
209+
},
210+
],
211+
},
212+
flags: {
213+
limit: {
214+
kind: "parsed",
215+
parse: numberParser,
216+
brief: "Maximum number of repositories to list",
217+
default: "30",
218+
},
219+
json: {
220+
kind: "boolean",
221+
brief: "Output JSON",
222+
default: false,
223+
},
224+
},
225+
aliases: { n: "limit" },
226+
},
227+
async func(
228+
this: SentryContext,
229+
flags: ListFlags,
230+
org?: string
231+
): Promise<void> {
232+
const { stdout, cwd } = this;
233+
234+
// Resolve which organizations to fetch from
235+
const {
236+
orgs: orgsToFetch,
237+
footer,
238+
skippedSelfHosted,
239+
} = await resolveOrgsToFetch(org, cwd);
240+
241+
// Fetch repositories from all orgs (or all accessible if none detected)
242+
let allRepos: RepositoryWithOrg[];
243+
if (orgsToFetch.length > 0) {
244+
const results = await Promise.all(
245+
orgsToFetch.map(fetchOrgRepositoriesSafe)
246+
);
247+
allRepos = results.flat();
248+
} else {
249+
allRepos = await fetchAllOrgRepositories();
250+
}
251+
252+
// Apply limit (limit is per-org when multiple orgs)
253+
const limitCount =
254+
orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit;
255+
const limited = allRepos.slice(0, limitCount);
256+
257+
if (flags.json) {
258+
writeJson(stdout, limited);
259+
return;
260+
}
261+
262+
if (limited.length === 0) {
263+
const msg =
264+
orgsToFetch.length === 1
265+
? `No repositories found in organization '${orgsToFetch[0]}'.\n`
266+
: "No repositories found.\n";
267+
stdout.write(msg);
268+
return;
269+
}
270+
271+
const widths = calculateColumnWidths(limited);
272+
writeHeader(stdout, widths);
273+
writeRows({
274+
stdout,
275+
repos: limited,
276+
...widths,
277+
});
278+
279+
if (allRepos.length > limited.length) {
280+
stdout.write(
281+
`\nShowing ${limited.length} of ${allRepos.length} repositories\n`
282+
);
283+
}
284+
285+
if (footer) {
286+
stdout.write(`\n${footer}\n`);
287+
}
288+
289+
if (skippedSelfHosted) {
290+
stdout.write(
291+
`\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` +
292+
"Specify the organization explicitly: sentry repo list <org>\n"
293+
);
294+
}
295+
296+
writeFooter(
297+
stdout,
298+
"Tip: Use 'sentry repo list <org>' to filter by organization"
299+
);
300+
},
301+
});

0 commit comments

Comments
 (0)