feat: add GitHub repo create CLI
This commit is contained in:
+176
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user