feat: add GitHub repo create CLI

This commit is contained in:
Codex
2026-06-05 10:10:13 +00:00
parent 096eb0cb9d
commit 171325f48d
+176 -2
View File
@@ -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<string, boolean>;
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<string, unknown> {
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<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const result = await githubRequest<GitHubRepository>(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<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const visibility = options.repoVisibility ?? "private";
const viewer = options.dryRun ? null : await githubRequest<GitHubAuthenticatedUser>(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<string, unknown> = {
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<GitHubRepository>(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<GitHubRepository>(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<string, unknown> {
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 <owner/repo>|--repo owner/name",
"bun scripts/cli.ts gh repo create <owner/repo>|--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 <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--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<GitHubCommandResult
const command = [top, sub].filter((value): value is string => 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;