diff --git a/scripts/comments-merge.js b/scripts/comments-merge.js new file mode 100644 index 0000000..795781a --- /dev/null +++ b/scripts/comments-merge.js @@ -0,0 +1,141 @@ +#!/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)', + ); + } +}