diff --git a/prompts/sdlc-fix.md b/prompts/sdlc-fix.md new file mode 100644 index 0000000..9e9905e --- /dev/null +++ b/prompts/sdlc-fix.md @@ -0,0 +1,104 @@ +name: sdlc-fix +description: 僅由 /sdlc-fix 指令叫用。讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。 + +# sdlc-fix + +reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏掉任何一則。 + +這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 + +## 輸入 + +一個 PR 編號。 + +## 1. 讀留言 + +``` +node scripts/pr-comments.js --repo --index +``` + +三類留言一次讀齊:**一般留言**、**review 總評**、**行內留言**。每一則都帶 `id`、`作者`、 +`內容`、`已處理`,行內的還帶 `檔案`/`行`/`diff`——那段 diff 是判斷「他在說哪裡」的依據, +不要略過不看。 + +**先看 `未處理數`。** 它是這一輪要處理的量;`已處理` 為 `true` 的那些是前一輪做過的, +跳過不再處理。 + +**三類都標記得了,機制不同**:一般留言與 review 總評用 `+1` reaction,行內留言用 +resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按讚是「我同意」,不是 +「這則處理過了」。 + +偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的 +那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。 + +## 2. 分類:必改還是建議 + +逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身: + +- **必改** — 指出了錯誤、遺漏、會出事的寫法,或明確要求改動。 +- **建議** — 提出另一種做法、風格偏好、「之後可以考慮」。 + +分類結果**先呈現給使用者**再動手:列出每一則的「類型/作者/一句話摘要/你的分類」。 +分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊, +並在下一步問清楚。 + +## 3. 不確定就問 + +**一次問一題。** 下列情況不要自作主張: + +- 分不出必改還是建議。 +- 知道要改,但有兩種以上做法,而選擇會影響別處。 +- 留言本身看不懂,或它指的位置與現在的程式碼對不上(PR 之後又推了新 commit 是常見原因)。 + +每一題給兩個選項,並附上你判斷的理由: + +- **建議** — 你的答案,寫「為什麼是這個」。 +- **手動輸入** — 讓使用者自己說。 + +**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。 + +## 4. 逐則處理並回覆 + +一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**—— +中途斷掉的話,沒有人知道哪幾則已經處理過。 + +``` +node scripts/pr-reply.js --repo --index \ + --comment <留言 id> --kind inline|general|review --body '<回覆>' --dry-run +``` + +`--kind` 對應 `pr-comments` 給的 `類型`:`行內` → `inline`、`一般` → `general`、 +`總評` → `review`。**三類留言的 id 各自獨立**,`--kind` 給錯會找不到那一則。 + +腳本自己會去查行內留言的位置(含它在新檔還是被刪掉的那一側)與它所屬的 commit, +把回覆放回同一串;也會在回覆成功之後才標記(行內用 resolve,一般與總評用 `+1`)。 +回覆失敗就不標記——沒回卻標記等於謊稱處理過。 + +**留言指向的程式碼已經被改掉時**,回覆可能會被 Gitea 拒絕(位置對不上)。那時不要硬試, +把那一則列進摘要的「無法處理」,讓使用者自己去 PR 上回。 + +**回覆要說出做了什麼**,不是「已修正」。reviewer 看回覆就要知道改法對不對, +不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納, +但沉默會讓 reviewer 以為被忽略了。 + +## 5. 修正摘要 + +全部處理完後印一則摘要,讓 reviewer 不必逐串點開: + +- **必改幾則、建議幾則**,各自處理了幾則、不改幾則。 +- **逐則一行**:作者/一句話原意/你的處置。 +- **沒有留下記號的那幾則**(`可標記` 為 `false`,或腳本回報 `已標記: false`)要單獨 + 列出來,否則 reviewer 掃 reaction 與 resolve 時會以為它們被跳過了。 +- **問過使用者的題目與答案**。 +- 有沒有留言因為指向的程式碼已經變了而無法處理。 + +摘要只印在終端,**不自動張貼到 PR 上**。要不要貼由使用者決定。 + +## 邊界 + +- 不改與留言無關的程式碼。順手看到的問題記下來說出來,不要摸進這一輪。 +- 不自行判斷不確定的事——寧可多問一題。 +- 不跳過任何一則留言。決定不改的也要回覆並說明理由。 +- 不在回覆沒成功時標記已處理。 +- **不自動張貼修正摘要**,也不自動關閉或合併 PR。 +- 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。 diff --git a/scripts/pr-comments.js b/scripts/pr-comments.js new file mode 100644 index 0000000..cae6943 --- /dev/null +++ b/scripts/pr-comments.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** + * 讀 PR 上的三類留言:一般留言、review 總評、行內留言。 + * + * 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是 + * `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。 + * + * 「已處理」在三類上的機制不同: + * - 一般留言、review 總評 → 自己打的 `+1` reaction + * - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點) + * + * **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review + * 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打 + * reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。 + * + * reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是 + * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 + * + * 用法: + * node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + giteaRequest, + main, + pages, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ +const DRAFT = 'PENDING'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const pullPath = `/repos/${repo}/pulls/${index}`; + + if (flags['dry-run']) { + return { + dryRun: true, + repo, + index, + requests: [ + { method: 'GET', path: '/user' }, + { method: 'GET', path: pullPath }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `${pullPath}/reviews` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, + ], + note: + '每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' + + '次數取決於留言數,事前無法列舉。', + }; + } + + const login = resolveLogin({ host: flags.host }); + await preflight(login, repo); + + const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login; + const pull = await fetchPull(login, repo, index, pullPath); + const 留言 = [ + ...(await readGeneral(login, repo, index, me)), + ...(await readReviews(login, repo, index, pullPath, me)), + ]; + + return { + repo, + index: pull.number, + title: pull.title, + url: pull.html_url, + state: pull.state, + 留言, + 未處理數: 留言.filter((comment) => !comment.已處理).length, + }; +}); + + +async function fetchPull(login, repo, index, path) { + const response = await giteaRequest(login, 'GET', path); + if (response.status === 404) { + throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`); + } + if (response.status === 403) { + throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`); + } + return expectOk(response, `GET ${path}`); +} + +/** + * 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。 + * 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。 + */ +async function readGeneral(login, repo, index, me) { + const path = `/repos/${repo}/issues/${index}/comments`; + const 留言 = []; + + for await (const comments of pages(login, path, { + limitCode: 'COMMENT_LIMIT', + limitHint: `${path} 的留言太多,讀不完整份清單`, + })) { + for (const comment of comments) { + if ((comment.body ?? '').trim() === '') continue; + 留言.push({ + id: comment.id, + 類型: '一般', + 作者: comment.user?.login ?? '', + 內容: comment.body, + 已處理: await markedByMe(login, repo, comment.id, me), + 可標記: true, + }); + } + } + return 留言; +} + +/** + * review 的總評與它底下的行內留言。 + * + * 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去; + * 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。 + */ +async function readReviews(login, repo, index, pullPath, me) { + const path = `${pullPath}/reviews`; + const commentIds = await reviewCommentIds(login, repo, index); + const 留言 = []; + + for await (const reviews of pages(login, path, { + limitCode: 'REVIEW_LIMIT', + limitHint: `${path} 的 review 太多,讀不完整份清單`, + })) { + for (const review of reviews) { + if (review.state === DRAFT) continue; + + const commentId = commentIds.get(review.id); + if ((review.body ?? '').trim() !== '') { + 留言.push({ + id: commentId ?? review.id, + review: review.id, + 類型: '總評', + 作者: review.user?.login ?? '', + 內容: review.body, + // 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以 + 已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me), + 可標記: commentId !== undefined, + }); + } + + const commentsPath = `${path}/${review.id}/comments`; + for await (const comments of pages(login, commentsPath, { + limitCode: 'REVIEW_COMMENT_LIMIT', + limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`, + })) { + for (const comment of comments) 留言.push(inlineComment(comment, review.id)); + } + } + } + return 留言; +} + +/** + * 一則行內留言。 + * + * 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`, + * Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined, + * 回覆時位置就送錯欄位、落到別的地方去。 + */ +function inlineComment(comment, reviewId) { + const onNew = (comment.position ?? 0) > 0; + return { + id: comment.id, + review: reviewId, + 類型: '行內', + 作者: comment.user?.login ?? '', + 內容: comment.body, + 檔案: comment.path, + 行: onNew ? comment.position : comment.original_position, + 側: onNew ? '新' : '舊', + // 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡 + diff: comment.diff_hunk ?? '', + // 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上 + commit: comment.commit_id ?? comment.original_commit_id ?? null, + 已處理: comment.resolver != null, + 可標記: true, + }; +} + +/** + * review id → 它在 issue comment 表裡的那一則 id。 + * 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。 + */ +async function reviewCommentIds(login, repo, index) { + const path = `/repos/${repo}/issues/${index}/timeline`; + const ids = new Map(); + + for await (const entries of pages(login, path, { + limitCode: 'TIMELINE_LIMIT', + limitHint: `${path} 的項目太多,對不齊總評的 reaction`, + })) { + for (const entry of entries) { + if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id); + } + } + return ids; +} + +/** + * 這一則是不是「我」標記過已處理。 + * + * Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`: + * reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。 + */ +async function markedByMe(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-reply.js b/scripts/pr-reply.js new file mode 100644 index 0000000..941906d --- /dev/null +++ b/scripts/pr-reply.js @@ -0,0 +1,274 @@ +#!/usr/bin/env node +/** + * 回覆一則 PR 留言,並標記它已經處理過。 + * + * 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事: + * + * - **行內留言**:Gitea 沒有「回覆某一則行內留言」的端點。把新留言指向**同一個檔案 + * 與同一行**,它就會排在原留言底下——位置是串的識別,抓錯就變成另開一串。 + * 位置不由呼叫端給,而是拿留言 id 去查出來:手抄行號是這一段最容易錯的地方。 + * - **一般留言**:PR 的一般留言是平的,沒有串。回覆就是新增一則,並引用原文開頭, + * 讓人看得出在回誰。 + * - **review 總評**:也是平的,回法同一般留言。標記用 `+1`,但 reaction 要打在它 + * **在 issue comment 表裡那一份**的 id 上——那份由 timeline 給,拿 review 自己的 + * id 去打會 404。對不到那一份時如實說標不了,不硬打。 + * + * 標記排在回覆之後,而且回覆沒成功就不標記:沒回就標記等於謊稱處理過。 + * + * 用法: + * node scripts/pr-reply.js --repo owner/name --index 45 --comment <留言 id> + * --kind inline|general|review --body '<回覆>' + * [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + giteaRequest, + main, + pages, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +/** `--kind` 認得的三類,與 pr-comments 的「行內/一般/總評」一一對應 */ +const KINDS = ['inline', 'general', 'review']; + +/** 還沒送出的 review:reviewer 自己都還看不到,不該回進去 */ +const DRAFT = 'PENDING'; + +/** 引用原文時保留的字數。引用是線索,不是複製一份。 */ +const QUOTE_MAX = 60; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index', 'comment', 'kind', 'body'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const commentId = parseIndex(flags.comment, '--comment'); + const kind = parseKind(flags.kind); + const body = parseBody(flags.body); + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + if (!dryRun) await preflight(login, repo); + + // 位置與原文都要先查出來——試跑也查,位置錯了不該等到實跑才發現 + const plan = await planReply(login, repo, index, commentId, kind, body); + + if (dryRun) { + return { dryRun: true, repo, index, comment: commentId, kind, ...plan.報告, requests: plan.requests }; + } + + for (const { method, path, body: payload } of plan.requests) { + expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`); + } + return { repo, index, comment: commentId, kind, ...plan.報告 }; +}); + + +/** + * 算出要發哪幾個請求。 + * 分成「算」與「做」兩段,試跑印出的就是實跑會發的東西,不另外維護一份描述。 + * + * 輸出的欄位形狀三類一致:用不到的欄位填 `null` 而不是讓它消失, + * 下游才不必為了少一欄多寫一種分支。 + */ +async function planReply(login, repo, index, commentId, kind, body) { + if (kind === 'inline') { + const target = await findInline(login, repo, index, commentId); + return { + requests: [ + { + method: 'POST', + path: `/repos/${repo}/pulls/${index}/reviews`, + body: { + // event 是 COMMENT:回覆一則意見不該順手把整個 PR 標成通過或要求變更 + event: 'COMMENT', + // 落在原留言的那個 commit 上:PR 之後又推了新 commit 的話, + // 同一個行號在新 commit 上指的是別的程式碼 + ...(target.commit_id ? { commit_id: target.commit_id } : {}), + comments: [{ path: target.path, ...positionOf(target), body }], + }, + }, + { method: 'POST', path: `/repos/${repo}/pulls/comments/${commentId}/resolve`, body: {} }, + ], + 報告: { + 已標記: true, + 標記方式: 'resolve', + 位置: `${target.path}:${positionOf(target).new_position ?? positionOf(target).old_position}`, + note: null, + }, + }; + } + + if (kind === 'general') { + const original = await findGeneral(login, repo, index, commentId); + return { + requests: [ + { + method: 'POST', + path: `/repos/${repo}/issues/${index}/comments`, + body: { body: `${quote(original.body)}\n\n${body}` }, + }, + { + method: 'POST', + path: `/repos/${repo}/issues/comments/${commentId}/reactions`, + body: { content: '+1' }, + }, + ], + 報告: { 已標記: true, 標記方式: 'reaction', 位置: null, note: null }, + }; + } + + // review 總評:回覆走一般留言那條路,reaction 打在它的 issue comment 那一份上 + const review = await findReview(login, repo, index, commentId); + const markId = await reviewCommentId(login, repo, index, commentId); + const requests = [ + { + method: 'POST', + path: `/repos/${repo}/issues/${index}/comments`, + body: { body: `${quote(review.body)}\n\n${body}` }, + }, + ]; + if (markId !== undefined) { + requests.push({ + method: 'POST', + path: `/repos/${repo}/issues/comments/${markId}/reactions`, + body: { content: '+1' }, + }); + } + + return { + requests, + 報告: { + 已標記: markId !== undefined, + 標記方式: markId === undefined ? null : 'reaction', + 位置: null, + note: + markId === undefined + ? '在 timeline 上對不到這則總評在 issue comment 表裡的那一份,標記不上去;' + + '回覆已經發出,記得在修正摘要裡交代它。' + : null, + }, + }; +} + +/** + * 回覆要送哪一個位置欄位。 + * Gitea 只填 `position` 與 `original_position` 其中一個:新檔那一側用前者, + * 被刪掉的那一行用後者。送錯欄位,回覆會落到別的地方去。 + */ +function positionOf(comment) { + return (comment.position ?? 0) > 0 + ? { new_position: comment.position } + : { old_position: comment.original_position }; +} + +/** 把原文引用起來,讓平的留言看得出在回誰。太長就截斷——引用是線索,不是複製一份。 */ +function quote(text) { + const first = (text ?? '').trim().split('\n')[0]; + const shown = first.length > QUOTE_MAX ? `${first.slice(0, QUOTE_MAX)}…` : first; + return `> ${shown}`; +} + +/** 三類共用的一句話:id 對不上多半是 --kind 給錯了 */ +function notFound(index, kind, id) { + return new ScriptError( + 'COMMENT_NOT_FOUND', + `PR ${index} 上找不到 id 為 ${id} 的${kind};` + + '請確認 --kind 與 --comment 對得上(三類留言的 id 各自獨立),必要時重跑 pr-comments', + ); +} + +/** + * 從 PR 的所有 review 裡找出那一則行內留言,取得它的位置與 commit。 + * Gitea 沒有「依 id 取 review comment」的端點,只能逐個 review 翻。 + */ +async function findInline(login, repo, index, commentId) { + for await (const review of eachReview(login, repo, index)) { + const path = `/repos/${repo}/pulls/${index}/reviews/${review.id}/comments`; + for await (const comments of pages(login, path, { + limitCode: 'REVIEW_COMMENT_LIMIT', + limitHint: `${path} 的行內留言太多,找不完`, + })) { + const hit = comments.find((comment) => comment.id === commentId); + if (hit) return hit; + } + } + throw notFound(index, '行內留言', commentId); +} + +async function findGeneral(login, repo, index, commentId) { + const path = `/repos/${repo}/issues/${index}/comments`; + + for await (const comments of pages(login, path, { + limitCode: 'COMMENT_LIMIT', + limitHint: `${path} 的留言太多,找不完`, + })) { + const hit = comments.find((comment) => comment.id === commentId); + if (hit) return hit; + } + throw notFound(index, '一般留言', commentId); +} + +async function findReview(login, repo, index, reviewId) { + for await (const review of eachReview(login, repo, index)) { + if (review.id === reviewId) return review; + } + throw notFound(index, 'review', reviewId); +} + +/** + * 逐頁走過已送出的 review。 + * PENDING 的跳過:那是 reviewer 寫到一半的草稿,他自己都還看不到,不該回進去。 + */ +async function* eachReview(login, repo, index) { + const path = `/repos/${repo}/pulls/${index}/reviews`; + + for await (const reviews of pages(login, path, { + limitCode: 'REVIEW_LIMIT', + limitHint: `${path} 的 review 太多,找不完`, + })) { + for (const review of reviews) { + if (review.state !== DRAFT) yield review; + } + } +} + +/** 總評在 issue comment 表裡那一份的 id;對不到就回 undefined,讓呼叫端如實說標不了 */ +async function reviewCommentId(login, repo, index, reviewId) { + const path = `/repos/${repo}/issues/${index}/timeline`; + + for await (const entries of pages(login, path, { + limitCode: 'TIMELINE_LIMIT', + limitHint: `${path} 的項目太多,對不齊總評的 reaction`, + })) { + const hit = entries.find((entry) => entry.type === 'review' && entry.review_id === reviewId); + if (hit) return hit.id; + } + return undefined; +} + +function parseKind(value) { + if (!KINDS.includes(value)) { + throw new ScriptError( + 'BAD_KIND', + `--kind 需為 ${KINDS.join('/')} 其中一個,收到的是 ${value}`, + ); + } + return value; +} + +function parseBody(value) { + const body = value.trim(); + if (body === '') { + throw new ScriptError('EMPTY_REPLY', '--body 不能是空的:回覆要說出這一則是怎麼處理的'); + } + return body; +} diff --git a/test/pr-comments.test.js b/test/pr-comments.test.js new file mode 100644 index 0000000..7456048 --- /dev/null +++ b/test/pr-comments.test.js @@ -0,0 +1,417 @@ +/** + * 讀 PR 上的三類留言。 + * + * 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是這一段 + * 存在的理由。所以測試的重點是「三類都讀到」與「每一則都說得出它能不能被標記」。 + * + * 「已處理」在三類上的機制不同:一般留言與 review 總評看**自己打的** `+1` reaction, + * 行內留言看它有沒有被 resolve。總評的 reaction 掛在它的 issue comment id 上, + * 不是 review id——兩個 id 不同命名空間,弄錯會 404。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 45; + +/** + * 組出一份假的 PR 留言現場。 + * @param {object} options general/reviews 各自的內容 + */ +function routes(overrides = {}, options = {}) { + const { + general = [], + reviews = [], + pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' }, + } = options; + + const base = healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, body: pull }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + status: 200, + body: general.map((c, i) => ({ + id: 100 + i, + body: c.body, + user: { login: c.user ?? 'reviewer' }, + })), + }, + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { + status: 200, + body: reviews.map((r, i) => ({ + id: 200 + i, + body: r.body ?? '', + state: r.state ?? 'COMMENT', + user: { login: r.user ?? 'reviewer' }, + })), + }, + // 總評在 issue comment 表裡也有一份,timeline 給得出它的 id 與 review_id + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { + status: 200, + body: reviews.map((r, i) => ({ + id: 400 + i, + type: 'review', + review_id: 200 + i, + body: r.body ?? '', + user: { login: r.user ?? 'reviewer' }, + })), + }, + }); + + general.forEach((c, i) => { + base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { + status: 200, + body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })), + }; + }); + reviews.forEach((r, i) => { + // 總評那一則的 reaction 掛在它的 issue comment id 上 + base[`GET /api/v1/repos/${REPO}/issues/comments/${400 + i}/reactions`] = { + status: 200, + body: (r.reactions ?? []).map((content) => ({ content, user: { login: 'tester' } })), + }; + base[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${200 + i}/comments`] = { + status: 200, + body: (r.comments ?? []).map((c, j) => ({ + id: 300 + i * 10 + j, + body: c.body, + path: c.path, + position: c.position ?? 1, + diff_hunk: c.diff ?? '@@ -1 +1 @@', + user: { login: c.user ?? r.user ?? 'reviewer' }, + resolver: c.resolved ? { login: 'someone' } : null, + })), + }; + }); + return { ...base, ...overrides }; +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +const run = (args, stub) => + runScript('pr-comments.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + +/** 一份三類俱全的現場 */ +const FULL = { + general: [ + { body: '整體方向沒問題,但命名再想想。', reactions: [] }, + { body: '這個我已經處理過了。', reactions: ['+1'] }, + ], + reviews: [ + { + body: '大致可以,兩個地方要改。', + state: 'REQUEST_CHANGES', + comments: [ + { body: '這裡少了錯誤處理。', path: 'scripts/claim.js', position: 12 }, + { body: '這行可以刪掉。', path: 'scripts/claim.js', position: 30, resolved: true }, + ], + }, + { body: '', state: 'APPROVED', comments: [{ body: '順手提一下拼字。', path: 'README.md', position: 3 }] }, + ], +}; + +// ── 三類都要讀到 ─────────────────────────────────────────────────── + +test('一般留言、review 總評、行內留言三類都讀得到', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { code, json } = await run([], stub); + + assert.equal(code, 0, JSON.stringify(json)); + const kinds = json.data.留言.map((c) => c.類型); + assert.equal(kinds.filter((k) => k === '一般').length, 2); + assert.equal(kinds.filter((k) => k === '總評').length, 1, '只有 body 非空的 review 算總評'); + assert.equal(kinds.filter((k) => k === '行內').length, 3); +}); + +test('body 是空的 review 不算總評:那是純粹的行內留言容器', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + const 總評 = json.data.留言.filter((c) => c.類型 === '總評'); + assert.deepEqual(總評.map((c) => c.內容), ['大致可以,兩個地方要改。']); +}); + +test('行內留言帶著檔案、行號與 diff 片段,agent 才看得懂在說哪裡', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + const 行內 = json.data.留言.find((c) => c.類型 === '行內'); + assert.equal(行內.檔案, 'scripts/claim.js'); + assert.equal(行內.行, 12); + assert.match(行內.diff, /@@/); +}); + +test('每一則都帶 id 與作者', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + for (const comment of json.data.留言) { + assert.equal(typeof comment.id, 'number', JSON.stringify(comment)); + assert.equal(typeof comment.作者, 'string'); + } +}); + +// ── 已處理的判斷:三類各有各的機制 ───────────────────────────────── + +test('一般留言看 +1 reaction', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + const 一般 = json.data.留言.filter((c) => c.類型 === '一般'); + assert.deepEqual(一般.map((c) => c.已處理), [false, true]); +}); + +test('行內留言看它有沒有被 resolve', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + const 行內 = json.data.留言.filter((c) => c.類型 === '行內'); + assert.deepEqual(行內.map((c) => c.已處理), [false, true, false]); +}); + +test('三類都標記得了:總評用它在 issue comment 表裡的那一份', async (t) => { + // Gitea 的 review 總評在 issue comment 表裡也有一份,reaction 掛在那個 id 上。 + // 用 review 自己的 id 去打 reaction 會 404——兩個 id 不同命名空間。 + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + for (const comment of json.data.留言) { + assert.equal(comment.可標記, true, `${comment.類型}應該標記得了`); + } +}); + +test('總評的 id 是它在 issue comment 表裡的 id,不是 review id', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + const 總評 = json.data.留言.find((c) => c.類型 === '總評'); + assert.equal(總評.id, 400, 'timeline 給的 comment id'); + assert.equal(總評.review, 200, 'review id 另外帶著,行內留言要靠它查'); +}); + +test('總評的已處理看它自己那則 comment 的 +1', async (t) => { + const stub = await withStub(t, {}, { + reviews: [ + { body: '這則處理過了', reactions: ['+1'] }, + { body: '這則還沒', reactions: [] }, + ], + }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]); +}); + +test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => { + // reviewer 對自己的留言按讚很常見;當成已處理的話,那一則會被靜靜跳過 + const stub = await withStub(t, {}, { + general: [ + { body: '自己打的', reactions: ['+1'], reactedBy: 'tester' }, + { body: '別人打的', reactions: ['+1'], reactedBy: 'reviewer' }, + ], + }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]); +}); + +test('未處理數只算還沒處理的,且與明細對得上', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + assert.equal(json.data.未處理數, json.data.留言.filter((c) => !c.已處理).length); + assert.equal(json.data.未處理數, 4); +}); + +test('留在刪除行的行內留言,位置在 original_position 上', async (t) => { + // Gitea 只填 position 與 original_position 其中一個:新檔那一側用 position, + // 舊檔(被刪掉的行)那一側用 original_position。只讀 position 會得到 undefined。 + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/200/comments`]: { + status: 200, + body: [{ + id: 350, + body: '這一行為什麼刪掉?', + path: 'scripts/old.js', + position: 0, + original_position: 7, + diff_hunk: '@@ -7 +0 @@', + user: { login: 'r' }, + resolver: null, + }], + }, + }, { reviews: [{ body: '', comments: [] }] }); + + const { json } = await run([], stub); + + const 行內 = json.data.留言.find((c) => c.類型 === '行內'); + assert.equal(行內.行, 7); + assert.equal(行內.側, '舊', '回覆時要知道它在哪一側,否則位置會送錯欄位'); +}); + +test('一般的行內留言在新檔那一側', async (t) => { + const stub = await withStub(t, {}, FULL); + + const 行內 = (await run([], stub)).json.data.留言.find((c) => c.類型 === '行內'); + + assert.equal(行內.側, '新'); +}); + +// ── 分頁:三類都要讀完 ───────────────────────────────────────────── + +test('review 逐頁讀完:超過一頁就漏掉總評與行內留言', async (t) => { + // 這個站台的預設頁大小是 30;沒分頁的話,第 31 個 review 之後整批消失, + // 而「不漏掉任何一則」正是這個指令存在的理由 + const page1 = Array.from({ length: 50 }, (_, i) => ({ + id: 600 + i, body: `第 ${i} 則總評`, state: 'COMMENT', user: { login: 'r' }, + })); + const page2 = [{ id: 700, body: '最後一則總評', state: 'COMMENT', user: { login: 'r' } }]; + const extra = {}; + for (const r of [...page1, ...page2]) { + extra[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${r.id}/comments`] = { status: 200, body: [] }; + } + + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: (req) => ({ + status: 200, + body: req.query.page === '1' ? page1 : page2, + }), + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({ + status: 200, + body: (req.query.page === '1' ? page1 : page2).map((r) => ({ + id: r.id + 5000, type: 'review', review_id: r.id, body: r.body, user: r.user, + })), + }), + ...extra, + ...Object.fromEntries([...page1, ...page2].map((r) => [ + `GET /api/v1/repos/${REPO}/issues/comments/${r.id + 5000}/reactions`, { status: 200, body: [] }, + ])), + }, {}); + + const { json } = await run([], stub); + + assert.equal(json.data.留言.length, 51); + assert.equal(json.data.留言.at(-1).內容, '最後一則總評'); +}); + +// ── PENDING 的 review 還沒送出 ───────────────────────────────────── + +test('PENDING 的 review 不算數:它還沒送出,reviewer 自己也看不到', async (t) => { + const stub = await withStub(t, {}, { + reviews: [ + { body: '寫到一半的草稿', state: 'PENDING', comments: [{ body: '草稿裡的行內', path: 'a.js' }] }, + { body: '送出來的', state: 'COMMENT' }, + ], + }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.留言.map((c) => c.內容), ['送出來的']); +}); + +// ── 空留言與系統事件 ─────────────────────────────────────────────── + +test('內容是空的一般留言不算:那多半是狀態變更的系統紀錄', async (t) => { + const stub = await withStub(t, {}, { + general: [{ body: '' }, { body: ' ' }, { body: '真的留言' }], + }); + + const { json } = await run([], stub); + + assert.deepEqual(json.data.留言.map((c) => c.內容), ['真的留言']); +}); + +test('完全沒有留言時回空陣列,不報錯', async (t) => { + const stub = await withStub(t, {}, {}); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.deepEqual(json.data.留言, []); + assert.equal(json.data.未處理數, 0); +}); + +// ── PR 本身 ─────────────────────────────────────────────────────── + +test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { json } = await run([], stub); + + assert.equal(json.data.index, INDEX); + assert.equal(json.data.title, 'feat/pr-comments/main'); + assert.equal(json.data.url, `https://x/${INDEX}`); + assert.equal(json.data.state, 'open'); +}); + +test('PR 不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 404, body: { message: 'not found' } }, + }, FULL); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'PULL_NOT_FOUND'); + assert.match(json.error.message, new RegExp(String(INDEX))); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { + const stub = await withStub(t, {}, FULL); + + const { code, json } = await run(['--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + 'GET /user', + `GET /repos/${REPO}/pulls/${INDEX}`, + `GET /repos/${REPO}/issues/${INDEX}/comments`, + `GET /repos/${REPO}/pulls/${INDEX}/reviews`, + `GET /repos/${REPO}/issues/${INDEX}/timeline`, + ], + ); + assert.match(json.data.note, /reaction|review/); + assert.equal(stub.requests.length, 0); +}); + +// ── 分頁 ─────────────────────────────────────────────────────────── + +test('留言逐頁讀完,不是只讀第一頁', async (t) => { + const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } })); + const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }]; + const reactions = {}; + for (const c of [...page1, ...page2]) { + reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] }; + } + + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({ + status: 200, + body: req.query.page === '1' ? page1 : page2, + }), + ...reactions, + }, {}); + + const { json } = await run([], stub); + + assert.equal(json.data.留言.length, 51); + assert.equal(json.data.留言.at(-1).內容, '最後一則'); +}); diff --git a/test/pr-reply.test.js b/test/pr-reply.test.js new file mode 100644 index 0000000..98bf675 --- /dev/null +++ b/test/pr-reply.test.js @@ -0,0 +1,350 @@ +/** + * 回覆一則 PR 留言並標記已處理。 + * + * 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事: + * - 行內留言 → 新 review 帶一則指向**同一個檔案與同一行**的留言,Gitea 才會把它 + * 排在原留言底下。位置抓錯就變成另開一串,reviewer 得自己找對應。 + * - 一般留言 → PR 的一般留言是平的,沒有串;回覆就是新增一則,並引用原文開頭 + * 讓人看得出在回誰。 + * - review 總評 → 沒有 reaction 也沒有 resolve,回覆是唯一能留下的痕跡。 + * + * 所以這一支的測試重點是「回對地方」與「標記用對機制」,而不是回覆的文字內容。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 45; +/** 行內留言的 id 與它所在的位置 */ +const INLINE = { id: 301, path: 'scripts/claim.js', position: 12, commit: 'abc123' }; +const GENERAL = { id: 101, body: '命名再想想。' }; +/** 總評:review 表的 id 與它在 issue comment 表裡那一份的 id */ +const REVIEW = { id: 201, commentId: 401, body: '大致可以,兩個地方要改。' }; + +function routes(overrides = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { + status: 200, + body: [{ id: REVIEW.id, body: REVIEW.body, state: 'REQUEST_CHANGES', user: { login: 'r' } }], + }, + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: { + status: 200, + body: [{ + id: INLINE.id, + body: '這裡少了錯誤處理。', + path: INLINE.path, + position: INLINE.position, + commit_id: INLINE.commit, + user: { login: 'r' }, + resolver: null, + }], + }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { + status: 200, + body: [{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body }], + }, + [`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + status: 200, + body: [{ id: GENERAL.id, body: GENERAL.body, user: { login: 'r' } }], + }, + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } }, + [`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} }, + [`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } }, + [`POST /api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`]: { status: 200, body: {} }, + ...overrides, + }); +} + +const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); + +const run = (args, stub) => + runScript('pr-reply.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + +const posted = (stub) => + stub.requests.filter((r) => r.method === 'POST' && !r.path.endsWith('/issues/0')); + +// ── 行內留言:回在同一個位置 ─────────────────────────────────────── + +test('回覆行內留言時,新留言指向同一個檔案與同一行', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已補上錯誤處理。'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + const review = posted(stub).find((r) => r.path.endsWith('/reviews')); + assert.deepEqual(review.body.comments, [ + { path: INLINE.path, new_position: INLINE.position, body: '已補上錯誤處理。' }, + ]); + assert.equal(review.body.event, 'COMMENT', '回覆不該順手把 PR 標成通過或要求變更'); + assert.equal( + review.body.commit_id, + INLINE.commit, + 'PR 之後又推了新 commit 時,行號在新 commit 上指的是別的程式碼', + ); +}); + +test('留在刪除行的留言要送 old_position,不是 new_position', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: { + status: 200, + body: [{ + id: INLINE.id, + body: '這一行為什麼刪掉?', + path: 'scripts/old.js', + position: 0, + original_position: 7, + commit_id: INLINE.commit, + user: { login: 'r' }, + resolver: null, + }], + }, + }); + + const { code, json } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '因為它已經沒有呼叫端了。'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(posted(stub).find((r) => r.path.endsWith('/reviews')).body.comments, [ + { path: 'scripts/old.js', old_position: 7, body: '因為它已經沒有呼叫端了。' }, + ]); +}); + +test('行內留言回覆完會被 resolve', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], + stub, + ); + + assert.ok(posted(stub).some((r) => r.path.endsWith(`/pulls/comments/${INLINE.id}/resolve`))); + assert.equal(json.data.已標記, true); + assert.equal(json.data.標記方式, 'resolve'); +}); + +test('先回覆再標記:標記是「這一則處理完了」的結論', async (t) => { + const stub = await withStub(t); + + await run(['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub); + + const paths = posted(stub).map((r) => r.path); + assert.ok( + paths.indexOf(`/api/v1/repos/${REPO}/pulls/${INDEX}/reviews`) + < paths.indexOf(`/api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`), + ); +}); + +test('回覆失敗時不標記:沒回就標記等於謊稱處理過', async (t) => { + const stub = await withStub(t, { + [`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 422, body: { message: 'bad position' } }, + }); + + const { code } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], + stub, + ); + + assert.equal(code, 1); + assert.equal(posted(stub).some((r) => r.path.endsWith('/resolve')), false); +}); + +test('找不到那則行內留言時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', '99999', '--kind', 'inline', '--body', '已修正。'], + stub, + ); + + assert.equal(json.error.code, 'COMMENT_NOT_FOUND'); + assert.deepEqual(posted(stub), []); +}); + +// ── 一般留言:平的,回覆要引用得出在回誰 ─────────────────────────── + +test('回覆一般留言時新增一則留言,並引用原文讓人看得出在回誰', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run( + ['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已改名為 claimWorkPackage。'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + const comment = posted(stub).find((r) => r.path.endsWith(`/issues/${INDEX}/comments`)); + assert.match(comment.body.body, /已改名為 claimWorkPackage。/); + assert.match(comment.body.body, />.*命名再想想/s, 'PR 的一般留言是平的,要引用才看得出在回誰'); +}); + +test('一般留言用 +1 標記,不是 resolve', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已處理。'], + stub, + ); + + const reaction = posted(stub).find((r) => r.path.endsWith('/reactions')); + assert.deepEqual(reaction.body, { content: '+1' }); + assert.equal(json.data.標記方式, 'reaction'); +}); + +// ── review 總評:標不了,但仍要回 ───────────────────────────────── + +test('回覆 review 總評並用它在 issue comment 表裡的 id 打 +1', async (t) => { + // 總評標得了,只是 reaction 要掛在另一個 id 上;用 review id 會 404 + const stub = await withStub(t); + + const { code, json } = await run( + ['--comment', String(REVIEW.id), '--kind', 'review', '--body', '兩處都已修正。'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.已標記, true); + assert.equal(json.data.標記方式, 'reaction'); + const reaction = posted(stub).find((r) => r.path.includes('/reactions')); + assert.ok( + reaction.path.endsWith(`/issues/comments/${REVIEW.commentId}/reactions`), + `reaction 要打在 ${REVIEW.commentId} 上,不是 review 的 ${REVIEW.id}`, + ); +}); + +test('總評在 timeline 上對不到那一份時,如實說標不了而不是硬打', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] }, + }); + + const { code, json } = await run( + ['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'], + stub, + ); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.已標記, false); + assert.equal(json.data.標記方式, null); + assert.match(json.data.note ?? '', /標記/); + assert.equal(posted(stub).some((r) => r.path.includes('/reactions')), false); +}); + +test('輸出的欄位形狀固定:用不到的欄位是 null,不是不存在', async (t) => { + const stub = await withStub(t); + + const inline = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub); + const stub2 = await withStub(t); + const general = await run( + ['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已修正。'], stub2); + + for (const { json } of [inline, general]) { + for (const key of ['已標記', '標記方式', '位置', 'note']) { + assert.ok(key in json.data, `輸出少了 ${key}:下游不該為此多寫一種分支`); + } + } + assert.equal(general.json.data.位置, null, '一般留言沒有位置,但欄位要在'); +}); + +// ── 輸入 ─────────────────────────────────────────────────────────── + +test('--kind 不是三類之一時擋下,並列出可用的', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(GENERAL.id), '--kind', '行內', '--body', '已處理。'], + stub, + ); + + assert.equal(json.error.code, 'BAD_KIND'); + assert.match(json.error.message, /inline/); +}); + +test('回覆內容是空的時候擋下', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(GENERAL.id), '--kind', 'general', '--body', ' '], + stub, + ); + + assert.equal(json.error.code, 'EMPTY_REPLY'); + assert.deepEqual(posted(stub), []); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的回覆與標記,且不張貼', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--dry-run'], + stub, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `POST /repos/${REPO}/pulls/${INDEX}/reviews`, + `POST /repos/${REPO}/pulls/comments/${INLINE.id}/resolve`, + ], + ); + assert.deepEqual(posted(stub), [], '預覽不得張貼'); +}); + +test('--dry-run 照樣查得出位置:位置錯了不該等到實跑才發現', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--dry-run'], + stub, + ); + + assert.deepEqual(json.data.requests[0].body.comments, [ + { path: INLINE.path, new_position: INLINE.position, body: '已修正。' }, + ]); +}); + +test('--dry-run 對總評印出回覆與 reaction 兩步', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。', '--dry-run'], + stub, + ); + + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `POST /repos/${REPO}/issues/${INDEX}/comments`, + `POST /repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`, + ], + ); +}); + +test('PENDING 的 review 回不了:reviewer 自己都還沒送出', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { + status: 200, + body: [{ id: REVIEW.id, body: '草稿', state: 'PENDING', user: { login: 'r' } }], + }, + }); + + const { json } = await run( + ['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'], + stub, + ); + + assert.equal(json.error.code, 'COMMENT_NOT_FOUND'); + assert.deepEqual(posted(stub), []); +}); diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js new file mode 100644 index 0000000..a23866a --- /dev/null +++ b/test/sdlc-fix-assets.test.js @@ -0,0 +1,133 @@ +/** + * /sdlc-fix 的流程正本。 + * + * 這一段有三件事只有正本做得到,腳本擋不住:分類必改/建議、不確定時停下來問、 + * 以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過——而那正是這個指令 + * 存在的理由。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js'; + +const prompt = readPrompt('sdlc-fix'); +const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界')); + +test('正本平台中立,description 前綴正確', () => { + assertNeutralPrompt(prompt, 'sdlc-fix'); +}); + +test('指名兩支腳本,順序為先讀再回', () => { + const order = ['pr-comments.js', 'pr-reply.js']; + const positions = order.map((name) => steps.indexOf(name)); + assert.equal(positions.every((p) => p >= 0), true, '兩支腳本都要被指名'); + assert.deepEqual([...positions].sort((a, b) => a - b), positions); +}); + +test('三類留言都點名,且交代行內的 diff 要看', () => { + for (const kind of ['一般留言', 'review 總評', '行內留言']) { + assert.match(steps, new RegExp(kind), `缺少:${kind}`); + } + assert.match(steps, /不要略過不看/); +}); + +test('三類的標記機制都交代了,並說明 +1 要是自己打的', () => { + assert.match(steps, /三類都標記得了/); + assert.match(steps, /resolve/); + assert.match(steps, /自己打的/); + assert.match(steps, /我同意/, '要說出為什麼別人的 \+1 不算'); +}); + +test('標不了的那幾則要在摘要裡單獨點出來', () => { + assert.match(steps, /可標記/); + assert.match(steps, /單獨點出來|單獨\n? 列出來|單獨列出來/); +}); + +test('留言指向的程式碼已被改掉時的處置有交代', () => { + assert.match(steps, /位置對不上/); + assert.match(steps, /不要硬試/); +}); + +// ── 分類 ─────────────────────────────────────────────────────────── + +test('必改與建議各有判斷依據,不是只給兩個詞', () => { + assert.match(steps, /\*\*必改\*\* — .{10,}/); + assert.match(steps, /\*\*建議\*\* — .{10,}/); +}); + +test('分類結果要先呈現給使用者再動手', () => { + assert.match(steps, /先呈現給使用者/); +}); + +test('拿不準時往必改那邊靠,並說明為什麼', () => { + assert.match(steps, /歸到必改/); + assert.match(steps, /代價不對稱/); +}); + +// ── 不確定就問 ───────────────────────────────────────────────────── + +test('一次問一題,選項含手動輸入', () => { + assert.match(steps, /一次問一題/); + assert.match(steps, /手動輸入/); +}); + +test('該問的情況有列舉,不是一句「不確定就問」', () => { + const section = steps.slice(steps.indexOf('## 3.'), steps.indexOf('## 4.')); + const bullets = section.match(/^- /gm) ?? []; + assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`); + assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名'); +}); + +test('說明了硬改的代價', () => { + assert.match(steps, /比多問一題貴得多/); +}); + +// ── 逐則處理 ─────────────────────────────────────────────────────── + +test('要一則一則回,不是全部改完才一起回', () => { + assert.match(steps, /不要全部改完才一起回/); + assert.match(steps, /中途斷掉/); +}); + +test('三類與 --kind 的對應寫出來了,並警告 id 各自獨立', () => { + assert.match(steps, /行內.*inline/); + assert.match(steps, /一般.*general/); + assert.match(steps, /總評.*review/); + assert.match(steps, /id 各自獨立/); +}); + +test('回覆要說出做了什麼,且決定不改的也要回', () => { + assert.match(steps, /不是「已修正」/); + assert.match(steps, /決定不改的也要回/); + assert.match(steps, /沉默會讓 reviewer 以為被忽略/); +}); + +test('回覆失敗就不標記的理由有寫', () => { + assert.match(steps, /謊稱處理過/); +}); + +test('寫入前要求先試跑', () => { + assert.match(steps, /--dry-run/); +}); + +// ── 摘要 ─────────────────────────────────────────────────────────── + +test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => { + const section = steps.slice(steps.indexOf('## 5.')); + assert.match(section, /逐則一行/); + assert.match(section, /沒有留下記號的那幾則/); + assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列'); +}); + +test('摘要不自動張貼', () => { + assert.match(steps, /不自動張貼/); +}); + +// ── 邊界 ─────────────────────────────────────────────────────────── + +test('邊界列出不做的事,含不動 review 狀態', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /不改與留言無關的程式碼/); + assert.match(boundary, /不跳過任何一則留言/); + assert.match(boundary, /不自動張貼修正摘要/); + assert.match(boundary, /不動 PR 的 review 狀態/); +});