Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions packages/server-core/src/github-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const GHRepoListItemSchema = z.object({

export type GitHubRepoListItem = z.infer<typeof GHRepoListItemSchema>;

// 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;
Expand All @@ -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<ListGitHubReposResult> {
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<ListGitHubReposResult> {
try {
const { stdout } = await execWithShellEnv("gh", [
"repo",
Expand Down