edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
// 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,
|
|
};
|
|
}
|