Files
pikasTech-unidesk/scripts/src/gh/issue-write.ts
T
2026-06-26 00:16:53 +08:00

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,
};
}