From a9441411096052571a0f577b31808ba9ca876190 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:37:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(=E7=95=99=E8=A8=80=E5=88=86=E9=A0=81):?= =?UTF-8?q?=20=E6=94=B9=E7=94=A8=20timeline=20=E5=88=86=E9=A0=81=E8=AE=80?= =?UTF-8?q?=E5=8F=96=E7=95=99=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 議題留言端點無法可靠處理分頁;改由 timeline 逐頁篩選 comment 事件並去重,讓抽取、整併與 PR 留言流程取得完整集合。 --- scripts/issue-extract.js | 5 ++--- scripts/lib.js | 16 +++++++++++----- scripts/pr-reply.js | 11 +++-------- scripts/pr-threads.js | 4 ++-- scripts/wp-extract.js | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/scripts/issue-extract.js b/scripts/issue-extract.js index 89e5bc5..c9cb113 100644 --- a/scripts/issue-extract.js +++ b/scripts/issue-extract.js @@ -31,16 +31,15 @@ main(async () => { const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const issuePath = `/repos/${repo}/issues/${index}`; - const commentsPath = `${issuePath}/comments`; + const timelinePath = `${issuePath}/timeline`; if (flags['dry-run']) { return { dryRun: true, repo, - index, requests: [ { method: 'GET', path: issuePath }, - { method: 'GET', path: commentsPath }, + { method: 'GET', path: timelinePath }, ], note: UNMERGED_COMMENT_NOTE, }; diff --git a/scripts/lib.js b/scripts/lib.js index 3cf593b..df55762 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -974,16 +974,22 @@ export async function countUnmergedComments(login, repo, index, me) { /** * 逐頁走過一顆議題(或 PR)的一般留言。 * 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。 + * Gitea 的 comments 端點固定只回第一批,完整清單改從 timeline 取得。 * @returns {AsyncGenerator} 一則一則交出去 */ export async function* listIssueComments(login, repo, index) { - const path = `/repos/${repo}/issues/${index}/comments`; + const path = `/repos/${repo}/issues/${index}/timeline`; + const seen = new Set(); - for await (const comments of pages(login, path, { - limitCode: 'COMMENT_LIMIT', - limitHint: `${path} 的留言太多,讀不完整份清單`, + for await (const entries of pages(login, path, { + limitCode: 'TIMELINE_LIMIT', + limitHint: `${path} 的 timeline 太多,讀不完整份留言清單`, })) { - for (const comment of comments) yield comment; + for (const entry of entries) { + if (entry.type !== 'comment' || seen.has(entry.id)) continue; + seen.add(entry.id); + yield entry; + } } } diff --git a/scripts/pr-reply.js b/scripts/pr-reply.js index 941906d..41d7432 100644 --- a/scripts/pr-reply.js +++ b/scripts/pr-reply.js @@ -24,6 +24,7 @@ import { ScriptError, expectOk, giteaRequest, + listIssueComments, main, pages, parseFlags, @@ -205,14 +206,8 @@ async function findInline(login, repo, 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; + for await (const comment of listIssueComments(login, repo, index)) { + if (comment.id === commentId) return comment; } throw notFound(index, '一般留言', commentId); } diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js index 2120d80..ba7e98f 100644 --- a/scripts/pr-threads.js +++ b/scripts/pr-threads.js @@ -49,7 +49,7 @@ export function plannedRequests(repo, index) { return [ { method: 'GET', path: '/user' }, { method: 'GET', path: pullPath }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, { method: 'GET', path: `${pullPath}/reviews` }, { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, ]; @@ -65,7 +65,7 @@ export function plannedRequests(repo, index) { export function commonRequests(repo, index) { return [ { method: 'GET', path: `/repos/${repo}/issues/${index}` }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, ]; } diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js index ae6f25c..de0d320 100644 --- a/scripts/wp-extract.js +++ b/scripts/wp-extract.js @@ -54,7 +54,7 @@ main(async () => { { method: 'GET', path: issuePath }, { method: 'GET', path: `${issuePath}/dependencies` }, { method: 'GET', path: `${issuePath}/blocks` }, - { method: 'GET', path: `${issuePath}/comments` }, + { method: 'GET', path: `${issuePath}/timeline` }, ], note: UNMERGED_COMMENT_NOTE, }; -- 2.53.0 From 437896348a71be54f5fd1fa2f15336a1f098b360 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:37:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(=E7=95=99=E8=A8=80=E5=88=86=E9=A0=81):?= =?UTF-8?q?=20=E6=94=B9=E7=94=A8=20timeline=20=E5=88=86=E9=A0=81=E8=AE=80?= =?UTF-8?q?=E5=8F=96=E7=95=99=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 議題留言端點無法可靠處理分頁;改由 timeline 逐頁篩選 comment 事件並去重,讓抽取、整併與 PR 留言流程取得完整集合。 --- test/comments-merge.test.js | 4 ++-- test/issue-extract.test.js | 39 ++++++++++++++++++++++--------- test/pr-comments.test.js | 46 ++++++++++++++++++++----------------- test/pr-reply.test.js | 9 ++++---- test/pr-watch.test.js | 10 +++++--- 5 files changed, 66 insertions(+), 42 deletions(-) diff --git a/test/comments-merge.test.js b/test/comments-merge.test.js index 9a14cd9..59a3d98 100644 --- a/test/comments-merge.test.js +++ b/test/comments-merge.test.js @@ -55,9 +55,9 @@ function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) { status: 200, body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` }, }), - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, - body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })), + body: comments.map((id) => ({ id, type: 'comment', body: `留言 ${id}`, user: { login: 'someone' } })), }, }); for (const id of comments) { diff --git a/test/issue-extract.test.js b/test/issue-extract.test.js index 2aa0d37..2965012 100644 --- a/test/issue-extract.test.js +++ b/test/issue-extract.test.js @@ -75,9 +75,9 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) { labels: [{ id: 55, name: 'ready-for-agent' }], }, }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, - body: comments.map((c, i) => ({ id: 100 + i, body: c.body })), + body: comments.map((c, i) => ({ id: 100 + i, type: 'comment', body: c.body })), }, ...overrides, }); @@ -340,7 +340,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { json.data.requests.map((r) => `${r.method} ${r.path}`), [ `GET /repos/${REPO}/issues/${INDEX}`, - `GET /repos/${REPO}/issues/${INDEX}/comments`, + `GET /repos/${REPO}/issues/${INDEX}/timeline`, ], ); assert.match( @@ -422,17 +422,20 @@ test('巢狀清單一律攤平,不無聲吃掉內容', async (t) => { assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']); }); -test('留言逐頁讀完,不是只讀第一頁', async (t) => { - // 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手 - const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i })); - const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i })); +test('留言 timeline 逐頁讀完並去除重複事件', async (t) => { + // 第一頁滿 50 筆就得再翻一頁;第二頁包含重複事件,仍只算一次 + const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i, type: 'comment' })); + const page2 = [ + page1.at(-1), + ...Array.from({ length: 20 }, (_, i) => ({ id: 300 + i, type: 'comment' })), + ]; const reactions = {}; for (const { id } of [...page1, ...page2]) { reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] }; } const stub = await withStub(t, { - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({ + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({ status: 200, body: req.query.page === '1' ? page1 : page2, }), @@ -441,7 +444,21 @@ test('留言逐頁讀完,不是只讀第一頁', async (t) => { const { json } = await run([], stub); - const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`)); - assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']); - assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來'); + const timelinePages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/timeline`)); + assert.deepEqual(timelinePages.map((r) => r.query.page), ['1', '2']); + assert.equal(json.data.未處理留言數, 70, '重複事件不應重複計入'); +}); + +test('timeline 讀取失敗時回傳可區分的錯誤', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { + status: 502, + body: { message: 'upstream unavailable' }, + }, + }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'HTTP_ERROR'); + assert.match(json.error.message, /502/); }); diff --git a/test/pr-comments.test.js b/test/pr-comments.test.js index 8997312..908b2dc 100644 --- a/test/pr-comments.test.js +++ b/test/pr-comments.test.js @@ -33,13 +33,23 @@ function routes(overrides = {}, options = {}) { status: 200, body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) }, }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, - body: general.map((c, i) => ({ - id: 100 + i, - body: c.body, - user: { login: c.user ?? 'reviewer' }, - })), + body: [ + ...general.map((c, i) => ({ + id: 100 + i, + type: 'comment', + body: c.body, + user: { login: c.user ?? 'reviewer' }, + })), + ...reviews.map((r, i) => ({ + id: 400 + i, + type: 'review', + review_id: 200 + i, + body: r.body ?? '', + user: { login: r.user ?? 'reviewer' }, + })), + ], }, [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, @@ -50,17 +60,6 @@ function routes(overrides = {}, options = {}) { user: { login: r.user ?? 'reviewer' }, })), }, - // 總評在 issue comment 表裡也有一份,timeline 給得出它的 id 與 review_id - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { - status: 200, - body: reviews.map((r, i) => ({ - id: 400 + i, - type: 'review', - review_id: 200 + i, - body: r.body ?? '', - user: { login: r.user ?? 'reviewer' }, - })), - }, }); general.forEach((c, i) => { @@ -416,7 +415,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { json.data.requests.map((r) => `${r.method} ${r.path}`), [ `GET /repos/${REPO}/issues/${INDEX}`, - `GET /repos/${REPO}/issues/${INDEX}/comments`, + `GET /repos/${REPO}/issues/${INDEX}/timeline`, ], ); assert.match(json.data.note, /reaction|review/); @@ -426,15 +425,20 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { // ── 分頁 ─────────────────────────────────────────────────────────── test('留言逐頁讀完,不是只讀第一頁', async (t) => { - const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } })); - const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }]; + const page1 = Array.from({ length: 50 }, (_, i) => ({ + id: 500 + i, + type: 'comment', + body: `第 ${i} 則`, + user: { login: 'r' }, + })); + const page2 = [{ id: 999, type: 'comment', body: '最後一則', user: { login: 'r' } }]; const reactions = {}; for (const c of [...page1, ...page2]) { reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] }; } const stub = await withStub(t, { - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({ + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({ status: 200, body: req.query.page === '1' ? page1 : page2, }), diff --git a/test/pr-reply.test.js b/test/pr-reply.test.js index 98bf675..5a34330 100644 --- a/test/pr-reply.test.js +++ b/test/pr-reply.test.js @@ -43,13 +43,12 @@ function routes(overrides = {}) { }, [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, - body: [{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body }], + body: [ + { id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body }, + { id: GENERAL.id, type: 'comment', body: GENERAL.body, user: { login: 'r' } }, + ], }, [`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { - status: 200, - body: [{ id: GENERAL.id, body: GENERAL.body, user: { login: 'r' } }], - }, [`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } }, [`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} }, [`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } }, diff --git a/test/pr-watch.test.js b/test/pr-watch.test.js index dfa9af3..e4edb72 100644 --- a/test/pr-watch.test.js +++ b/test/pr-watch.test.js @@ -44,12 +44,16 @@ function routes({ state = 'open', merged = false, draft = false, general = [], h head: head ?? { ref: BRANCH, label: BRANCH }, }, }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, - body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })), + body: general.map((c, i) => ({ + id: 100 + i, + type: 'comment', + body: c.body, + user: { login: 'reviewer' }, + })), }, [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] }, }); general.forEach((c, i) => { base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { -- 2.53.0 From 7fa7f7e1dedf0b9f6a5465c89ddecc06bb3c8175 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:38:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(pr-threads):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=95=99=E8=A8=80=E7=AB=AF=E9=BB=9E=E9=A0=90=E8=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 留言共用 iterator 已改走 timeline,預覽只列一次該端點,避免把端點種類誤報成重複請求。 --- scripts/pr-threads.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js index ba7e98f..b42b029 100644 --- a/scripts/pr-threads.js +++ b/scripts/pr-threads.js @@ -51,7 +51,6 @@ export function plannedRequests(repo, index) { { 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` }, ]; } -- 2.53.0