Files
tea-sdlc/scripts/pr-comments.js
T
jiantw83 cc29b5bc21 feat(pr-comments): 讀 PR 上的三類留言
一般留言、review 總評、行內留言分散在三個端點,漏掉任何一類就會有意見沒被處理——
而那正是 /sdlc-fix 存在的理由。只讀不寫,必改/建議的分類由讀到內容的人判斷。

三類的「已處理」機制不同:一般留言與總評看自己打的 +1,行內留言看有沒有被 resolve。
總評的 reaction 掛在它在 issue comment 表裡那一份的 id 上,由 timeline 對應得出來;
拿 review 自己的 id 去打會 404,兩個 id 不同命名空間。

reaction 要是自己打的才算已處理:reviewer 對留言按讚是「我同意」,不是「這則處理過了」,
當成已處理會讓那一則被靜靜跳過。

行內留言的位置分兩側:新檔那側在 position,被刪掉的那行在 original_position,Gitea 只填
其中一個。輸出把兩者收斂成「行」與「側」,回覆時才知道該送哪個欄位。也帶出 commit,
讓回覆落在原留言的那個 commit 上。

三種清單都逐頁讀完:這個站台的預設頁大小是 30,沒分頁的話第 31 個 review 之後整批消失。
2026-09-17 08:46:33 +00:00

228 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}