edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
332 lines
16 KiB
TypeScript
332 lines
16 KiB
TypeScript
// 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<GitHubCommandResult> {
|
|
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<GitHubIssue>(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<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
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 <text>", { 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<GitHubIssue>(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<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
const commandName = "issue board-row delete";
|
|
return issueBoardRowMutation(
|
|
repo,
|
|
token,
|
|
issueNumber,
|
|
options,
|
|
commandName,
|
|
"delete",
|
|
(boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed),
|
|
);
|
|
}
|