diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index ca68fade..374a62f6 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -58,9 +58,9 @@ const GH_VALUE_OPTIONS = new Set([ "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr", - "--search", "--inactive-hours", "--comment", "--comment-file", + "--search", "--inactive-hours", "--comment", "--comment-file", "--description", ]); -const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]); +const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]); const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; const ISSUE_SCAN_MAX_FINDINGS = 60; const ISSUE_BODY_PROFILES = { @@ -92,6 +92,7 @@ type IssueListState = typeof ISSUE_LIST_STATES[number]; type PrListState = typeof ISSUE_LIST_STATES[number]; type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; type PullRequestMergeMethod = "merge" | "squash" | "rebase"; +type RepoVisibility = "private" | "public"; type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number]; type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number]; type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; @@ -355,6 +356,9 @@ interface GitHubOptions { bodyFile?: string; comment?: string; commentFile?: string; + repoDescription?: string; + repoVisibility?: RepoVisibility; + repoAutoInit: boolean; base?: string; head?: string; jsonFields?: IssueViewJsonField[]; @@ -534,10 +538,23 @@ interface GitHubPullRequestGraphqlMetadata { interface GitHubRepository { id?: number; + name?: string; full_name?: string; private?: boolean; + html_url?: string; + clone_url?: string; + ssh_url?: string; + description?: string | null; default_branch?: string; permissions?: Record; + owner?: { login?: string }; + created_at?: string; + updated_at?: string; +} + +interface GitHubAuthenticatedUser { + login?: string; + id?: number; } interface GitHubBranch { @@ -774,6 +791,15 @@ function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues { }; } +function parseRepoVisibility(args: string[]): RepoVisibility | undefined { + const privateRequested = hasFlag(args, "--private"); + const publicRequested = hasFlag(args, "--public"); + if (privateRequested && publicRequested) throw new Error("gh repo create accepts either --private or --public, not both"); + if (privateRequested) return "private"; + if (publicRequested) return "public"; + return undefined; +} + function validateKnownOptions(args: string[]): void { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -807,6 +833,22 @@ function isOwnerRepo(value: string): boolean { function resolveRepoOption(args: string[]): string { const [top, sub] = args; const explicitRepo = optionValue(args, "--repo"); + if (top === "repo" && (sub === "view" || sub === "create")) { + const positionals = positionalArgs(args.slice(2)); + if (positionals.length > 1) { + throw new Error(`gh repo ${sub} accepts at most one positional owner/repo target`); + } + const positionalRepo = positionals[0]; + if (positionalRepo !== undefined) { + if (!isOwnerRepo(positionalRepo)) { + throw new Error(`gh repo ${sub} positional argument must be owner/repo`); + } + if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { + throw new Error(`gh repo ${sub} received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target`); + } + return positionalRepo; + } + } if ((top === "issue" || top === "pr") && sub === "list") { const positionals = positionalArgs(args.slice(2)); if (positionals.length > 1) { @@ -855,6 +897,9 @@ function parseOptions(args: string[]): GitHubOptions { bodyFile: optionValue(args, "--body-file"), comment: optionValue(args, "--comment"), commentFile: optionValue(args, "--comment-file"), + repoDescription: optionValue(args, "--description"), + repoVisibility: parseRepoVisibility(args), + repoAutoInit: hasFlag(args, "--auto-init"), base: optionValue(args, "--base"), head: optionValue(args, "--head"), jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, @@ -2244,6 +2289,110 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev return summary; } +function repoSummary(repo: GitHubRepository): Record { + return { + id: repo.id ?? null, + name: repo.name ?? null, + fullName: repo.full_name ?? null, + owner: repo.owner?.login ?? null, + private: repo.private ?? null, + visibility: repo.private === true ? "private" : repo.private === false ? "public" : "unknown", + description: repo.description ?? null, + defaultBranch: repo.default_branch ?? null, + url: repo.html_url ?? null, + cloneUrl: repo.clone_url ?? null, + sshUrl: repo.ssh_url ?? null, + createdAt: repo.created_at ?? null, + updatedAt: repo.updated_at ?? null, + permissions: repo.permissions ?? null, + }; +} + +async function repoView(repo: string, token: string): Promise { + const { owner, name } = repoParts(repo); + const result = await githubRequest(token, "GET", `/repos/${owner}/${name}`); + if (isGitHubError(result)) return commandError("repo view", repo, result); + return { + ok: true, + command: "repo view", + repo, + repository: repoSummary(result), + request: { + method: "GET", + path: `/repos/${owner}/${name}`, + }, + rest: true, + }; +} + +async function repoCreate(repo: string, token: string, options: GitHubOptions): Promise { + const { owner, name } = repoParts(repo); + const visibility = options.repoVisibility ?? "private"; + const viewer = options.dryRun ? null : await githubRequest(token, "GET", "/user"); + if (isGitHubError(viewer)) return commandError("repo create", repo, viewer, { phase: "viewer" }); + const viewerLogin = viewer?.login ?? null; + const createPath = viewerLogin !== null && viewerLogin.toLowerCase() === owner.toLowerCase() + ? "/user/repos" + : `/orgs/${owner}/repos`; + const payload: Record = { + name, + private: visibility === "private", + auto_init: options.repoAutoInit, + }; + if (options.repoDescription !== undefined) payload.description = options.repoDescription; + const planned = { + repo, + owner, + name, + visibility, + description: options.repoDescription ?? null, + autoInit: options.repoAutoInit, + request: { + method: "POST", + path: createPath, + body: { + name, + private: visibility === "private", + descriptionChars: options.repoDescription?.length ?? 0, + auto_init: options.repoAutoInit, + }, + }, + }; + if (options.dryRun) { + return { + ok: true, + command: "repo create", + repo, + dryRun: true, + planned, + note: "Dry-run only; no GitHub repository was created.", + }; + } + const existing = await githubRequest(token, "GET", `/repos/${owner}/${name}`); + if (!isGitHubError(existing)) { + return validationError("repo create", repo, "repository already exists; refusing duplicate create", { + repository: repoSummary(existing), + planned, + next: { + command: `bun scripts/cli.ts gh repo view ${repo}`, + }, + }); + } + if (existing.degradedReason !== "repo-not-found") { + return commandError("repo create", repo, existing, { phase: "preflight-existing-repo", planned }); + } + const created = await githubRequest(token, "POST", createPath, payload); + if (isGitHubError(created)) return commandError("repo create", repo, created, { planned }); + return { + ok: true, + command: "repo create", + repo, + repository: repoSummary(created), + planned, + rest: true, + }; +} + function issueLifecycleSummary(issue: GitHubIssue): Record { return { id: issue.id, @@ -6533,6 +6682,8 @@ export function ghHelp(): unknown { output: "json", usage: [ "bun scripts/cli.ts gh auth status [--repo owner/name]", + "bun scripts/cli.ts gh repo view |--repo owner/name", + "bun scripts/cli.ts gh repo create |--repo owner/name [--private|--public] [--description text] [--auto-init] [--dry-run]", "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]", "bun scripts/cli.ts gh issue view [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]", "bun scripts/cli.ts gh issue read [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", @@ -6575,6 +6726,7 @@ export function ghHelp(): unknown { defaults: { repo: DEFAULT_REPO }, notes: [ "Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.", + "repo view/create use GitHub REST through the same token path. repo create defaults to private repositories, preflights existing repos, supports --dry-run, and refuses duplicate creation.", "Token values are never printed; auth status reports only token source and presence.", "issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.", "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", @@ -6734,9 +6886,31 @@ export async function runGhCommand(args: string[]): Promise value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments"); } + if ((optionWasProvided(args, "--description") || optionWasProvided(args, "--private") || optionWasProvided(args, "--public") || optionWasProvided(args, "--auto-init")) && !(top === "repo" && sub === "create")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--description, --private, --public, and --auto-init are only supported by gh repo create"); + } if (top === "auth" && sub === "status") return authStatus(options.repo); + if (top === "repo") { + if (sub !== "view" && sub !== "create") { + return unsupportedCommand(`repo ${sub ?? ""}`.trim(), options.repo, "repo supported commands are view and create.", { + supportedCommands: [ + `bun scripts/cli.ts gh repo view ${options.repo}`, + `bun scripts/cli.ts gh repo create ${options.repo} --private --dry-run`, + ], + }); + } + if (sub === "create" && options.dryRun) return repoCreate(options.repo, "", options); + const { token, probe } = resolveToken(true); + const commandName = `repo ${sub}`; + const missing = authRequired(options.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + if (sub === "view") return repoView(options.repo, token); + return repoCreate(options.repo, token, options); + } + if (top === "preflight") { const resolved = resolvePositionalPrReference(args, 1, "preflight", options); if (isGitHubCommandResult(resolved)) return resolved;