// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:4145-4460. import path from "node:path"; import { bodySafetySignals, bodySha, preview, repoParts } from "./auth-and-safety"; import { boardBodyGuardSummary, boardBodySafetyIssue, boardRowsForState, boardRowSummary, boardRowValuePollutionEvidence, findBoardRow, parseBoardTables, readBoardRowFile } from "./board-parser"; import { boardRowAddPlan, boardRowDeletePlan, boardRowMovePlan, boardRowUpdatePlan, boardRowUpsertPlan } from "./board-plans"; import { assertConcurrencyOptions, validateIssueConcurrency } from "./body-guards"; import { codeQueueBoardCommanderBriefHint } from "./body-profiles"; import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueWriteDisclosure, validationError } from "./client"; import { getIssue } from "./issue-read"; import { issueSummary } from "./issue-summary"; import { BOARD_MUTATION_SECTIONS, BOARD_ROW_FIELDS } from "./types"; import type { BoardMutationSection, BoardRowMutationPlanResult, BoardRowValidationWarning, BoardTableSection, GitHubCommandFailure, GitHubCommandResult, GitHubIssue, GitHubOptions } from "./types"; export async function issueBoardRowMutation( repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert", mutationKey: "add" | "move" | "delete" | "upsert", buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure, ): Promise { const concurrencyOptionError = assertConcurrencyOptions(options, commandName); if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; const boardIssue = await getIssue(token, repo, options.boardIssue); if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); const parsed = parseBoardTables(boardIssue.body ?? ""); const planned = buildPlan(boardIssue, parsed); if (planned.ok === false) return planned; const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined; const effectiveDryRun = options.dryRun || !hasConcurrencyGuard; const base = { ok: true, command: commandName, repo, boardIssue: { number: boardIssue.number, title: boardIssue.title, state: boardIssue.state, url: boardIssue.html_url, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), issueNumber, operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey, dryRun: effectiveDryRun, planned: true, [mutationKey]: planned.plan, guard, bodyOnlySafety: { oldBody: { fetched: true, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, newBody: { bodyChars: planned.newBody.length, bodySha: bodySha(planned.newBody), ...bodySafetySignals(planned.newBody), }, }, concurrency: { expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null, note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`, }, }; if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard }; if (effectiveDryRun) { return { ...base, dryRun: true, disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), readCommands: issueBodyReadCommands(repo, options.boardIssue), wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, note: !hasConcurrencyGuard ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." : "Dry-run only; no GitHub issue body was modified.", }; } const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; const { owner, name } = repoParts(repo); const issue = await githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan }); return { ...base, dryRun: false, planned: false, issue: issueSummary(issue, { includeBody: options.raw || options.full }), disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), readCommands: issueBodyReadCommands(repo, options.boardIssue), concurrency: { checked: true, oldIssueUpdatedAt: boardIssue.updated_at ?? null, oldBodySha: bodySha(boardIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null, }, rest: true, }; } export async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise { const commandName = "issue board-row list"; const boardIssue = await getIssue(token, repo, options.boardIssue); if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue }); const parsed = parseBoardTables(boardIssue.body ?? ""); const rows = boardRowsForState(parsed.sections, options.listState); return { ok: true, command: commandName, repo, dryRun: true, readOnly: true, codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), boardIssue: { number: boardIssue.number, title: boardIssue.title, state: boardIssue.state, url: boardIssue.html_url, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, state: options.listState, count: rows.length, rows: rows.map(({ section, row }) => boardRowSummary(section, row)), sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })), rowValidationWarnings: parsed.warnings, note: "Read-only board row list; no issue body was edited.", }; } export async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row get"; const boardIssue = await getIssue(token, repo, options.boardIssue); if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); const parsed = parseBoardTables(boardIssue.body ?? ""); const found = findBoardRow(repo, parsed.sections, issueNumber); if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue }; return { ok: true, command: commandName, repo, dryRun: true, readOnly: true, codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), boardIssue: { number: boardIssue.number, title: boardIssue.title, state: boardIssue.state, url: boardIssue.html_url, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, issueNumber, row: boardRowSummary(found.section, found.row), rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber), note: "Read-only board row get; no issue body was edited.", }; } export async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row update"; if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() }); if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value ", { issueNumber, field: options.boardRowField }); const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue); if (valuePollutionEvidence.length > 0) { return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", { issueNumber, field: options.boardRowField, valuePreview: preview(options.boardRowValue), shellPollution: { suspected: true, evidence: valuePollutionEvidence }, }); } const concurrencyOptionError = assertConcurrencyOptions(options, commandName); if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; const boardIssue = await getIssue(token, repo, options.boardIssue); if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); const parsed = parseBoardTables(boardIssue.body ?? ""); const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed); if (planned.ok === false) return planned; const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined); const base = { ok: true, command: commandName, repo, boardIssue: { number: boardIssue.number, title: boardIssue.title, state: boardIssue.state, url: boardIssue.html_url, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), issueNumber, dryRun: effectiveDryRun, planned: true, update: planned.plan, guard, bodyOnlySafety: { oldBody: { fetched: true, bodyChars: (boardIssue.body ?? "").length, bodySha: bodySha(boardIssue.body ?? ""), updatedAt: boardIssue.updated_at ?? null, }, newBody: { bodyChars: planned.newBody.length, bodySha: bodySha(planned.newBody), ...bodySafetySignals(planned.newBody), }, }, concurrency: { expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null, note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH", }, }; if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard }; if (effectiveDryRun) { return { ...base, dryRun: true, disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), readCommands: issueBodyReadCommands(repo, options.boardIssue), wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." : "Dry-run only; no GitHub issue body was modified.", }; } const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; const { owner, name } = repoParts(repo); const issue = await githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan }); return { ...base, dryRun: false, planned: false, issue: issueSummary(issue, { includeBody: options.raw || options.full }), disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), readCommands: issueBodyReadCommands(repo, options.boardIssue), concurrency: { checked: true, oldIssueUpdatedAt: boardIssue.updated_at ?? null, oldBodySha: bodySha(boardIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null, }, rest: true, }; } export async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row add"; if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() }); let rowFile: { path: string; raw: string; cells: string[] }; try { rowFile = readBoardRowFile(options.boardRowFile, commandName); } catch (error) { return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber, rowFile: options.boardRowFile ?? null, }); } return issueBoardRowMutation( repo, token, issueNumber, options, commandName, "add", (boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed), ); } export async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row upsert"; return issueBoardRowMutation( repo, token, issueNumber, options, commandName, "upsert", (boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed), ); } export async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row move"; if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() }); return issueBoardRowMutation( repo, token, issueNumber, options, commandName, "move", (boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed), ); } export async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue board-row delete"; return issueBoardRowMutation( repo, token, issueNumber, options, commandName, "delete", (boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed), ); }