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

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