feat(cli): unify gh issue and pr crud
This commit is contained in:
+270
-49
@@ -15,7 +15,9 @@ const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
|
||||
const COMMANDER_BRIEF_TARGET_ISSUE = 24;
|
||||
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
|
||||
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
|
||||
const PR_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const;
|
||||
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
|
||||
const BODY_UPDATE_MODES = ["replace", "append"] as const;
|
||||
const ISSUE_BODY_PROFILES = {
|
||||
"code-queue-board": {
|
||||
label: "Code Queue long board issue #20",
|
||||
@@ -31,7 +33,9 @@ const ISSUE_BODY_PROFILES = {
|
||||
|
||||
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
|
||||
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
|
||||
type PrJsonField = typeof PR_JSON_FIELDS[number];
|
||||
type IssueListState = typeof ISSUE_LIST_STATES[number];
|
||||
type BodyUpdateMode = typeof BODY_UPDATE_MODES[number];
|
||||
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
|
||||
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
|
||||
|
||||
@@ -134,7 +138,9 @@ interface GitHubOptions {
|
||||
head?: string;
|
||||
jsonFields?: IssueViewJsonField[];
|
||||
issueListJsonFields?: IssueListJsonField[];
|
||||
prJsonFields?: PrJsonField[];
|
||||
listState: IssueListState;
|
||||
mode: BodyUpdateMode;
|
||||
expectUpdatedAt?: string;
|
||||
expectBodySha?: string;
|
||||
bodyProfile: IssueBodyProfileOption;
|
||||
@@ -269,12 +275,22 @@ function parseIssueListJsonFields(requested: string[] | undefined): IssueListJso
|
||||
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
|
||||
}
|
||||
|
||||
function parsePrJsonFields(command: string, requested: string[] | undefined): PrJsonField[] | undefined {
|
||||
return validateJsonFields(command, requested, PR_JSON_FIELDS);
|
||||
}
|
||||
|
||||
function parseIssueListState(args: string[]): IssueListState {
|
||||
const raw = optionValue(args, "--state") ?? "open";
|
||||
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
|
||||
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
|
||||
}
|
||||
|
||||
function parseBodyUpdateMode(args: string[]): BodyUpdateMode {
|
||||
const raw = optionValue(args, "--mode") ?? "replace";
|
||||
if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode;
|
||||
throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`);
|
||||
}
|
||||
|
||||
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
|
||||
const raw = optionValue(args, "--body-profile") ?? "auto";
|
||||
if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw;
|
||||
@@ -282,7 +298,7 @@ function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
|
||||
}
|
||||
|
||||
function validateKnownOptions(args: string[]): void {
|
||||
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
|
||||
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
|
||||
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
@@ -314,7 +330,9 @@ function parseOptions(args: string[]): GitHubOptions {
|
||||
head: optionValue(args, "--head"),
|
||||
jsonFields: top === "issue" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
|
||||
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
|
||||
prJsonFields: top === "pr" && (sub === "view" || sub === "list") ? parsePrJsonFields(`gh pr ${sub}`, requestedJsonFields) : undefined,
|
||||
listState: parseIssueListState(args),
|
||||
mode: parseBodyUpdateMode(args),
|
||||
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
|
||||
expectBodySha: optionValue(args, "--expect-body-sha"),
|
||||
bodyProfile: parseIssueBodyProfile(args),
|
||||
@@ -1110,6 +1128,13 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function selectedPrJson(pr: GitHubPullRequest, fields: PrJsonField[]): Record<string, unknown> {
|
||||
const summary = prSummary(pr) as Record<PrJsonField, unknown>;
|
||||
const selected: Record<string, unknown> = {};
|
||||
for (const field of fields) selected[field] = summary[field];
|
||||
return selected;
|
||||
}
|
||||
|
||||
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
|
||||
return {
|
||||
id: repo.id ?? null,
|
||||
@@ -1217,6 +1242,25 @@ function prCommentPlannedOperation(repo: string, issueNumber: number, body: stri
|
||||
};
|
||||
}
|
||||
|
||||
function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> {
|
||||
const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body;
|
||||
return {
|
||||
command,
|
||||
repo,
|
||||
number,
|
||||
mode,
|
||||
bodySource,
|
||||
input: bodySafetySignals(body),
|
||||
existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) },
|
||||
finalBody: {
|
||||
bodyChars: finalBody.length,
|
||||
bodyPreview: preview(finalBody),
|
||||
bodyPreviewLines: previewLines(finalBody),
|
||||
...bodySafetySignals(finalBody),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
|
||||
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
|
||||
@@ -1319,11 +1363,11 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
const { owner, name } = repoParts(repo);
|
||||
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
|
||||
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
|
||||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||||
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr comment",
|
||||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||||
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr comment create",
|
||||
repo,
|
||||
issueNumber,
|
||||
pr: prSummary(prResult),
|
||||
@@ -1342,6 +1386,65 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
};
|
||||
}
|
||||
|
||||
async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
let bodySource: { body: string; bodySource: Record<string, unknown> };
|
||||
try {
|
||||
bodySource = readMarkdownBody(options, "pr update");
|
||||
} catch (error) {
|
||||
return validationError("pr update", repo, error instanceof Error ? error.message : String(error), { number });
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
let oldPr: GitHubPullRequest | null = null;
|
||||
if (token.length > 0 && (options.mode === "append" || !options.dryRun)) {
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(pr)) return commandError("pr update", repo, pr, { number, phase: "read-before-update" });
|
||||
oldPr = pr;
|
||||
}
|
||||
const finalBody = options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body;
|
||||
const planned = bodyUpdatePlan("pr update", repo, number, options.mode, bodySource.body, bodySource.bodySource, oldPr?.body ?? null);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr update",
|
||||
repo,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
number,
|
||||
title: options.title ?? null,
|
||||
...planned,
|
||||
request: {
|
||||
method: "PATCH",
|
||||
path: `/repos/{owner}/{repo}/pulls/${number}`,
|
||||
body: { title: options.title ?? null, bodyChars: finalBody.length },
|
||||
},
|
||||
};
|
||||
}
|
||||
const payload: Record<string, unknown> = { body: finalBody };
|
||||
if (options.title !== undefined) payload.title = options.title;
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload);
|
||||
if (isGitHubError(pr)) return commandError("pr update", repo, pr, { number, planned });
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr update",
|
||||
repo,
|
||||
number,
|
||||
mode: options.mode,
|
||||
pullRequest: prSummary(pr),
|
||||
update: planned,
|
||||
request: { method: "PATCH", path: `/repos/${owner}/${name}/pulls/${number}`, title: options.title ?? null, bodyChars: finalBody.length },
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
const command = state === "closed" ? "pr close" : "pr reopen";
|
||||
if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } };
|
||||
const { owner, name } = repoParts(repo);
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state });
|
||||
if (isGitHubError(pr)) return commandError(command, repo, pr, { number });
|
||||
return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true };
|
||||
}
|
||||
|
||||
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
|
||||
@@ -1427,30 +1530,37 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
|
||||
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true };
|
||||
}
|
||||
|
||||
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const body = readBodyFile(options.bodyFile, "issue edit");
|
||||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
|
||||
if (bodyGuard !== null) return bodyGuard;
|
||||
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
|
||||
const body = readBodyFile(options.bodyFile, commandName);
|
||||
if (options.mode === "replace") {
|
||||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
|
||||
if (bodyGuard !== null) return bodyGuard;
|
||||
}
|
||||
const concurrencyOptionError = assertConcurrencyOptions(options);
|
||||
if (concurrencyOptionError !== null) return concurrencyOptionError;
|
||||
let oldIssue: GitHubIssue | null = null;
|
||||
let briefDiff: CommanderBriefDiff | null = null;
|
||||
const claudeQqConfig = commanderBriefClaudeQqConfig();
|
||||
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
|
||||
return validationError("issue edit", repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
|
||||
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
|
||||
}
|
||||
const needsReadBeforeEdit = !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
|
||||
const needsReadBeforeEdit = options.mode === "append" || !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined);
|
||||
if (needsReadBeforeEdit) {
|
||||
const issue = await getIssue(token, repo, issueNumber);
|
||||
if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber, phase: "read-before-edit" });
|
||||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
|
||||
oldIssue = issue;
|
||||
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
|
||||
if (concurrencyError !== null) return concurrencyError;
|
||||
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", body);
|
||||
if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body);
|
||||
}
|
||||
const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body;
|
||||
if (options.mode === "append") {
|
||||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options);
|
||||
if (bodyGuard !== null) return bodyGuard;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", body) : null;
|
||||
const guard = issueEditGuardSummary(issueNumber, body, options);
|
||||
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
|
||||
const guard = issueEditGuardSummary(issueNumber, finalBody, options);
|
||||
let dryRunOldBody: Record<string, unknown> = {
|
||||
fetched: false,
|
||||
bodyChars: null,
|
||||
@@ -1485,17 +1595,19 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: "issue edit",
|
||||
command: commandName,
|
||||
repo,
|
||||
dryRun: true,
|
||||
issueNumber,
|
||||
...dryRunBody(repo, options.title, body),
|
||||
mode: options.mode,
|
||||
...dryRunBody(repo, options.title, finalBody),
|
||||
guard,
|
||||
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, { kind: "body-file", path: options.bodyFile ?? null }, oldIssue?.body ?? null),
|
||||
bodyOnlySafety: {
|
||||
oldBody: dryRunOldBody,
|
||||
newBody: {
|
||||
bodyChars: body.length,
|
||||
bodySha: bodySha(body),
|
||||
bodyChars: finalBody.length,
|
||||
bodySha: bodySha(finalBody),
|
||||
},
|
||||
},
|
||||
concurrency: {
|
||||
@@ -1507,7 +1619,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile },
|
||||
...(options.notifyClaudeQqBriefDiff
|
||||
? {
|
||||
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, body, dryRunDiff ?? commanderBriefDiff("", body), claudeQqConfig),
|
||||
commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig),
|
||||
dryRunOldBodySource: "not-fetched",
|
||||
dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body",
|
||||
dryRunNoWrite: true,
|
||||
@@ -1517,17 +1629,17 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
};
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const payload: Record<string, unknown> = { body };
|
||||
const payload: Record<string, unknown> = { body: finalBody };
|
||||
if (options.title !== undefined) payload.title = options.title;
|
||||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
|
||||
if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber });
|
||||
const guard = issueEditGuardSummary(issueNumber, body, options);
|
||||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
|
||||
const guard = issueEditGuardSummary(issueNumber, finalBody, options);
|
||||
const concurrency = oldIssue === null
|
||||
? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }
|
||||
: { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null };
|
||||
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), guard, concurrency, rest: true };
|
||||
if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: commandName, repo, issue: issueSummary(issue), mode: options.mode, guard, concurrency, rest: true };
|
||||
|
||||
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", body);
|
||||
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
|
||||
const claudeqq = diff.ok
|
||||
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
|
||||
: {
|
||||
@@ -1539,7 +1651,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
} satisfies ClaudeQqSendResult;
|
||||
return {
|
||||
ok: true,
|
||||
command: "issue edit",
|
||||
command: commandName,
|
||||
repo,
|
||||
issue: issueSummary(issue),
|
||||
guard,
|
||||
@@ -1567,11 +1679,38 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
|
||||
|
||||
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const body = readBodyFile(options.bodyFile, "issue comment");
|
||||
if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) };
|
||||
if (options.dryRun) return { ok: true, command: "issue comment create", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) };
|
||||
const { owner, name } = repoParts(repo);
|
||||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||||
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
|
||||
return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true };
|
||||
return { ok: true, command: "issue comment create", repo, comment: commentSummary(comment), rest: true };
|
||||
}
|
||||
|
||||
async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
const command = `${ownerKind} comment delete`;
|
||||
const { owner, name } = repoParts(repo);
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
commentId,
|
||||
request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` },
|
||||
};
|
||||
}
|
||||
const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`);
|
||||
if (isGitHubError(result)) return commandError(command, repo, result, { commentId });
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
commentId,
|
||||
deleted: true,
|
||||
request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` },
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
@@ -1722,20 +1861,23 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function prList(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
|
||||
async function prList(repo: string, token: string, limit: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
|
||||
if (isGitHubError(prs)) return commandError("pr list", repo, prs);
|
||||
const fields = jsonFields ?? PR_JSON_FIELDS.slice();
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr list",
|
||||
repo,
|
||||
plannedScope: "read-only REST support",
|
||||
pullRequests: prs.map(prSummary),
|
||||
limit,
|
||||
count: prs.length,
|
||||
jsonFields: fields,
|
||||
pullRequests: prs.map((pr) => selectedPrJson(pr, fields)),
|
||||
};
|
||||
}
|
||||
|
||||
async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> {
|
||||
async function prView(repo: string, token: string, number: number, jsonFields: PrJsonField[] | undefined): Promise<GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number });
|
||||
@@ -1743,8 +1885,8 @@ async function prView(repo: string, token: string, number: number): Promise<GitH
|
||||
ok: true,
|
||||
command: "pr view",
|
||||
repo,
|
||||
plannedScope: "read-only REST support",
|
||||
pullRequest: prSummary(pr),
|
||||
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(pr, jsonFields) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1757,29 +1899,39 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
|
||||
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]",
|
||||
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
|
||||
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
|
||||
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]",
|
||||
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment create <number> --body-file <file> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
|
||||
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]",
|
||||
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]",
|
||||
"bun scripts/cli.ts gh pr view <number> [--repo owner/name]",
|
||||
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
|
||||
"bun scripts/cli.ts gh pr view <number> [--repo owner/name] [--json body,title,state,head,base,draft]",
|
||||
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title title] [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||||
],
|
||||
defaults: { repo: DEFAULT_REPO },
|
||||
notes: [
|
||||
"Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
|
||||
"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.",
|
||||
"Token values are never printed; auth status reports only token source and presence.",
|
||||
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
|
||||
"issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
|
||||
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
|
||||
"issue edit --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.",
|
||||
"issue edit dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
|
||||
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
|
||||
"issue edit is a compatibility alias for issue update --mode replace.",
|
||||
"issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.",
|
||||
"issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.",
|
||||
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
|
||||
"issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.",
|
||||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||||
"PR create/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
|
||||
"comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1808,16 +1960,41 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
}
|
||||
if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--json field selection is only supported by gh issue view and gh issue list");
|
||||
if (!(top === "pr" && (sub === "view" || sub === "list"))) {
|
||||
return validationError(command, options.repo, "--json field selection is only supported by gh issue view/list and gh pr view/list");
|
||||
}
|
||||
}
|
||||
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && sub === "edit")) {
|
||||
if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && sub === "update"))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue edit");
|
||||
return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update");
|
||||
}
|
||||
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && (sub === "edit" || sub === "update"))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit");
|
||||
}
|
||||
|
||||
if (top === "auth" && sub === "status") return authStatus(options.repo);
|
||||
|
||||
if (top === "issue") {
|
||||
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
|
||||
if (sub === "comment" && third === "delete") {
|
||||
const commentId = parseNumberForCommand(options.repo, args[3], "issue comment delete");
|
||||
if (typeof commentId !== "number") return commentId;
|
||||
if (options.dryRun) return commentDelete(options.repo, "", "issue", commentId, true);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "issue comment delete", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return commentDelete(options.repo, token, "issue", commentId, false);
|
||||
}
|
||||
if (sub === "comment" && third === "create") {
|
||||
const issueNumber = parseNumberForCommand(options.repo, args[3], "issue comment create");
|
||||
if (typeof issueNumber !== "number") return issueNumber;
|
||||
if (options.dryRun) return issueComment(options.repo, "", issueNumber, options);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "issue comment create", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return issueComment(options.repo, token, issueNumber, options);
|
||||
}
|
||||
if (options.dryRun) {
|
||||
if (sub === "create") return issueCreate(options.repo, "", options);
|
||||
if (sub === "edit") {
|
||||
@@ -1825,6 +2002,11 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
const { token } = resolveToken(false);
|
||||
return issueEdit(options.repo, token ?? "", issueNumber, options);
|
||||
}
|
||||
if (sub === "update") {
|
||||
const issueNumber = parseNumber(third, "issue update");
|
||||
const { token } = resolveToken(false);
|
||||
return issueEdit(options.repo, token ?? "", issueNumber, options, "issue update");
|
||||
}
|
||||
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
|
||||
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
|
||||
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
|
||||
@@ -1837,13 +2019,34 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields);
|
||||
if (sub === "create") return issueCreate(options.repo, token, options);
|
||||
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
|
||||
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");
|
||||
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
|
||||
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
|
||||
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
|
||||
if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics.");
|
||||
if (sub === "scan-escape") return issueScanEscape(options.repo, token, options.limit);
|
||||
}
|
||||
|
||||
if (top === "pr") {
|
||||
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
|
||||
if (sub === "comment" && third === "delete") {
|
||||
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete");
|
||||
if (typeof commentId !== "number") return commentId;
|
||||
if (options.dryRun) return commentDelete(options.repo, "", "pr", commentId, true);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "pr comment delete", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return commentDelete(options.repo, token, "pr", commentId, false);
|
||||
}
|
||||
if (sub === "comment" && third === "create") {
|
||||
const number = parseNumberForCommand(options.repo, args[3], "pr comment create");
|
||||
if (typeof number !== "number") return number;
|
||||
if (options.dryRun) return prComment(options.repo, "", number, options);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "pr comment create", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return prComment(options.repo, token, number, options);
|
||||
}
|
||||
if (sub === "create") {
|
||||
if (options.dryRun) return prCreate(options.repo, "", options);
|
||||
const { token, probe } = resolveToken(true);
|
||||
@@ -1864,17 +2067,35 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (typeof number !== "number") return number;
|
||||
return prComment(options.repo, token, number, options);
|
||||
}
|
||||
if (sub === "update") {
|
||||
const number = parseNumberForCommand(options.repo, third, "pr update");
|
||||
if (typeof number !== "number") return number;
|
||||
if (options.dryRun && options.mode === "replace") return prUpdate(options.repo, "", number, options);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, "pr update", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr update", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return prUpdate(options.repo, token, number, options);
|
||||
}
|
||||
if (sub === "close" || sub === "reopen") {
|
||||
const number = parseNumberForCommand(options.repo, third, `pr ${sub}`);
|
||||
if (typeof number !== "number") return number;
|
||||
if (options.dryRun) return prState(options.repo, "", number, sub === "close" ? "closed" : "open", true);
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, `pr ${sub}`, probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||||
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
|
||||
}
|
||||
if (sub === "merge") {
|
||||
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
|
||||
}
|
||||
if (sub !== "list" && sub !== "view") {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, comment, and unsupported merge.");
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, update, close, reopen, comment create/delete, and unsupported merge/delete.");
|
||||
}
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, `pr ${sub}`, probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||||
if (sub === "list") return prList(options.repo, token, options.limit);
|
||||
return prView(options.repo, token, parseNumber(third, "pr view"));
|
||||
if (sub === "list") return prList(options.repo, token, options.limit, options.prJsonFields);
|
||||
return prView(options.repo, token, parseNumber(third, "pr view"), options.prJsonFields);
|
||||
}
|
||||
|
||||
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
|
||||
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
|
||||
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
|
||||
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR list/view/create/comment operations through REST with body-file support, token diagnostics, escape scanning, and merge blocked." },
|
||||
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." },
|
||||
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
|
||||
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
|
||||
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
|
||||
|
||||
Reference in New Issue
Block a user