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

531 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:2674-3191.
import path from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { preview, shellPollutionEvidence } from "./auth-and-safety";
import { isDailyCommanderBriefTitle, issueEditGuardSummary, validateIssueBodyGuard } from "./body-guards";
import { normalizeNewlines } from "./body-profiles";
import { validationError } from "./client";
import { BOARD_AUDIT_REQUIRED_COLUMNS, CODE_QUEUE_BOARD_TARGET_ISSUE, DEFAULT_BOARD_KNOWN_META_ISSUES } from "./types";
import type { BoardColumnKind, BoardIgnoreReason, BoardIssueEntry, BoardMutationSection, BoardRequiredColumn, BoardRowField, BoardRowLookup, BoardRowUpsertField, BoardRowValidationWarning, BoardSectionKind, BoardTableRow, BoardTableSection, GitHubCommandFailure, GitHubCommandResult, GitHubIssue, GitHubOptions, IssueListState } from "./types";
export function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry {
return {
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
};
}
export function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] {
return issues.slice().sort((a, b) => a.number - b.number);
}
export function mergedKnownMetaIssues(options: GitHubOptions): number[] {
const seen = new Set<number>();
const values: number[] = [];
for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) {
if (seen.has(issueNumber)) continue;
seen.add(issueNumber);
values.push(issueNumber);
}
return values;
}
export function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map<number, BoardIgnoreReason> {
const ignored = new Map<number, BoardIgnoreReason>();
for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta");
for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored");
if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) {
for (const issue of issues) {
if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed");
}
}
return ignored;
}
export function stripMarkdownInline(text: string): string {
return text
.replace(/\[[^\]]*]\(([^)]*)\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_`~]/g, "")
.trim();
}
export function normalizeBoardHeader(header: string): string {
return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, "");
}
export function boardHeaderColumnKind(header: string): BoardColumnKind | null {
const normalized = normalizeBoardHeader(header);
if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category";
if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch";
if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance";
if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask";
if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus";
if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary";
if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus";
if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress";
return null;
}
export function boardColumnIndexMap(headers: string[]): Map<BoardColumnKind, number> {
const map = new Map<BoardColumnKind, number>();
headers.forEach((header, headerIndex) => {
const kind = boardHeaderColumnKind(header);
if (kind !== null && !map.has(kind)) map.set(kind, headerIndex);
});
return map;
}
export function boardRequiredColumnIndexMap(headers: string[]): Map<BoardRequiredColumn, number> {
const map = new Map<BoardRequiredColumn, number>();
const allColumns = boardColumnIndexMap(headers);
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const index = allColumns.get(column);
if (index !== undefined) map.set(column, index);
}
return map;
}
export function boardColumnKindForField(field: BoardRowField): BoardColumnKind {
if (field === "branch") return "branch";
if (field === "tasks") return "relatedTask";
if (field === "focus") return "focus";
if (field === "progress") return "progress";
return "acceptance";
}
export function boardIssueColumnIndex(headers: string[]): number | null {
for (let index = 0; index < headers.length; index += 1) {
const normalized = normalizeBoardHeader(headers[index]);
if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index;
}
return null;
}
export function markdownCells(line: string): string[] {
const trimmed = line.trim();
const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
const cells: string[] = [];
let current = "";
for (let index = 0; index < withoutOuterPipes.length; index += 1) {
const char = withoutOuterPipes[index];
const previous = withoutOuterPipes[index - 1];
if (char === "|" && previous !== "\\") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
}
export function isMarkdownTableSeparator(line: string): boolean {
const cells = markdownCells(line);
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim()));
}
export function boardSectionKindFromHeading(line: string): BoardSectionKind | null {
const normalized = line.toLowerCase();
if (!line.trimStart().startsWith("#")) return null;
if (normalized.includes("open") || normalized.includes("看板(open") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open";
if (normalized.includes("closed") || normalized.includes("看板(closed") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed";
return null;
}
export function extractIssueNumbers(text: string): number[] {
const numbers: number[] = [];
const seen = new Set<number>();
const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g];
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match = pattern.exec(text);
while (match !== null) {
const value = Number(match[1]);
if (Number.isInteger(value) && value > 0 && !seen.has(value)) {
seen.add(value);
numbers.push(value);
}
match = pattern.exec(text);
}
}
return numbers;
}
export function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null {
if (issueCell === undefined) return null;
const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g;
let match = markdownLinkPattern.exec(issueCell);
while (match !== null) {
const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1];
if (issueNumber !== undefined) {
const value = Number(issueNumber);
if (Number.isInteger(value) && value > 0) return value;
}
match = markdownLinkPattern.exec(issueCell);
}
const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1];
if (leadingIssueReference !== undefined) {
const value = Number(leadingIssueReference);
if (Number.isInteger(value) && value > 0) return value;
}
return null;
}
export function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow {
const columnMap = boardRequiredColumnIndexMap(section.headers);
const issueColumnIndex = boardIssueColumnIndex(section.headers);
const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex];
const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell);
const issueNumbers = issueColumnIndex === null
? extractIssueNumbers(raw)
: (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]);
const columns: Partial<Record<BoardRequiredColumn, string>> = {};
for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) {
const cellIndex = columnMap.get(column);
if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? "";
}
return {
section: section.kind,
lineNumber,
raw,
cells,
issueNumbers,
issueNumber: issueNumbers[0] ?? null,
title: issueColumnIndex === null
? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null)
: (issueCell ?? cells[0] ?? null),
columns,
};
}
export function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } {
const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim();
return { spaced, compact: spaced.replace(/\s+/g, "") };
}
export function isRelatedTaskNoTaskPlaceholder(value: string): boolean {
const normalized = normalizedBoardCellPlaceholder(value);
return [
"-",
"—",
"",
"n/a",
"na",
"none",
"no task",
"no-task",
"no code queue task",
"not applicable",
].includes(normalized.spaced) || [
"无",
"无任务",
"无关联任务",
"无相关任务",
"无codequeue任务",
"无codequeuetask",
"不适用",
].includes(normalized.compact);
}
export function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean {
if (value === undefined) return false;
const stripped = stripMarkdownInline(value);
if (stripped.length === 0) return false;
if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true;
return !["-", "—", "n/a", "na", "todo", "tbd", "待补", "未填", "无"].includes(stripped.toLowerCase());
}
export function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } {
const lines = normalizeNewlines(body).split("\n");
const sections: BoardTableSection[] = [];
const warnings: BoardRowValidationWarning[] = [];
let currentKind: BoardSectionKind | null = null;
let currentHeading = "";
let currentHeadingLine = 0;
for (let index = 0; index < lines.length; index += 1) {
const headingKind = boardSectionKindFromHeading(lines[index]);
if (headingKind !== null) {
currentKind = headingKind;
currentHeading = lines[index].trim();
currentHeadingLine = index + 1;
continue;
}
if (currentKind === null) continue;
if (!lines[index].trim().startsWith("|")) continue;
const separator = lines[index + 1];
if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue;
const headers = markdownCells(lines[index]);
const rows: BoardTableRow[] = [];
let rowIndex = index + 2;
while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) {
if (!isMarkdownTableSeparator(lines[rowIndex])) {
const cells = markdownCells(lines[rowIndex]);
const row = boardRowFromCells(
{ kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] },
lines[rowIndex],
cells,
rowIndex + 1,
);
rows.push(row);
if (row.issueNumbers.length === 0) {
warnings.push({
issueNumber: null,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-issue-reference",
message: "Board table row does not contain a GitHub issue reference such as #36.",
rowPreview: preview(row.raw),
});
}
if (row.issueNumbers.length > 1) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "multiple-issue-references",
message: boardIssueColumnIndex(headers) === null
? "Board table row contains multiple issue references; audit uses the first number as the row key."
: "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.",
rowPreview: preview(row.raw),
});
}
const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column));
if (missingColumns.length > 0) {
warnings.push({
issueNumber: row.issueNumber,
section: currentKind,
lineNumber: row.lineNumber,
kind: "missing-required-columns",
message: "Board table row is missing branch, acceptance, related task, or progress information.",
missingColumns,
columns: row.columns,
rowPreview: preview(row.raw),
});
}
}
rowIndex += 1;
}
sections.push({
kind: currentKind,
heading: currentHeading,
headingLine: currentHeadingLine,
headerLine: index + 1,
headers,
rows,
});
index = rowIndex - 1;
}
return { sections, warnings };
}
export function boardRowsByIssue(rows: BoardTableRow[]): Map<number, BoardTableRow[]> {
const map = new Map<number, BoardTableRow[]>();
for (const row of rows) {
if (row.issueNumber === null) continue;
const list = map.get(row.issueNumber);
if (list === undefined) map.set(row.issueNumber, [row]);
else list.push(row);
}
return map;
}
export function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
progress: valueFor("progress"),
status: acceptance,
validation: acceptance,
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
focus: valueFor("focus"),
};
}
export function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowUpsertField, string | null> {
const columnMap = boardColumnIndexMap(section.headers);
const valueFor = (kind: BoardColumnKind): string | null => {
const index = columnMap.get(kind);
return index === undefined ? null : row.cells[index] ?? "";
};
const acceptance = valueFor("acceptance");
return {
category: valueFor("category"),
branch: valueFor("branch"),
tasks: valueFor("relatedTask"),
summary: valueFor("summary"),
focus: valueFor("focus"),
validation: acceptance,
progress: valueFor("progress"),
status: valueFor("githubStatus"),
};
}
export function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record<string, unknown> {
return {
issueNumber: row.issueNumber,
section: row.section,
lineNumber: row.lineNumber,
heading: section.heading,
headers: section.headers,
title: row.title,
cells: row.cells,
fields: boardRowFieldValues(section, row),
raw: row.raw,
rowPreview: preview(row.raw),
};
}
export function boardRowsExactMatch(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
export function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return sections
.filter((section) => state === "all" || section.kind === state)
.flatMap((section) => section.rows.map((row) => ({ section, row })));
}
export function boardDuplicateRows(
sections: BoardTableSection[],
issueNumber: number,
): Array<{ section: BoardTableSection; row: BoardTableRow }> {
return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber);
}
export function targetBoardSection(
repo: string,
sections: BoardTableSection[],
kind: BoardMutationSection,
commandName: string,
): { ok: true; section: BoardTableSection } | GitHubCommandFailure {
const matches = sections.filter((section) => section.kind === kind);
if (matches.length === 0) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, {
section: kind,
availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, {
section: kind,
matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })),
}) as GitHubCommandFailure;
}
return { ok: true, section: matches[0] };
}
export function findBoardRow(
repo: string,
sections: BoardTableSection[],
issueNumber: number,
state: IssueListState = "all",
): BoardRowLookup | GitHubCommandFailure {
const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber);
if (matches.length === 0) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, {
issueNumber,
state,
availableIssueNumbers: boardRowsForState(sections, state)
.map(({ row }) => row.issueNumber)
.filter((value): value is number => value !== null)
.sort((a, b) => a - b),
}) as GitHubCommandFailure;
}
if (matches.length > 1) {
return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, {
issueNumber,
state,
matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })),
}) as GitHubCommandFailure;
}
return { ok: true, ...matches[0], matches };
}
export function escapeMarkdownTableCell(value: string): string {
return value
.replace(/\r\n/g, " ")
.replace(/[\r\n]+/g, " ")
.replace(/\|/g, "\\|")
.trim();
}
export function boardRowValuePollutionEvidence(value: string): string[] {
return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return");
}
export function renderMarkdownTableRow(cells: string[]): string {
return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`;
}
export function validateMarkdownTableRowLine(line: string): string | null {
if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |";
if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator";
return null;
}
export function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } {
if (path === undefined) throw new Error(`${command} requires --row-file <markdown-row-file>`);
if (!existsSync(path)) throw new Error(`row file not found: ${path}`);
const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim();
if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row");
const lines = normalized.split("\n");
if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row");
const line = lines[0];
const lineError = validateMarkdownTableRowLine(line);
if (lineError !== null) throw new Error(lineError);
return { path, raw: line, cells: markdownCells(line) };
}
export function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines[lineNumber - 1] = newLine;
return lines.join("\n");
}
export function removeBoardBodyLine(body: string, lineNumber: number): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`);
lines.splice(lineNumber - 1, 1);
return lines.join("\n");
}
export function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string {
const normalized = normalizeNewlines(body);
const lines = normalized.split("\n");
if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`);
lines.splice(lineNumber, 0, newLine);
return lines.join("\n");
}
export function boardSectionInsertAfterLine(section: BoardTableSection): number {
if (section.rows.length === 0) return section.headerLine + 1;
return section.rows[section.rows.length - 1].lineNumber;
}
export function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions);
}
export function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> {
const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile };
return issueEditGuardSummary(boardIssueNumber, body, guardOptions);
}