edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
531 lines
22 KiB
TypeScript
531 lines
22 KiB
TypeScript
// 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);
|
||
}
|