#!/usr/bin/env node /** * 把留言裡的決策整併回議題描述,並標記那幾則留言。 * * 判斷「哪幾則留言有決策、該併進哪一段、併成什麼樣子」是讀得懂內容的人的事; * 這一支只負責把結果安全地寫回去。 * * 兩件事錯了都很安靜,所以都做得很窄: * * - **局部更新。** 只換指定那一段,標題與其餘段落一字不動。整份重寫會把別人在其他 * 段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。 * - **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來; * 描述沒寫成功就不標記——標了就等於這則再也不會被看到。 * * 用法: * node scripts/comments-merge.js --repo owner/name --index 7 * --section <段落名> --content-file <檔案> --merged 101,102 * [--host <網址>] [--dry-run] */ import { ScriptError, expectOk, fetchIssue, giteaRequest, listIssueComments, main, parseFlags, parseIndex, parseRepo, preflight, readTextFile, resolveLogin, } from './lib.js'; import { replaceSection } from './issue-body.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index', 'section', 'content-file', 'merged'], optional: ['host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const section = flags.section; const content = readTextFile(flags['content-file'], '--content-file'); const merged = parseMerged(flags.merged); const dryRun = flags['dry-run'] === true; const login = resolveLogin({ host: flags.host }); if (!dryRun) await preflight(login, repo); const issue = await fetchIssue(login, repo, index); const issuePath = `/repos/${repo}/issues/${index}`; const result = replaceSection(issue.body ?? '', section, content); if (result.status === 'not-found') { throw new ScriptError( 'SECTION_NOT_FOUND', `議題 #${index} 上沒有「${section}」這個段落;請確認段落名與議題上的 \`## 標題\` 完全一致。` + '本工具不會把內容補到議題末尾——段落名打錯時那樣做比什麼都不做更難收拾', ); } if (result.status === 'ambiguous') { throw new ScriptError( 'SECTION_AMBIGUOUS', `議題 #${index} 上有 ${result.count} 個「${section}」段落,分不出要換哪一個;` + '請先到議題上把重複的標題改成看得出差別的名稱', ); } const body = result.body; // 標記之前先確認這幾則留言真的在這顆議題上:標錯地方的 reaction 很難發現 await checkComments(login, repo, index, merged); // 描述沒變就不送:空的 PATCH 會把議題的 updated_at 推新,看起來像有人動過 const 描述已更新 = body !== issue.body; const requests = [ ...(描述已更新 ? [{ method: 'PATCH', path: issuePath, body: { body } }] : []), ...merged.map((id) => ({ method: 'POST', path: `/repos/${repo}/issues/comments/${id}/reactions`, body: { content: '+1' }, })), ]; if (dryRun) { return { dryRun: true, repo, index, section, 描述已更新, 已標記: merged, requests }; } // 順序是先寫描述再標記:標記是「這則已經收進去了」的結論, // 反過來的話,描述寫失敗時那幾則已經被標成處理過,再也不會被提出來 for (const { method, path, body: payload } of requests) { expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`); } return { repo, index, url: issue.html_url, section, 描述已更新, 已標記: merged, }; }); /** 逗號分隔的留言 id。整併卻不標記的話,下次會重複處理同一則,所以這個 flag 是必填。 */ function parseMerged(value) { const ids = value .split(',') .map((item) => item.trim()) .filter((item) => item !== '') .map((item) => parseIndex(item, '--merged')); if (ids.length === 0) { throw new ScriptError('MISSING_FLAG', '--merged 至少要有一則留言 id'); } return [...new Set(ids)]; } /** * 確認這幾則留言都在這顆議題上。 * 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。 */ async function checkComments(login, repo, index, merged) { const seen = new Set(); for await (const comment of listIssueComments(login, repo, index)) { seen.add(comment.id); } const missing = merged.filter((id) => !seen.has(id)); if (missing.length > 0) { throw new ScriptError( 'COMMENT_NOT_FOUND', `議題 #${index} 上沒有這幾則留言:${missing.join('、')};` + '請確認 id 來自這顆議題(必要時重跑 issue-extract 或 wp-extract)', ); } }