Merge pull request 'feat/pr-comments-and-fix/main' (#47) from feat/pr-comments-and-fix/main into master
Reviewed-on: #47 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #47.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
name: sdlc-fix
|
||||
description: 僅由 /sdlc-fix 指令叫用。讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
|
||||
|
||||
# sdlc-fix
|
||||
|
||||
reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏掉任何一則。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個 PR 編號。
|
||||
|
||||
## 1. 讀留言
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <PR 編號>
|
||||
```
|
||||
|
||||
三類留言一次讀齊:**一般留言**、**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 <owner/name> --index <PR 編號> \
|
||||
--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 標成通過或要求變更。
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).內容, '最後一則');
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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 狀態/);
|
||||
});
|
||||
Reference in New Issue
Block a user