diff --git a/scripts/lib.js b/scripts/lib.js index ca2fb8b..bc97400 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -663,6 +663,28 @@ function checkTimeTracker(info) { } } +/** + * 讀一個由 flag 指定的文字檔。 + * + * 「讀一個 --xxx-file 或直接失敗」原本在四支腳本裡各寫一份,錯誤碼還有三種拼法。 + * 同一種情況要有同一個碼,呼叫端才分辨得出到底是哪一步壞了。 + * + * @param {string} path 檔案路徑 + * @param {string} flag 出現在錯誤訊息裡的 flag 名,例如 '--body-file' + * @param {{allowEmpty?: boolean}} options 內容可不可以是空的;預設不可以 + * @returns {string} + */ +export function readTextFile(path, flag, { allowEmpty = false } = {}) { + if (!existsSync(path)) { + throw new ScriptError('FILE_NOT_FOUND', `找不到 ${flag} 指定的檔案 ${path}`); + } + const content = readFileSync(path, 'utf8'); + if (!allowEmpty && content.trim() === '') { + throw new ScriptError('FILE_EMPTY', `${flag} 指定的檔案 ${path} 是空的`); + } + return content; +} + // ── 議題讀取:兩支抽取腳本共用 ──────────────────────────────────── /** @@ -697,32 +719,57 @@ export const UNMERGED_COMMENT_NOTE = * 數出尚未被整併回描述的留言則數。 * * 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。 - * 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件 - * 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準 - * ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。 + * 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長, + * 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完, + * 讀不完寧可報錯。 * * @param {{base: string, token: string}} login * @param {string} repo owner/name * @param {number} index + * @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過 * @returns {Promise} */ -export async function countUnmergedComments(login, repo, index) { - const commentsPath = `/repos/${repo}/issues/${index}/comments`; +export async function countUnmergedComments(login, repo, index, me) { let unmerged = 0; - for await (const comments of pages(login, commentsPath, { - limitCode: 'COMMENT_LIMIT', - limitHint: `${commentsPath} 的留言太多,數不完未整併的則數`, - })) { - for (const comment of comments) { - const path = `/repos/${repo}/issues/comments/${comment.id}/reactions`; - const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; - if (!reactions.some((reaction) => reaction.content === '+1')) unmerged += 1; - } + for await (const comment of listIssueComments(login, repo, index)) { + if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1; } return unmerged; } +/** + * 逐頁走過一顆議題(或 PR)的一般留言。 + * 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。 + * @returns {AsyncGenerator} 一則一則交出去 + */ +export async function* listIssueComments(login, repo, index) { + const path = `/repos/${repo}/issues/${index}/comments`; + + for await (const comments of pages(login, path, { + limitCode: 'COMMENT_LIMIT', + limitHint: `${path} 的留言太多,讀不完整份清單`, + })) { + for (const comment of comments) yield comment; + } +} + +/** + * 這一則是不是「我」標記過已整併。 + * + * Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`: + * 別人按讚是「我同意」,當成已整併會讓那一則的決策永遠不被收進描述—— + * 而那正是 /sdlc-sync 要解決的事。 + * + * @param {string} me 目前登入帳號;preflight 的回傳帶得出來 + */ +export async function mergedByMe(login, repo, id, me) { + const path = `/repos/${repo}/issues/comments/${id}/reactions`; + const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + + return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me); +} + // ── 碼錶 ─────────────────────────────────────────────────────────── /** diff --git a/scripts/pr-create.js b/scripts/pr-create.js index c256f1b..a9dc353 100644 --- a/scripts/pr-create.js +++ b/scripts/pr-create.js @@ -25,7 +25,6 @@ * --body-file <描述檔> --index 13 * [--issue-repo owner/name] [--host <網址>] [--dry-run] */ -import { existsSync, readFileSync } from 'node:fs'; import { ScriptError, expectOk, @@ -35,6 +34,7 @@ import { parseIndex, parseRepo, preflight, + readTextFile, resolveLogin, } from './lib.js'; @@ -87,7 +87,7 @@ main(async () => { const head = flags.head; const base = flags.base; const index = parseIndex(flags.index); - const body = readBody(flags['body-file']); + const body = readTextFile(flags['body-file'], '--body-file'); // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 checkSections(body); @@ -160,12 +160,6 @@ async function findOpenPull(login, repo, head) { } -function readBody(path) { - if (!existsSync(path)) { - throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); - } - return readFileSync(path, 'utf8'); -} /** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */ function checkSections(body) {