refactor: split github cli implementation (#908)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
||||
// Moved mechanically from scripts/src/gh.ts:6758-7146.
|
||||
|
||||
import path from "node:path";
|
||||
import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety";
|
||||
import { assertConcurrencyOptions, issueEditGuardSummary, issueProfileNeedsMetadata, validateIssueBodyGuard, validateIssueConcurrency } from "./body-guards";
|
||||
import { applyVirtualBodyPatch, bodyPatchErrorSummary, readBodyPatch, readMarkdownBody, readMarkdownBodyFileOrStdin } from "./body-input";
|
||||
import { commanderBriefDiff, dryRunBody } from "./body-profiles";
|
||||
import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueWriteDisclosure, runnerDisposition, validationError, writeBodyPlan } from "./client";
|
||||
import { getIssue } from "./issue-read";
|
||||
import { issueLabelNames, issueSummary } from "./issue-summary";
|
||||
import { commanderBriefClaudeQqConfig, commanderBriefNotificationPlan, isRecord, maskedTarget, sendCommanderBriefClaudeQq } from "./notify-claudeqq";
|
||||
import { ghShort, ghTable, ghText } from "./render";
|
||||
import { bodyUpdatePlan } from "./repo-compare";
|
||||
import { COMMANDER_BRIEF_TARGET_ISSUE } from "./types";
|
||||
import type { ClaudeQqSendResult, CommanderBriefDiff, GitHubCommandResult, GitHubIssue, GitHubOptions, IssueProfileValidationContext } from "./types";
|
||||
|
||||
export async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
if (options.title === undefined) return withIssueCreateRendered(validationError("issue create", repo, "issue create requires --title <title>"));
|
||||
if (options.body !== undefined) {
|
||||
return withIssueCreateRendered(validationError("issue create", repo, "issue create does not support --body; use --body-stdin with a quoted heredoc for Markdown", {
|
||||
title: options.title,
|
||||
bodySource: "inline",
|
||||
}));
|
||||
}
|
||||
const { body, bodySource } = readMarkdownBody({
|
||||
...options,
|
||||
body: undefined,
|
||||
}, "issue create");
|
||||
const labels = options.labels;
|
||||
if (options.dryRun) {
|
||||
return withIssueCreateRendered({
|
||||
ok: true,
|
||||
command: "issue create",
|
||||
repo,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
title: options.title,
|
||||
labels,
|
||||
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
|
||||
});
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const payload: Record<string, unknown> = { title: options.title, body };
|
||||
if (labels.length > 0) payload.labels = labels;
|
||||
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload);
|
||||
if (isGitHubError(issue)) return withIssueCreateRendered(commandError("issue create", repo, issue, { title: options.title, labels }));
|
||||
const appliedLabels = issueLabelNames(issue);
|
||||
const missingLabels = labels.filter((label) => !appliedLabels.includes(label));
|
||||
if (missingLabels.length > 0) {
|
||||
return withIssueCreateRendered(validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", {
|
||||
issue: issueSummary(issue),
|
||||
requestedLabels: labels,
|
||||
appliedLabels,
|
||||
missingLabels,
|
||||
}));
|
||||
}
|
||||
return withIssueCreateRendered({ ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true });
|
||||
}
|
||||
|
||||
export function withIssueCreateRendered(result: GitHubCommandResult): GitHubCommandResult {
|
||||
return {
|
||||
...result,
|
||||
contentType: "text/plain",
|
||||
renderedText: renderIssueCreateTable(result),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderIssueCreateTable(result: GitHubCommandResult): string {
|
||||
const issue = isRecord(result.issue) ? result.issue : {};
|
||||
const number = ghText(issue.number ?? result.number);
|
||||
const title = ghText(issue.title ?? result.title);
|
||||
const ok = result.ok !== false;
|
||||
const details = isRecord(result.details) ? result.details : {};
|
||||
const status = ok ? (result.dryRun === true ? "dry-run" : "created") : "failed";
|
||||
const reason = ok ? "-" : ghText(result.degradedReason ?? details.degradedReason ?? "validation-failed");
|
||||
const message = ghText(result.message ?? details.message);
|
||||
const labels = Array.isArray(result.labels) ? result.labels.map(String).join(",") : ghText(result.labels);
|
||||
const lines = [
|
||||
`gh issue create (${status})`,
|
||||
"",
|
||||
ghTable(["ISSUE", "STATUS", "REASON", "LABELS", "TITLE"], [[
|
||||
number === "-" ? "-" : `#${number}`,
|
||||
status,
|
||||
ghShort(reason, 32),
|
||||
ghShort(labels, 40),
|
||||
ghShort(title, 96),
|
||||
]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` repo=${ghText(result.repo)} url=${ghText(issue.url ?? result.url)}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
if (!ok) {
|
||||
const nextIndex = lines.indexOf("Next:");
|
||||
if (nextIndex >= 0) lines.splice(nextIndex, 0, ` message=${ghShort(message, 160)}`, "");
|
||||
lines.push(" use --body-stdin with a quoted heredoc for Markdown issue bodies");
|
||||
} else if (status === "created" && number !== "-") {
|
||||
lines.push(` bun scripts/cli.ts gh issue view ${number} --repo ${ghText(result.repo)}`);
|
||||
} else {
|
||||
lines.push(" rerun without --dry-run to create the issue");
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use gh issue view --full for structured metadata.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function issuePatch(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const commandName = "issue patch";
|
||||
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
|
||||
if (concurrencyOptionError !== null) return concurrencyOptionError;
|
||||
let patchInput: { patch: string; patchSource: Record<string, unknown> };
|
||||
try {
|
||||
patchInput = readBodyPatch(options, commandName);
|
||||
} catch (error) {
|
||||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||||
}
|
||||
const { patch, patchSource } = patchInput;
|
||||
const oldIssue = await getIssue(token, repo, issueNumber);
|
||||
if (isGitHubError(oldIssue)) return commandError(commandName, repo, oldIssue, { issueNumber, phase: "read-before-patch" });
|
||||
const concurrencyError = validateIssueConcurrency(repo, issueNumber, oldIssue, options, commandName);
|
||||
if (concurrencyError !== null) return concurrencyError;
|
||||
const oldBody = oldIssue.body ?? "";
|
||||
let patched: { newBody: string; patchSummary: Record<string, unknown> };
|
||||
try {
|
||||
patched = applyVirtualBodyPatch("issue.md", oldBody, patch);
|
||||
} catch (error) {
|
||||
return validationError(commandName, repo, "body patch failed; no GitHub write performed", {
|
||||
issueNumber,
|
||||
patchSource,
|
||||
patchError: bodyPatchErrorSummary(error),
|
||||
oldBody: {
|
||||
bodyChars: oldBody.length,
|
||||
bodySha: bodySha(oldBody),
|
||||
updatedAt: oldIssue.updated_at ?? null,
|
||||
},
|
||||
noWrite: true,
|
||||
});
|
||||
}
|
||||
const profileContext: IssueProfileValidationContext = { issueTitle: oldIssue.title, issueBody: oldIssue.body, issueMetadataFetched: true };
|
||||
const guard = validateIssueBodyGuard(repo, issueNumber, patched.newBody, options, profileContext, commandName, patchSource);
|
||||
if (guard !== null) return guard;
|
||||
const guardSummary = issueEditGuardSummary(issueNumber, patched.newBody, options, profileContext);
|
||||
const bodyChange = {
|
||||
oldBodyChars: oldBody.length,
|
||||
newBodyChars: patched.newBody.length,
|
||||
deltaChars: patched.newBody.length - oldBody.length,
|
||||
oldBodySha: bodySha(oldBody),
|
||||
newBodySha: bodySha(patched.newBody),
|
||||
oldUpdatedAt: oldIssue.updated_at ?? null,
|
||||
};
|
||||
const concurrency = {
|
||||
checked: true,
|
||||
oldIssueUpdatedAt: oldIssue.updated_at ?? null,
|
||||
oldBodySha: bodySha(oldBody),
|
||||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||||
expectBodySha: options.expectBodySha ?? null,
|
||||
};
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command: commandName,
|
||||
repo,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
issueNumber,
|
||||
patchSource,
|
||||
patch: patched.patchSummary,
|
||||
...dryRunBody(repo, undefined, patched.newBody),
|
||||
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
guard: guardSummary,
|
||||
concurrency: { ...concurrency, dryRunNoWrite: true },
|
||||
bodyChange,
|
||||
wouldPatch: {
|
||||
issueNumber,
|
||||
bodyChars: patched.newBody.length,
|
||||
bodySha: bodySha(patched.newBody),
|
||||
method: "PATCH",
|
||||
path: `/repos/{owner}/{repo}/issues/${issueNumber}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { body: patched.newBody });
|
||||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
|
||||
return {
|
||||
ok: true,
|
||||
command: commandName,
|
||||
repo,
|
||||
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
|
||||
patchSource,
|
||||
patch: patched.patchSummary,
|
||||
bodyChange: {
|
||||
...bodyChange,
|
||||
newUpdatedAt: issue.updated_at ?? null,
|
||||
},
|
||||
guard: guardSummary,
|
||||
concurrency,
|
||||
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
request: {
|
||||
method: "PATCH",
|
||||
path: `/repos/${owner}/${name}/issues/${issueNumber}`,
|
||||
bodyChars: patched.newBody.length,
|
||||
},
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
|
||||
if (options.bodyFile === undefined) {
|
||||
return validationError(commandName, repo, `${commandName} requires --body-stdin or --body-file <file|->`, { issueNumber });
|
||||
}
|
||||
let bodyInput: { body: string; bodySource: Record<string, unknown> };
|
||||
try {
|
||||
bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile);
|
||||
} catch (error) {
|
||||
return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
||||
}
|
||||
const { body, bodySource } = bodyInput;
|
||||
const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile);
|
||||
if (options.mode === "replace" && !needsProfileMetadata) {
|
||||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options);
|
||||
if (bodyGuard !== null) return bodyGuard;
|
||||
}
|
||||
const concurrencyOptionError = assertConcurrencyOptions(options, commandName);
|
||||
if (concurrencyOptionError !== null) return concurrencyOptionError;
|
||||
let oldIssue: GitHubIssue | null = null;
|
||||
let briefDiff: CommanderBriefDiff | null = null;
|
||||
let profileContext: IssueProfileValidationContext = {};
|
||||
const claudeQqConfig = commanderBriefClaudeQqConfig();
|
||||
if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) {
|
||||
return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber });
|
||||
}
|
||||
const needsReadBeforeEdit = options.mode === "append"
|
||||
|| needsProfileMetadata && token.length > 0
|
||||
|| !options.dryRun;
|
||||
if (needsReadBeforeEdit) {
|
||||
const issue = await getIssue(token, repo, issueNumber);
|
||||
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" });
|
||||
oldIssue = issue;
|
||||
profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true };
|
||||
const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options);
|
||||
if (concurrencyError !== null) return concurrencyError;
|
||||
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" || needsProfileMetadata) {
|
||||
const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext);
|
||||
if (bodyGuard !== null) return bodyGuard;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null;
|
||||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||||
let dryRunOldBody: Record<string, unknown> = {
|
||||
fetched: false,
|
||||
bodyChars: null,
|
||||
bodySha: null,
|
||||
updatedAt: null,
|
||||
reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run",
|
||||
};
|
||||
if (oldIssue !== null) {
|
||||
const oldBody = oldIssue.body ?? "";
|
||||
dryRunOldBody = {
|
||||
fetched: true,
|
||||
bodyChars: oldBody.length,
|
||||
bodySha: bodySha(oldBody),
|
||||
updatedAt: oldIssue.updated_at ?? null,
|
||||
};
|
||||
} else if (token.length > 0) {
|
||||
const issue = await getIssue(token, repo, issueNumber);
|
||||
if (isGitHubError(issue)) {
|
||||
dryRunOldBody = {
|
||||
fetched: false,
|
||||
bodyChars: null,
|
||||
bodySha: null,
|
||||
updatedAt: null,
|
||||
readError: {
|
||||
degradedReason: issue.degradedReason,
|
||||
runnerDisposition: issue.runnerDisposition,
|
||||
message: issue.message,
|
||||
status: issue.status ?? null,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const oldBody = issue.body ?? "";
|
||||
dryRunOldBody = {
|
||||
fetched: true,
|
||||
bodyChars: oldBody.length,
|
||||
bodySha: bodySha(oldBody),
|
||||
updatedAt: issue.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
command: commandName,
|
||||
repo,
|
||||
dryRun: true,
|
||||
issueNumber,
|
||||
mode: options.mode,
|
||||
...dryRunBody(repo, options.title, finalBody),
|
||||
disclosure: issueWriteDisclosure(options, repo, issueNumber, true),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
guard,
|
||||
update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null),
|
||||
bodyOnlySafety: {
|
||||
oldBody: dryRunOldBody,
|
||||
newBody: {
|
||||
bodyChars: finalBody.length,
|
||||
bodySha: bodySha(finalBody),
|
||||
bodyPreview: preview(finalBody),
|
||||
bodyPreviewLines: previewLines(finalBody),
|
||||
},
|
||||
},
|
||||
concurrency: {
|
||||
dryRunNoWrite: true,
|
||||
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
||||
expectBodySha: options.expectBodySha ?? null,
|
||||
note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH",
|
||||
},
|
||||
wouldPatch: {
|
||||
issueNumber,
|
||||
title: options.title ?? null,
|
||||
bodySource,
|
||||
mode: options.mode,
|
||||
bodyChars: finalBody.length,
|
||||
bodySha: bodySha(finalBody),
|
||||
},
|
||||
...(options.notifyClaudeQqBriefDiff
|
||||
? {
|
||||
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,
|
||||
dryRunNoClaudeQqSend: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
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(commandName, repo, issue, { issueNumber });
|
||||
const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext);
|
||||
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: commandName,
|
||||
repo,
|
||||
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
|
||||
mode: options.mode,
|
||||
bodySource,
|
||||
guard,
|
||||
concurrency,
|
||||
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody);
|
||||
const claudeqq = diff.ok
|
||||
? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message)
|
||||
: {
|
||||
ok: true,
|
||||
attempted: false,
|
||||
skipped: true,
|
||||
skippedReason: diff.skippedReason ?? "no commander brief diff to send",
|
||||
target: maskedTarget(claudeQqConfig),
|
||||
} satisfies ClaudeQqSendResult;
|
||||
return {
|
||||
ok: true,
|
||||
command: commandName,
|
||||
repo,
|
||||
issue: issueSummary(issue, { includeBody: options.raw || options.full }),
|
||||
guard,
|
||||
concurrency,
|
||||
disclosure: issueWriteDisclosure(options, repo, issueNumber, false),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
rest: true,
|
||||
commanderBriefNotification: {
|
||||
issueNumber,
|
||||
oldIssueUpdatedAt: oldIssue?.updated_at ?? null,
|
||||
diff: {
|
||||
ok: diff.ok,
|
||||
mode: diff.mode,
|
||||
chars: diff.chars,
|
||||
sectionCount: diff.sectionCount,
|
||||
skippedReason: diff.skippedReason ?? null,
|
||||
preview: diff.ok ? preview(diff.message) : "",
|
||||
previewLines: diff.ok ? previewLines(diff.message) : [],
|
||||
containsLiteralBackslashN: diff.message.includes("\\n"),
|
||||
containsBackticks: diff.message.includes("`"),
|
||||
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
|
||||
},
|
||||
},
|
||||
claudeqq,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user