diff --git a/packages/server-core/src/github-repos.ts b/packages/server-core/src/github-repos.ts index 6595a20a..d9ec8471 100644 --- a/packages/server-core/src/github-repos.ts +++ b/packages/server-core/src/github-repos.ts @@ -19,6 +19,14 @@ const GHRepoListItemSchema = z.object({ export type GitHubRepoListItem = z.infer; +// REST /user/repos item shape (only the fields the picker needs). +const GHApiRepoSchema = z.object({ + full_name: z.string(), + html_url: z.string(), + description: z.string().nullable(), + updated_at: z.string(), +}); + export interface ListGitHubReposResult { repos: GitHubRepoListItem[]; authenticated: boolean; @@ -27,12 +35,53 @@ export interface ListGitHubReposResult { const REPO_LIST_LIMIT = 200; /** - * Lists the authenticated user's GitHub repos via `gh repo list`. Never - * throws — `gh` missing, not authenticated, or any other failure (network, - * malformed output) all degrade to `{ repos: [], authenticated: false }` so - * callers can render a "run `gh auth login`" hint instead of erroring. + * Lists the authenticated user's GitHub repos — personal AND org/collaborator + * repos — via the REST `/user/repos` endpoint (`gh repo list` only returns + * repos the user owns, which hides organization repos; see the affiliation + * param). `--paginate --slurp` yields a JSON array of pages. Never throws — + * `gh` missing, not authenticated, or any other failure (network, malformed + * output) all degrade to `{ repos: [], authenticated: false }` so callers can + * render a "run `gh auth login`" hint instead of erroring. */ export async function listGitHubRepos(): Promise { + try { + const { stdout } = await execWithShellEnv("gh", [ + "api", + "--paginate", + "--slurp", + "user/repos?affiliation=owner,collaborator,organization_member&sort=updated&per_page=100", + ]); + + const raw: unknown = JSON.parse(stdout); + if (!Array.isArray(raw)) { + return { repos: [], authenticated: false }; + } + + const repos: GitHubRepoListItem[] = []; + for (const page of raw) { + if (!Array.isArray(page)) continue; + for (const item of page) { + const result = GHApiRepoSchema.safeParse(item); + if (result.success) { + repos.push({ + nameWithOwner: result.data.full_name, + url: result.data.html_url, + description: result.data.description, + updatedAt: result.data.updated_at, + }); + } + } + } + + return { repos: repos.slice(0, REPO_LIST_LIMIT), authenticated: true }; + } catch { + // `gh api --slurp` needs a reasonably recent gh; fall back to the + // owner-only listing rather than losing the picker entirely. + return listOwnedGitHubRepos(); + } +} + +async function listOwnedGitHubRepos(): Promise { try { const { stdout } = await execWithShellEnv("gh", [ "repo",