Merge pull request 'feat/gitea-comment-pagination/main' (#78) from feat/gitea-comment-pagination/main into master
Reviewed-on: #78 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #78.
This commit is contained in:
@@ -31,16 +31,15 @@ main(async () => {
|
|||||||
const repo = parseRepo(flags.repo);
|
const repo = parseRepo(flags.repo);
|
||||||
const index = parseIndex(flags.index);
|
const index = parseIndex(flags.index);
|
||||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||||
const commentsPath = `${issuePath}/comments`;
|
const timelinePath = `${issuePath}/timeline`;
|
||||||
|
|
||||||
if (flags['dry-run']) {
|
if (flags['dry-run']) {
|
||||||
return {
|
return {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
repo,
|
repo,
|
||||||
index,
|
|
||||||
requests: [
|
requests: [
|
||||||
{ method: 'GET', path: issuePath },
|
{ method: 'GET', path: issuePath },
|
||||||
{ method: 'GET', path: commentsPath },
|
{ method: 'GET', path: timelinePath },
|
||||||
],
|
],
|
||||||
note: UNMERGED_COMMENT_NOTE,
|
note: UNMERGED_COMMENT_NOTE,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-5
@@ -974,16 +974,22 @@ export async function countUnmergedComments(login, repo, index, me) {
|
|||||||
/**
|
/**
|
||||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||||
|
* Gitea 的 comments 端點固定只回第一批,完整清單改從 timeline 取得。
|
||||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||||
*/
|
*/
|
||||||
export async function* listIssueComments(login, repo, index) {
|
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, {
|
for await (const entries of pages(login, path, {
|
||||||
limitCode: 'COMMENT_LIMIT',
|
limitCode: 'TIMELINE_LIMIT',
|
||||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-8
@@ -24,6 +24,7 @@ import {
|
|||||||
ScriptError,
|
ScriptError,
|
||||||
expectOk,
|
expectOk,
|
||||||
giteaRequest,
|
giteaRequest,
|
||||||
|
listIssueComments,
|
||||||
main,
|
main,
|
||||||
pages,
|
pages,
|
||||||
parseFlags,
|
parseFlags,
|
||||||
@@ -205,14 +206,8 @@ async function findInline(login, repo, index, commentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findGeneral(login, repo, index, commentId) {
|
async function findGeneral(login, repo, index, commentId) {
|
||||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
for await (const comment of listIssueComments(login, repo, index)) {
|
||||||
|
if (comment.id === commentId) return comment;
|
||||||
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);
|
throw notFound(index, '一般留言', commentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ export function plannedRequests(repo, index) {
|
|||||||
return [
|
return [
|
||||||
{ method: 'GET', path: '/user' },
|
{ method: 'GET', path: '/user' },
|
||||||
{ method: 'GET', path: pullPath },
|
{ method: 'GET', path: pullPath },
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
|
||||||
{ method: 'GET', path: `${pullPath}/reviews` },
|
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||||
|
{ method: 'GET', path: `${pullPath}/reviews` },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ export function plannedRequests(repo, index) {
|
|||||||
export function commonRequests(repo, index) {
|
export function commonRequests(repo, index) {
|
||||||
return [
|
return [
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ main(async () => {
|
|||||||
{ method: 'GET', path: issuePath },
|
{ method: 'GET', path: issuePath },
|
||||||
{ method: 'GET', path: `${issuePath}/dependencies` },
|
{ method: 'GET', path: `${issuePath}/dependencies` },
|
||||||
{ method: 'GET', path: `${issuePath}/blocks` },
|
{ method: 'GET', path: `${issuePath}/blocks` },
|
||||||
{ method: 'GET', path: `${issuePath}/comments` },
|
{ method: 'GET', path: `${issuePath}/timeline` },
|
||||||
],
|
],
|
||||||
note: UNMERGED_COMMENT_NOTE,
|
note: UNMERGED_COMMENT_NOTE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
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,
|
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) {
|
for (const id of comments) {
|
||||||
|
|||||||
+28
-11
@@ -75,9 +75,9 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
|||||||
labels: [{ id: 55, name: 'ready-for-agent' }],
|
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,
|
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,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -340,7 +340,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
[
|
[
|
||||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -422,17 +422,20 @@ test('巢狀清單一律攤平,不無聲吃掉內容', async (t) => {
|
|||||||
assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']);
|
assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
test('留言 timeline 逐頁讀完並去除重複事件', async (t) => {
|
||||||
// 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手
|
// 第一頁滿 50 筆就得再翻一頁;第二頁包含重複事件,仍只算一次
|
||||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i }));
|
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i, type: 'comment' }));
|
||||||
const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i }));
|
const page2 = [
|
||||||
|
page1.at(-1),
|
||||||
|
...Array.from({ length: 20 }, (_, i) => ({ id: 300 + i, type: 'comment' })),
|
||||||
|
];
|
||||||
const reactions = {};
|
const reactions = {};
|
||||||
for (const { id } of [...page1, ...page2]) {
|
for (const { id } of [...page1, ...page2]) {
|
||||||
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
|
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stub = await withStub(t, {
|
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,
|
status: 200,
|
||||||
body: req.query.page === '1' ? page1 : page2,
|
body: req.query.page === '1' ? page1 : page2,
|
||||||
}),
|
}),
|
||||||
@@ -441,7 +444,21 @@ test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
|||||||
|
|
||||||
const { json } = await run([], stub);
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
const timelinePages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/timeline`));
|
||||||
assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']);
|
assert.deepEqual(timelinePages.map((r) => r.query.page), ['1', '2']);
|
||||||
assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來');
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-21
@@ -33,13 +33,23 @@ function routes(overrides = {}, options = {}) {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
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,
|
status: 200,
|
||||||
body: general.map((c, i) => ({
|
body: [
|
||||||
id: 100 + i,
|
...general.map((c, i) => ({
|
||||||
body: c.body,
|
id: 100 + i,
|
||||||
user: { login: c.user ?? 'reviewer' },
|
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`]: {
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -50,17 +60,6 @@ function routes(overrides = {}, options = {}) {
|
|||||||
user: { login: r.user ?? 'reviewer' },
|
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) => {
|
general.forEach((c, i) => {
|
||||||
@@ -416,7 +415,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
[
|
[
|
||||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.match(json.data.note, /reaction|review/);
|
assert.match(json.data.note, /reaction|review/);
|
||||||
@@ -426,15 +425,20 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
// ── 分頁 ───────────────────────────────────────────────────────────
|
// ── 分頁 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } }));
|
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||||
const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }];
|
id: 500 + i,
|
||||||
|
type: 'comment',
|
||||||
|
body: `第 ${i} 則`,
|
||||||
|
user: { login: 'r' },
|
||||||
|
}));
|
||||||
|
const page2 = [{ id: 999, type: 'comment', body: '最後一則', user: { login: 'r' } }];
|
||||||
const reactions = {};
|
const reactions = {};
|
||||||
for (const c of [...page1, ...page2]) {
|
for (const c of [...page1, ...page2]) {
|
||||||
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] };
|
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stub = await withStub(t, {
|
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,
|
status: 200,
|
||||||
body: req.query.page === '1' ? page1 : page2,
|
body: req.query.page === '1' ? page1 : page2,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -43,13 +43,12 @@ function routes(overrides = {}) {
|
|||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
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: {} },
|
[`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/${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}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} },
|
||||||
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
||||||
|
|||||||
@@ -44,12 +44,16 @@ function routes({ state = 'open', merged = false, draft = false, general = [], h
|
|||||||
head: head ?? { ref: BRANCH, label: BRANCH },
|
head: head ?? { ref: BRANCH, label: BRANCH },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
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}/pulls/${INDEX}/reviews`]: { status: 200, body: [] },
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
|
||||||
});
|
});
|
||||||
general.forEach((c, i) => {
|
general.forEach((c, i) => {
|
||||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user