Files
tea-sdlc/scripts/pr-threads.js
T
jiantw83 a944141109 fix(留言分頁): 改用 timeline 分頁讀取留言
Gitea 議題留言端點無法可靠處理分頁;改由 timeline 逐頁篩選 comment 事件並去重,讓抽取、整併與 PR 留言流程取得完整集合。
2026-09-22 14:37:38 +08:00

218 lines
8.6 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.
/**
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
*
* 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理,
* `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次,
* 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過
* 才會被發現。
*
* 「已處理」在三類上的機制不同:
* - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
* 抽取契約數未整併則數時用的是同一條規則)
* - 行內留言 → 有沒有被 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 對留言按讚是「我同意」,不是
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
*/
import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
const DRAFT = 'PENDING';
/**
* 讀齊三類留言。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index PR 編號
* @param {string} me 自己的帳號,用來認「這則是我標的」
* @returns {Promise<object[]>}
*/
export async function readPullComments(login, repo, index, me) {
const pullPath = `/repos/${repo}/pulls/${index}`;
return [
...(await readGeneralComments(login, repo, index, me)),
...(await readReviews(login, repo, index, pullPath, me)),
];
}
/**
* 讀這三類留言會發出哪些請求。兩支腳本的 `--dry-run` 都印它——預告與實際發出的請求
* 分開寫,加一個端點就會有一邊忘了改,而預告錯了等於沒有預告。
* @returns {{method: string, path: string}[]}
*/
export function plannedRequests(repo, index) {
const pullPath = `/repos/${repo}/pulls/${index}`;
return [
{ method: 'GET', path: '/user' },
{ method: 'GET', path: pullPath },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
{ method: 'GET', path: `${pullPath}/reviews` },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
];
}
/**
* 不確定是議題還是 PR 時,一定會發的那兩個請求。
*
* `pr-comments` 收得下兩種輸入,而它在試跑階段還沒讀過議題、不知道是哪一種。
* 與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的,
* 其餘交給 note 說明。`pr-watch` 的輸入一定是 PR,繼續用 plannedRequests。
*/
export function commonRequests(repo, index) {
return [
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
];
}
/** `commonRequests` 列不完的那部分:是 PR 的話還要再讀三處。 */
export const ISSUE_OR_PULL_NOTE =
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的行內留言' +
'與 timeline。次數取決於留言數,事前無法列舉。';
/**
* `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
*/
export const COMMENT_REQUEST_NOTE =
'每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' +
'次數取決於留言數,事前無法列舉。';
/** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */
export function unhandledCount(留言) {
return 留言.filter((comment) => !comment.已處理).length;
}
/**
* 讀一顆 PR。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,後者是權限沒開。
* @returns {Promise<object>}
*/
export async function fetchPull(login, repo, index) {
const path = `/repos/${repo}/pulls/${index}`;
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 的留言端點——
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
*/
export async function readGeneralComments(login, repo, index, me) {
const 留言 = [];
for await (const comment of listIssueComments(login, repo, index)) {
if ((comment.body ?? '').trim() === '') continue;
留言.push({
id: comment.id,
review: null,
類型: '一般',
作者: comment.user?.login ?? '',
內容: comment.body,
已處理: await mergedByMe(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 mergedByMe(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;
}