feat(comments-merge): 把留言裡的決策整併回議題描述並標記
判斷「哪幾則有決策、該併進哪一段」是讀得懂內容的人的事;這一支只負責把結果安全地寫回去。 先寫描述再標記,描述寫失敗就不標記:反過來的話,那幾則已經被標成處理過,再也不會被提出來。 --merged 只收真的併進去的那幾則,略過的保持未標記。標記之前先核對那幾則確實在這顆議題上—— 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user