// 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(); 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 { const ignored = new Map(); 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 { const map = new Map(); 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 { const map = new Map(); 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(); 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> = {}; 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 { const map = new Map(); 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 { 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 { 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 { 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 `); 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 { const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile }; return issueEditGuardSummary(boardIssueNumber, body, guardOptions); }