feat/pr-watch-and-cleanup/main #48
+6
-162
@@ -5,35 +5,23 @@
|
|||||||
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是
|
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是
|
||||||
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。
|
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。
|
||||||
*
|
*
|
||||||
* 「已處理」在三類上的機制不同:
|
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
|
||||||
* - 一般留言、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]
|
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
ScriptError,
|
|
||||||
expectOk,
|
expectOk,
|
||||||
giteaRequest,
|
giteaRequest,
|
||||||
main,
|
main,
|
||||||
pages,
|
|
||||||
parseFlags,
|
parseFlags,
|
||||||
parseIndex,
|
parseIndex,
|
||||||
parseRepo,
|
parseRepo,
|
||||||
preflight,
|
preflight,
|
||||||
resolveLogin,
|
resolveLogin,
|
||||||
} from './lib.js';
|
} from './lib.js';
|
||||||
|
import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js';
|
||||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
|
|
||||||
const DRAFT = 'PENDING';
|
|
||||||
|
|
||||||
main(async () => {
|
main(async () => {
|
||||||
const flags = parseFlags(process.argv.slice(2), {
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
@@ -67,11 +55,8 @@ main(async () => {
|
|||||||
await preflight(login, repo);
|
await preflight(login, repo);
|
||||||
|
|
||||||
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
||||||
const pull = await fetchPull(login, repo, index, pullPath);
|
const pull = await fetchPull(login, repo, index);
|
||||||
const 留言 = [
|
const 留言 = await readPullComments(login, repo, index, me);
|
||||||
...(await readGeneral(login, repo, index, me)),
|
|
||||||
...(await readReviews(login, repo, index, pullPath, me)),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repo,
|
repo,
|
||||||
@@ -80,148 +65,7 @@ main(async () => {
|
|||||||
url: pull.html_url,
|
url: pull.html_url,
|
||||||
state: pull.state,
|
state: pull.state,
|
||||||
留言,
|
留言,
|
||||||
未處理數: 留言.filter((comment) => !comment.已處理).length,
|
未處理數: unhandledCount(留言),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
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,190 @@
|
|||||||
|
/**
|
||||||
|
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
|
||||||
|
*
|
||||||
|
* 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理,
|
||||||
|
* `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次,
|
||||||
|
* 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過
|
||||||
|
* 才會被發現。
|
||||||
|
*
|
||||||
|
* 「已處理」在三類上的機制不同:
|
||||||
|
* - 一般留言、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 對留言按讚是「我同意」,不是
|
||||||
|
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
|
||||||
|
*/
|
||||||
|
import { ScriptError, expectOk, giteaRequest, 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 readGeneral(login, repo, index, me)),
|
||||||
|
...(await readReviews(login, repo, index, pullPath, me)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 還沒被處理的則數。`/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 的留言端點。
|
||||||
|
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user