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

270 lines
9.4 KiB
JavaScript
Raw Permalink 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 留言,並標記它已經處理過。
*
* 「回在 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,
listIssueComments,
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) {
for await (const comment of listIssueComments(login, repo, index)) {
if (comment.id === commentId) return comment;
}
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;
}