fix: guard board against commander brief pollution
This commit is contained in:
@@ -704,6 +704,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
assertCondition(boardRowGetRow.issueNumber === 35 && boardRowGetRow.section === "open", "board-row get should return the target row", boardRowGetData);
|
||||
assertCondition(Array.isArray(boardRowGetRow.cells) && boardRowGetRow.cells[1] === "OPEN", "board-row get should expose the GitHub status column", boardRowGetRow);
|
||||
assertCondition(boardRowGetFields.branch === "master" && boardRowGetFields.status === "pass" && boardRowGetFields.validation === "pass" && boardRowGetFields.tasks === "cq-35" && String(boardRowGetFields.focus ?? "").includes("当前关注点"), "board-row get should expose canonical field aliases", boardRowGetFields);
|
||||
const boardRowGetHint = boardRowGetData.codeQueueBoardHint as JsonRecord;
|
||||
assertCondition(boardRowGetHint.detected === false && String(boardRowGetHint.warning ?? "").includes("#20 is the long-term board only"), "board-row get should remind callers not to put daily briefs into #20", boardRowGetHint);
|
||||
|
||||
const boardRowUpsertUpdateRequestCountBefore = mock.requests.length;
|
||||
const boardRowUpsertUpdate = await runCli([
|
||||
@@ -1088,6 +1090,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
const readBodyData = dataOf(readBody.json ?? {});
|
||||
const readIssue = readBodyData.issue as JsonRecord;
|
||||
assertCondition(typeof readIssue.body === "string" && readIssue.body.includes("## 看板(OPEN)"), ".data.issue.body should remain readable", readBodyData);
|
||||
const readBodyHint = readBodyData.codeQueueBoardHint as JsonRecord;
|
||||
assertCondition(readBodyHint.detected === false && String(readBodyHint.warning ?? "").includes("#20 is the long-term board only"), "issue read #20 should remind callers not to put daily briefs into #20", readBodyHint);
|
||||
const selectedJson = readBodyData.json as JsonRecord;
|
||||
assertCondition(typeof selectedJson.body === "string" && selectedJson.body === readIssue.body, "selected json body should match issue body", readBodyData);
|
||||
assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson);
|
||||
@@ -1130,6 +1134,29 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
|
||||
const profileGuard = profileData.guard as JsonRecord;
|
||||
assertCondition(Array.isArray(profileGuard.failures) && profileGuard.failures.includes("profile-heading-missing"), "#20 guard should report missing heading", profileGuard);
|
||||
|
||||
const pollutedBoardFile = join(tmp, "polluted-board.md");
|
||||
writeFileSync(pollutedBoardFile, [
|
||||
"# Code Queue",
|
||||
"",
|
||||
"## 看板(OPEN)",
|
||||
"",
|
||||
"| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| #20 | OPEN | master | meta | governance | active | active |",
|
||||
"",
|
||||
"## 更新 2026-05-21 15:18 北京时间",
|
||||
"",
|
||||
"- 这类每日简报段落必须写到每日滚动简报 issue,而不是 #20。",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
const pollutedBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", pollutedBoardFile, "--dry-run"], env);
|
||||
assertCondition(pollutedBoard.status !== 0, "#20 body guard should reject commander brief update sections", pollutedBoard.json ?? { stdout: pollutedBoard.stdout });
|
||||
const pollutedBoardData = failedDataOf(pollutedBoard.json ?? {});
|
||||
const pollutedBoardGuard = pollutedBoardData.guard as JsonRecord;
|
||||
assertCondition(Array.isArray(pollutedBoardGuard.failures) && pollutedBoardGuard.failures.includes("code-queue-board-contains-commander-brief-updates"), "#20 guard should report commander brief pollution", pollutedBoardGuard);
|
||||
const pollutedBoardHint = pollutedBoardGuard.codeQueueBoardHint as JsonRecord;
|
||||
assertCondition(pollutedBoardHint.detected === true && String(pollutedBoardHint.route ?? "").includes("daily rolling commander brief"), "#20 guard should hint to move updates to the daily brief issue", pollutedBoardHint);
|
||||
|
||||
const commanderBriefBlocked = await runCli(["gh", "issue", "edit", "24", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env);
|
||||
assertCondition(commanderBriefBlocked.status !== 0, "#24 missing heading should fail", commanderBriefBlocked.json ?? { stdout: commanderBriefBlocked.stdout });
|
||||
const commanderBriefData = failedDataOf(commanderBriefBlocked.json ?? {});
|
||||
|
||||
+44
-1
@@ -41,6 +41,11 @@ const ISSUE_BODY_PROFILES = {
|
||||
const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u;
|
||||
const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u;
|
||||
const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u;
|
||||
const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [
|
||||
/^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u,
|
||||
/^##\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间指挥更新\s*$/u,
|
||||
/^###\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+CST[::].*$/u,
|
||||
] as const;
|
||||
|
||||
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
|
||||
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
|
||||
@@ -780,6 +785,31 @@ function isTimelineHeading(line: string): boolean {
|
||||
return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd());
|
||||
}
|
||||
|
||||
function isCommanderBriefUpdateHeading(line: string): boolean {
|
||||
const trimmed = line.trimEnd();
|
||||
return COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS.some((pattern) => pattern.test(trimmed));
|
||||
}
|
||||
|
||||
function commanderBriefUpdateHeadings(body: string): string[] {
|
||||
return normalizeNewlines(body)
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => isCommanderBriefUpdateHeading(line));
|
||||
}
|
||||
|
||||
function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null {
|
||||
if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null;
|
||||
const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body);
|
||||
return {
|
||||
warning: "#20 is the long-term board only; do not write daily commander brief update sections into #20.",
|
||||
route: "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief.",
|
||||
forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST:..."],
|
||||
suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-file <file> --body-profile commander-brief --expect-body-sha <sha>",
|
||||
detected: headings.length > 0,
|
||||
detectedHeadings: headings,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTimelineSections(markdown: string): CommanderBriefSection[] {
|
||||
const normalized = normalizeNewlines(markdown);
|
||||
const lines = normalized.split("\n");
|
||||
@@ -2795,6 +2825,7 @@ async function issueBoardRowMutation(
|
||||
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,
|
||||
@@ -2864,6 +2895,7 @@ async function issueBoardRowList(repo: string, token: string, options: GitHubOpt
|
||||
repo,
|
||||
dryRun: true,
|
||||
readOnly: true,
|
||||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
|
||||
boardIssue: {
|
||||
number: boardIssue.number,
|
||||
title: boardIssue.title,
|
||||
@@ -2895,6 +2927,7 @@ async function issueBoardRowGet(repo: string, token: string, issueNumber: number
|
||||
repo,
|
||||
dryRun: true,
|
||||
readOnly: true,
|
||||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""),
|
||||
boardIssue: {
|
||||
number: boardIssue.number,
|
||||
title: boardIssue.title,
|
||||
@@ -2948,6 +2981,7 @@ async function issueBoardRowUpdate(repo: string, token: string, issueNumber: num
|
||||
bodySha: bodySha(boardIssue.body ?? ""),
|
||||
updatedAt: boardIssue.updated_at ?? null,
|
||||
},
|
||||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody),
|
||||
issueNumber,
|
||||
dryRun: effectiveDryRun,
|
||||
planned: true,
|
||||
@@ -3170,11 +3204,14 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string,
|
||||
const isBlank = trimmed.length === 0;
|
||||
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
|
||||
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
||||
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
||||
const boardContainsCommanderBriefUpdates = boardBriefHint?.detected === true;
|
||||
const profileOk = validationBoolean(profileValidation, "ok");
|
||||
const failures: string[] = [];
|
||||
if (isLiteralNull) failures.push("literal-null-body");
|
||||
if (isBlank) failures.push("blank-body");
|
||||
if (isShort && !options.allowShortBody) failures.push("short-body");
|
||||
if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates");
|
||||
if (!profileOk) {
|
||||
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
|
||||
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
|
||||
@@ -3196,6 +3233,7 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string,
|
||||
bodySource: { kind: "body-file", path: options.bodyFile ?? null },
|
||||
bodyPreview: preview(body),
|
||||
bodyPreviewLines: previewLines(body),
|
||||
codeQueueBoardHint: boardBriefHint,
|
||||
...bodySafetySignals(body),
|
||||
isLiteralNull,
|
||||
isBlank,
|
||||
@@ -3208,12 +3246,14 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string,
|
||||
function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> {
|
||||
const trimmed = body.trim();
|
||||
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
||||
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
||||
const warnings: string[] = [];
|
||||
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
|
||||
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
|
||||
}
|
||||
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
|
||||
if (profile.ok === false) warnings.push("profile guard would fail");
|
||||
if (boardBriefHint?.detected === true) warnings.push("code-queue-board-contains-commander-brief-updates");
|
||||
const signals = bodySafetySignals(body);
|
||||
const shellPollution = signals.shellPollution as Record<string, unknown>;
|
||||
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
|
||||
@@ -3221,11 +3261,13 @@ function issueEditGuardSummary(issueNumber: number, body: string, options: GitHu
|
||||
ok: trimmed.length > 0
|
||||
&& trimmed.toLowerCase() !== "null"
|
||||
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
|
||||
&& profile.ok === true,
|
||||
&& profile.ok === true
|
||||
&& boardBriefHint?.detected !== true,
|
||||
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
||||
allowShortBody: options.allowShortBody,
|
||||
warnings,
|
||||
profile,
|
||||
codeQueueBoardHint: boardBriefHint,
|
||||
...signals,
|
||||
};
|
||||
}
|
||||
@@ -3647,6 +3689,7 @@ async function issueRead(repo: string, token: string, issueNumber: number, jsonF
|
||||
command: commandName,
|
||||
repo,
|
||||
issue: issueSummary(issue),
|
||||
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""),
|
||||
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
|
||||
...(jsonFields === undefined ? {} : {
|
||||
jsonFields,
|
||||
|
||||
Reference in New Issue
Block a user