// 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 { if (options.title === undefined) return withIssueCreateRendered(validationError("issue create", repo, "issue create requires --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, }; }