#!/usr/bin/env node // 迁移说明(#1252):本文件由 scripts/fix-issue-body.mjs 转写而来,只加类型注解,行为零变化。 /** * scripts/fix-issue-body.mts —— 「把一段正文写回 issue」的可执行化身 * * 出处:#571(任务:正文写回脚本落地)按契约 #576 落地。 * 为什么要它:agent 在终端里手敲 gh 命令写正文时,Windows PowerShell 会吞掉参数里的引号, * 长正文内联进命令行也容易写坏;写坏以后正文格式(字面 \n 转义、开头不可见字符)没人兜底。 * 这个脚本把「读文件 → 校正写法 → 走文件参数写回」收成一条命令,提示词以后只写脚本名加参数。 * * 用法: * node scripts/fix-issue-body.mts --issue 571 --body-file ./.tmp-571-body.md * node scripts/fix-issue-body.mts --issue 571 --body-file ./body.md --repo owner/name * node scripts/fix-issue-body.mts --issue 571 --body-file ./body.md --dry-run * node scripts/fix-issue-body.mts --help * * 参数: * --issue <号> 必传。要写回正文的 issue 编号。 * --body-file <路径> 必传。正文文件;文件里必须是真实换行。脚本不替你编正文,只校正写法。 * --repo 可选。默认用当前目录所在仓库(跨仓库调用时才传)。 * --dry-run 可选。只演练:打印将要执行的命令与将要写入的正文,不联网、不改任何东西。 * --help 打印这段说明。 * * 脚本做五件事: * 1. 认工作区:从当前目录逐层向上找到工作区根(自带 .git 或自带主锚文件的最近一层),读根上的主锚文件认后端。 * 在子目录里跑也认得出;一路到顶都没有标记时,提示说清「请到工作区根目录去跑」并给出那条目录的名字。 * 2. 剥掉正文开头的第一个不可见字符(BOM)——Windows 编辑器另存常带这个字符。 * 3. 正文几乎没有真实换行、却存在字面 \n 转义时,把转义还原成真实换行;否则原样保留 * (阈值与 src/shared/parser.js 的 normalizeBody 一致:真实换行少于 2 处且字面转义至少 1 处才还原)。 * 4. 检查正文格式,只告警不改写:每个 `## 章节` 是否独占一行、标题后是否留空行。 * 5. 写回一律走正文文件参数(--body-file),不把正文拼进命令行字符串。 * * 可重复跑:写之前先读一次当前正文,已经和要写的内容一样就直接返回,不再发请求。 * 失败处理:失败自动重试一次;还失败就在该 issue 下留一条固定格式评论(含脚本名、参数、失败原因), * 方便以后按脚本名搜出所有失败现场。正文没被改坏。 * * 回包:stdout 一行 JSON(stderr 是给人看的叙述)。字段: * changed 这次对票做了什么(数组;真跑时已经一致就是空数组) * edge 本脚本不建边,恒为 null(占位保持与关联脚本同形) * expected 本脚本不数数,恒为 null * actual 本脚本不数数,恒为 null * commented 失败时有没有在该 issue 下留评论 * ok 总通过没(机器只看这个字段) * warnings 格式告警(数组;只提示,不影响 ok) * dryRun 是不是演练模式 * body 只有演练模式才有:将要写入的正文(真实换行) * command 只有演练模式才有:将要执行的命令(数组) * error 只有失败才有:失败原因 * * 退出码:0 通过;1 写回失败(已重试一次);2 参数或工作区后端不对,命令根本没执行。 */ import { readFileSync, writeFileSync, rmSync, mkdtempSync, existsSync } from "node:fs"; import { join, resolve, dirname, basename } from "node:path"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; const SCRIPT_NAME: string = "scripts/fix-issue-body.mts"; const TRACKER_DOC: string = "docs/agents/issue-tracker.md"; const BOM: string = "\uFEFF"; /** 以上逐层找根时,在哪一层停下:自带 .git(文件或目录都算)或自带主锚文件的那一层,两者同等优先、谁近听谁。 */ const ROOT_MARKER_DIR: string = ".git"; /** 本次调用认出来的工作区根(main 一开始就填好)。后面每条 gh 命令都在这一层跑: * 脚本可能是在工作区的子目录里被调起来的,而 gh 的「当前仓库」按进程当前目录算。 */ let WORKSPACE_ROOT: string = ""; /** 解析出来的命令行选项:issue 与 bodyFile 在校验通过后才有值,构造过程中先记 null。 */ interface FixIssueOpts { issue: string | null; bodyFile: string | null; repo: string | null; dryRun: boolean; } /** 认后端的结果:找到工作区根与主锚文件位置,外加判出的后端身份与判定依据。 */ interface BackendDetect { docPath: string; root: string; rootFound: boolean; backendId: string | null; reason: string; } /** 取路径最后一段,单独用(不经 redactLocalPaths 的整句改写)。 */ function nameOf(p: unknown): string { const s = String(p == null ? "" : p); const parts = s.split(/[\\/]+/).filter(Boolean); return parts.length ? parts[parts.length - 1] : s; } /** 把文本里的本地绝对路径收敛成文件名(只留最后一段)。 * 为什么必须洗:回包与 stderr 会经终端、issue 评论、人的对话继续往外走,脚本名与文件名的身份够用, * 本地绝对路径不该跟着走。与姊妹脚本 scripts/wire-subissues.mts 的 redactLocalPaths 同一条口径。 */ function redactLocalPaths(text: unknown): string { let s = String(text == null ? "" : text); s = s.replace(/[A-Za-z]:\\[^\s"'`|<>]*/g, (m: string): string => basename(m)); s = s.replace(/\\\\[^\s"'`|<>]+/g, (m: string): string => basename(m)); s = s.replace(/(^|[\s(`"'])\/(?:[^\s"'`|<>/]+\/)+[^\s"'`|<>]*/g, (m: string, p1: string): string => p1 + basename(m)); return s; } /** * 找工作区根:从 startDir 逐层向上,第一个「自带 .git(文件或目录都算)」或「自带 docs/agents/issue-tracker.md」 * 的目录就是工作区根;两种标记同等优先、谁近听谁;一路到磁盘根都没有,就用 startDir 本身。 * 这条规则与宿主同一条(出处:票 #649 定版、架构决定记录 docs/adr/20260918-subworkspace-identity-root.md, * 宿主侧实现在 src/host/workspaceKey.ts 的 resolveWorkspaceRoot)。脚本要能单跑、不依赖仓库源码, * 所以这里是一份自包含的副本——两份是否给出同一结论由 tests/verify-issue-body-script.ts 与 * tests/verify-wire-subissues-script.ts 的子目录用例钉住。 * @returns {{ root: string, found: boolean }} found=false 表示一路上一个标记都没有(root 就是 startDir 本身)。 */ function findWorkspaceRoot(startDir: string): { root: string; found: boolean } { let cursor = resolve(startDir); for (let i = 0; i < 64; i++) { try { if (existsSync(join(cursor, ROOT_MARKER_DIR))) return { root: cursor, found: true }; if (existsSync(join(cursor, TRACKER_DOC))) return { root: cursor, found: true }; } catch (e) { /* 这一层探测不了就继续往上,与宿主同款:探测失败不算「找到了根」 */ } const up = dirname(cursor); if (!up || up === cursor) break; cursor = up; } return { root: resolve(startDir), found: false }; } /** 读文件失败、参数缺失、后端不对时统一走这里:先给人话,再给一行机器可读的回包。 */ function fail(code: number, message: string, extra?: Record): never { const clean = redactLocalPaths(message); console.error(clean); const out = Object.assign({ changed: [], edge: null, expected: null, actual: null, commented: false, ok: false, warnings: [], dryRun: false, error: clean, }, extra || {}); console.log(JSON.stringify(out)); process.exit(code); } function usage(): void { console.error(`用法: node ${SCRIPT_NAME} --issue <号> --body-file <路径> [--repo ] [--dry-run] --issue <号> 必传,要写回正文的 issue 编号 --body-file <路径> 必传,正文文件(文件里必须是真实换行) --repo 可选,默认当前目录所在仓库 --dry-run 可选,只演练:打印将要执行的命令与将要写入的正文,不联网、不改任何东西 --help 打印这段说明 例子: node ${SCRIPT_NAME} --issue 571 --body-file ./.tmp-571-body.md node ${SCRIPT_NAME} --issue 571 --body-file ./body.md --dry-run 脚本会剥掉正文开头的不可见字符、按阈值还原字面 \\n 转义、检查标题是否独占一行与标题后是否留空行, 再统一走正文文件参数写回。已经和当前正文一样时不再发请求,可以重复跑。`); } function parseArgs(argv: string[]): FixIssueOpts { const args = argv.slice(2); if (args.includes("--help") || args.includes("-h")) { usage(); process.exit(0); } const out: FixIssueOpts = { issue: null, bodyFile: null, repo: null, dryRun: false }; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "--dry-run") { out.dryRun = true; continue; } if (a === "--issue" || a === "--body-file" || a === "--repo") { const v = args[i + 1]; if (v === undefined || v.startsWith("--")) fail(2, `参数 ${a} 后面没给值。`); i++; if (a === "--issue") out.issue = v.trim(); else if (a === "--body-file") out.bodyFile = v; else out.repo = v.trim(); continue; } fail(2, `不认识的参数:${a}。用 --help 看用法。`); } if (!out.issue) fail(2, "没给 --issue。要写回哪张票的正文?用 --issue <号> 传进来。"); if (!/^\d+$/.test(out.issue)) fail(2, `--issue 只接受数字编号,收到的是「${out.issue}」。`); if (!out.bodyFile) fail(2, "没给 --body-file。正文必须放在文件里传进来(命令行内联正文会被外壳吃掉引号)。"); if (out.repo && !/^[^/\s]+\/[^/\s]+$/.test(out.repo)) fail(2, `--repo 要写成 owner/name,收到的是「${out.repo}」。`); return out; } /** * 认后端:先找到工作区根(从当前目录逐层向上),再读根上的主锚文件 docs/agents/issue-tracker.md, * 只认首批支持的 GitHub。判定口径与宿主探测的标题规则一致(# Issue tracker: <后端名>),不另造一套。 * 在子目录里跑得出的结论与在根上跑一样——工作区级的文件只有一份,只可能落在工作区根。 */ function detectBackend(startDir: string): BackendDetect { const found = findWorkspaceRoot(startDir); const docPath = resolve(found.root, TRACKER_DOC); const base = { docPath: docPath, root: found.root, rootFound: found.found }; let raw = ""; try { raw = readFileSync(docPath, "utf8"); } catch (e) { return Object.assign(base, { backendId: null, reason: "missing" }); } const text = raw.replace(/^\uFEFF/, ""); if (/^#\s*issue\s*tracker\s*:\s*gitlab/im.test(text)) return Object.assign(base, { backendId: "gitlab", reason: "title" }); if (/^#\s*issue\s*tracker\s*:\s*(markdown|local)/im.test(text)) return Object.assign(base, { backendId: "markdown", reason: "title" }); if (/^#\s*issue\s*tracker\s*:\s*github/im.test(text)) return Object.assign(base, { backendId: "github", reason: "title" }); if (/github/i.test(text)) return Object.assign(base, { backendId: "github", reason: "keyword" }); return Object.assign(base, { backendId: null, reason: "unknown" }); } /** 认不出后端时往哪儿去:话要说成一条能照做的出路,不只是「认不出后端」。三种情形分开说: * · 找到了根、根上那层的主锚文件里没有认得出后端的标记 → 把首行写成「# Issue tracker: GitHub」; * · 找到了根、根上却没有主锚文件 → 这个仓库还没初始化过,出路是先在根上做一次初始化; * · 一路上一个标记都没有 → 脚本站的地方根本不是一个工作区,请到工作区根目录去跑。 * 三种都给出那条目录的名字(只给最后一段,不带本地绝对路径——回包与报错会继续往外走)。 */ function requireGithub(startDir: string): BackendDetect { const det = detectBackend(startDir); if (det.backendId === "github") return det; const head = `这个工作区用的不是首批支持的 GitHub(主锚文件:${TRACKER_DOC})。`; if (det.backendId === "gitlab") { fail(2, `${head}它声明的是 GitLab;首批脚本只服务 GitHub,GitLab 待第二批。请按 GitLab 的方式改票,不要调本脚本。`); } if (det.backendId === "markdown") { fail(2, `${head}它声明的是本地 Markdown;本地 Markdown 的票就是仓库里的文件,请直接改文件存盘,不要调本脚本。`); } const rootName = nameOf(det.root); if (det.rootFound) { if (det.reason === "unknown") { fail(2, `${head}已找到工作区根目录「${rootName}」,但那一层的主锚文件里没有认得出后端的标记(首行既不是「# Issue tracker: GitHub」,也不含 GitHub 字样)。请把主锚文件首行写成「# Issue tracker: GitHub」,或者从别的目录调用本脚本。`); } fail(2, `${head}已找到工作区根目录「${rootName}」,但这一层没有主锚文件 docs/agents/issue-tracker.md,认不出后端。请在那一层做一次工作区初始化(生成主锚文件),或者从别的目录调用本脚本。`); } fail(2, `${head}认不出后端(从「${nameOf(startDir)}」逐层向上都没找到 .git,也没有 docs/agents/issue-tracker.md)。请到工作区根目录去跑这条命令(当前找不到那条目录,请改用你的工作区根目录);GitHub 工作区请确认该目录的主锚文件首行写着「# Issue tracker: GitHub」,本地 Markdown 工作区请直接改票文件存盘,GitLab 待第二批。`); } /** * 校正正文写法。返回 { text, changes, realNewlines, literalEscapes }。 * 阈值与 src/shared/parser.js 的 normalizeBody 完全一致,本文件是它的独立副本(脚本要能单跑, * 不依赖仓库源码目录与 Node 的模块探测),两者是否一致由 tests/verify-issue-body-script.ts 差分钉住。 */ function normalizeForWrite(raw: unknown): { text: string; changes: string[]; realNewlines: number; literalEscapes: number } { const changes: string[] = []; let s = String(raw == null ? "" : raw); if (s.startsWith(BOM)) { s = s.slice(1); changes.push("剥掉开头的不可见字符(BOM)"); } const realNewlines = (s.match(/\n/g) || []).length; const literalEscapes = (s.match(/\\n/g) || []).length; if (realNewlines < 2 && literalEscapes > 0) { s = s.split("\\n").join("\n"); changes.push(`把 ${literalEscapes} 处字面 \\n 转义还原成真实换行`); } return { text: s, changes: changes, realNewlines: realNewlines, literalEscapes: literalEscapes }; } /** 判断某一行里,`##` 开头的标题是不是没独占一行(出现在行中间)。行内代码里的写法不算。 */ function midLineHeading(line: string): string | null { const re = /#{2,6}\s+\S/g; let m: RegExpExecArray | null; while ((m = re.exec(line)) !== null) { const before = line.slice(0, m.index); if (before.trim() === "") return null; // 标题在行首(允许前面是空白),正常 const ticks = (before.match(/`/g) || []).length; if (ticks % 2 === 1) continue; // 落在行内代码里,是举例不是标题 return m[0].trim(); } return null; } /** * 检查正文格式,只告警不改写。规则来自正文格式契约: * 每个 `## 章节` 独占一行;标题后留空行。代码围栏内的内容不检查。 */ function checkFormat(text: unknown): string[] { const warnings: string[] = []; const lines = String(text == null ? "" : text).split(/\r?\n/); let fence: string | null = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const fm = line.match(/^\s*(`{3,}|~{3,})/); if (fm) { const marker = fm[1][0]; if (fence === null) fence = marker; else if (marker === fence) fence = null; continue; } if (fence !== null) continue; const mid = midLineHeading(line); if (mid) warnings.push(`第 ${i + 1} 行:标题「${mid}」前面还有文字,标题没独占一行`); if (/^#{2,6}\s+\S/.test(line)) { const next = lines[i + 1]; if (next !== undefined && next.trim() !== "") { warnings.push(`第 ${i + 1} 行:标题后没留空行(下一行还有内容)`); } } } return warnings; } /** 找 gh:优先环境变量 DSH_GH_PATH(与平台抽象层的备用路径语义一致),否则用 PATH 上的 gh。 */ function resolveGh(): { cmd: string; prefix: string[] } { const envPath = process.env.DSH_GH_PATH; if (envPath && existsSync(envPath)) { // 指向 .js/.mjs 时用 node 跑(给门禁与离线验收放一个假 gh 用;真实安装的 gh 是可执行文件,走下面那支) if (/\.(m?js|cjs)$/i.test(envPath)) return { cmd: process.execPath, prefix: [envPath] }; return { cmd: envPath, prefix: [] }; } return { cmd: "gh", prefix: [] }; } function runGh(argv: string[]): { ok: boolean; status: number; stdout: string; stderr: string } { const gh = resolveGh(); const res = spawnSync(gh.cmd, gh.prefix.concat(argv), { cwd: WORKSPACE_ROOT || process.cwd(), encoding: "utf8", maxBuffer: 32 * 1024 * 1024, windowsHide: true, }); if (res.error) return { ok: false, status: -1, stdout: "", stderr: String(res.error.message || res.error) }; return { ok: res.status === 0, status: typeof res.status === "number" ? res.status : -1, stdout: res.stdout || "", stderr: (res.stderr || "").trim(), }; } /** 失败原因取一行:太长只留前一段并标注已截断。 */ function shortReason(text: unknown): string { const first = String(text || "").split(/\r?\n/).map((s: string): string => s.trim()).filter(Boolean)[0] || ""; return first.length > 200 ? first.slice(0, 200) + "…(已截断)" : first; } function repoArgs(repo: string | null): string[] { return repo ? ["--repo", repo] : []; } /** 读当前正文(只用来判「是否已经一致」,读不到不阻塞写回)。 */ function readCurrentBody(issue: string, repo: string | null): { ok: true; body: string } | { ok: false; reason: string } { const res = runGh(["issue", "view", String(issue)].concat(repoArgs(repo)).concat(["--json", "body"])); if (!res.ok) return { ok: false, reason: shortReason(res.stderr) }; try { const parsed = JSON.parse(res.stdout); return { ok: true, body: typeof parsed.body === "string" ? parsed.body : "" }; } catch (e) { return { ok: false, reason: "读回的正文不是 JSON:" + e.message }; } } /** 写回正文:正文先落临时文件,再走 --body-file 参数,绝不把正文拼进命令行。 */ function writeBody(issue: string, repo: string | null, text: string, workDir: string): { ok: true; retried: boolean } | { ok: false; retried: boolean; reason: string } { const bodyPath = join(workDir, "body.md"); writeFileSync(bodyPath, text, "utf8"); const argv = ["issue", "edit", String(issue)].concat(repoArgs(repo)).concat(["--body-file", bodyPath]); let res = runGh(argv); if (res.ok) return { ok: true, retried: false }; console.error(`写回失败,按契约自动重试一次:${shortReason(res.stderr)}`); res = runGh(argv); if (res.ok) return { ok: true, retried: true }; return { ok: false, retried: true, reason: shortReason(res.stderr) }; } /** 失败留痕:在该 issue 下留一条固定格式评论,含脚本名、参数与失败原因,方便按脚本名搜出所有失败现场。 */ function commentFailure(issue: string, repo: string | null, opts: FixIssueOpts, reason: string | undefined, workDir: string): boolean { const lines = [ ``, `**${SCRIPT_NAME} 写正文失败**`, "", `- 脚本:\`${SCRIPT_NAME}\``, `- 参数:\`--issue ${issue} --body-file ${opts.bodyFile}\`${repo ? "(--repo " + repo + ")" : ""}`, `- 失败原因:${reason || "没有拿到报错文本"}`, "", "这条评论由脚本自动留下,用来以后按脚本名搜出失败现场。票的正文没有被改坏。", ]; const notePath = join(workDir, "failure-comment.md"); writeFileSync(notePath, lines.join("\n") + "\n", "utf8"); const res = runGh(["issue", "comment", String(issue)].concat(repoArgs(repo)).concat(["--body-file", notePath])); return res.ok; } function main(): void { const opts = parseArgs(process.argv); // 认后端的同时拿到工作区根:后面的 gh 命令都在那一层跑。 // 为什么不能继续用 process.cwd():脚本可能是在工作区的子目录里被调起来的, // 而 gh 的「当前仓库」按进程当前目录算,在子目录里跑会把票落到别处(工作区根本来就只有一份)。 // 在根上跑时两者相同,行为与旧版一致。 const det = requireGithub(process.cwd()); WORKSPACE_ROOT = det.root; let raw: string; try { raw = readFileSync(opts.bodyFile, "utf8"); } catch (e) { fail(2, `读不到正文文件:${opts.bodyFile} —— ${e.message}`); } const normalized = normalizeForWrite(raw); const warnings = checkFormat(normalized.text); warnings.forEach((w: string): void => console.error("告警:" + w)); // 演练:不联网、不改任何东西,把「将要写什么、将要执行什么」摆出来给人看。 if (opts.dryRun) { const previewCommand = ["gh", "issue", "edit", String(opts.issue)] .concat(repoArgs(opts.repo)) .concat(["--body-file", "<临时文件,演练时未创建>"]); const changed = normalized.changes.map((c: string): string => `#${opts.issue} 正文:${c}`); changed.push(`#${opts.issue} 正文:将按文件内容写回(演练未联网核对当前正文)`); console.error("演练:不会写任何东西。将执行的命令:"); console.error(" " + previewCommand.join(" ")); console.error("将要写入的正文:"); console.error(normalized.text); console.log(JSON.stringify({ changed: changed, edge: null, expected: null, actual: null, commented: false, ok: true, warnings: warnings, dryRun: true, body: normalized.text, command: previewCommand, })); process.exit(0); } const workDir = mkdtempSync(join(tmpdir(), "fix-issue-body-")); try { const current = readCurrentBody(opts.issue!, opts.repo); if (!current.ok) { console.error(`读不到当前正文(${current.reason}),跳过「是否已经一致」的检查,直接写。`); } if (current.ok && current.body === normalized.text) { console.error(`#${opts.issue} 的正文已经和要写的内容一样,不再发请求。`); console.log(JSON.stringify({ changed: [], edge: null, expected: null, actual: null, commented: false, ok: true, warnings: warnings, dryRun: false, })); process.exit(0); } const written = writeBody(opts.issue!, opts.repo, normalized.text, workDir); if (!written.ok) { const commented = commentFailure(opts.issue!, opts.repo, opts, written.reason, workDir); console.error(`写回 #${opts.issue} 正文失败:${written.reason}`); console.error(commented ? "已在该票下留一条失败评论。" : "失败评论也没留上(可能连评论权限都没有),请人工处理。"); console.log(JSON.stringify({ changed: [], edge: null, expected: null, actual: null, commented: commented, ok: false, warnings: warnings, dryRun: false, error: written.reason || "写回失败", })); process.exit(1); } const changed = normalized.changes.map((c: string): string => `#${opts.issue} 正文:${c}`); changed.push(`#${opts.issue} 正文:已按文件内容写回`); console.error(`#${opts.issue} 正文已写回${written.retried ? "(重试一次后成功)" : ""}。`); console.log(JSON.stringify({ changed: changed, edge: null, expected: null, actual: null, commented: false, ok: true, warnings: warnings, dryRun: false, })); process.exit(0); } finally { try { rmSync(workDir, { recursive: true, force: true }); } catch (e) { /* 临时目录清不掉不影响结果 */ } } } main();