From 133d417d01149a715942e538340eb397011968c7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 13:33:49 +0000 Subject: [PATCH] fix: guard board against commander brief pollution --- docs/reference/cli.md | 1 + scripts/gh-cli-issue-guard-contract-test.ts | 27 +++++++++++++ scripts/src/gh.ts | 45 ++++++++++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af6a6e14..69e5dcc2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,6 +34,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 - `gh issue read [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 - `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard:#20 必须包含 `## 看板(OPEN)`,#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24;#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 +- #20 只允许承担长期总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。`gh issue read/view 20` 会返回 `codeQueueBoardHint`;`gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间`、`## YYYY-MM-DD HH:mm 北京时间指挥更新` 和 `### YYYY-MM-DD HH:mm CST:...` 这类简报段落,并在 `codeQueueBoardHint` 中提示改写到每日简报 issue;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报混入 #20。 - `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 - `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是 #20 长期总看板只读覆盖审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它读取 board body、GitHub open issue 列表和 closed issue 列表,对比 OPEN/CLOSED Markdown 表格并输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。当表格里存在 Issue 列时,row.issueNumber 优先取 Issue cell 中第一个指向 `/issues/<N>` 的 Markdown link,找不到时取开头的 `#N`;同一 Issue cell 里主引用后面的标题说明引用(例如 `#20 总看板`、`基于 #4`)不触发 `multiple-issue-references`。没有 Issue 列的旧表格仍回退到整行 issue 提取,并保留多 issue 引用告警。`相关 Code Queue 任务`/`relatedTask` 列允许 `—`、`-`、`n/a`、`无任务` 等无关联任务占位表示 closed 历史/治理项没有 Code Queue task;这个放宽不适用于 branch、acceptance 或 progress。默认把 #20 和 #24 作为 `known-meta` 治理/简报 issue 忽略;标题匹配 `YYYY-MM-DD 指挥简报(北京时间)` 的每日滚动简报由 #20 顶部指挥简报索引管理,不进入 OPEN/CLOSED 覆盖审计,并在 `ignoredIssues` 中标记 `reason=brief-index-managed`。需要扩展治理项用 `--known-meta-issue`,临时排除业务 issue 用 `--ignore-issue`。指挥官发现总看板可能漏行时,应先跑 board audit 获取结构化结果,再决定是否人工编辑 #20,而不是只靠 grep。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get <issueNumber> --board-issue 20` 和 `gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]` 是 #20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射固定为:`branch` -> Branch,`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert <issueNumber> --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section` 或 `--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move`。`gh issue board-row add <issueNumber> --board-issue 20 --section open|closed --row-file <file> [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move <issueNumber> --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete <issueNumber> --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha` 或 `--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index e39f3698..d77cc97b 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -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 ?? {}); diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 8e221b20..7dd516a6 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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,