「回在原本那一串底下」對三類是三件不同的事。行內留言沒有「回覆某一則」的端點, 把新留言指向同一個檔案與同一行,Gitea 才會把它排在原留言底下——位置是串的識別。位置不由 呼叫端給而是拿 id 查出來:手抄行號是這一段最容易錯的地方。回覆還帶上原留言的 commit_id, 否則 PR 之後又推了新 commit 時,同一個行號指的是別的程式碼。 標記排在回覆之後,回覆沒成功就不標記:沒回就標記等於謊稱處理過。 輸出三類同形:用不到的欄位填 null 而不是讓它消失,下游不必為了少一欄多寫一種分支。
275 lines
9.6 KiB
JavaScript
275 lines
9.6 KiB
JavaScript
#!/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;
|
||
}
|