Merge pull request 'feat/comments-merge/main' (#52) from feat/comments-merge/main into master
Reviewed-on: #52 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #52.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把留言裡的決策整併回議題描述,並標記那幾則留言。
|
||||
*
|
||||
* 判斷「哪幾則留言有決策、該併進哪一段、併成什麼樣子」是讀得懂內容的人的事;
|
||||
* 這一支只負責把結果安全地寫回去。
|
||||
*
|
||||
* 兩件事錯了都很安靜,所以都做得很窄:
|
||||
*
|
||||
* - **局部更新。** 只換指定那一段,標題與其餘段落一字不動。整份重寫會把別人在其他
|
||||
* 段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
* - **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
|
||||
* 描述沒寫成功就不標記——標了就等於這則再也不會被看到。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/comments-merge.js --repo owner/name --index 7
|
||||
* --section <段落名> --content-file <檔案> --merged 101,102
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
fetchIssue,
|
||||
giteaRequest,
|
||||
listIssueComments,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
readTextFile,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { replaceSection } from './issue-body.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index', 'section', 'content-file', 'merged'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const section = flags.section;
|
||||
const content = readTextFile(flags['content-file'], '--content-file');
|
||||
const merged = parseMerged(flags.merged);
|
||||
const dryRun = flags['dry-run'] === true;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!dryRun) await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||
|
||||
const result = replaceSection(issue.body ?? '', section, content);
|
||||
if (result.status === 'not-found') {
|
||||
throw new ScriptError(
|
||||
'SECTION_NOT_FOUND',
|
||||
`議題 #${index} 上沒有「${section}」這個段落;請確認段落名與議題上的 \`## 標題\` 完全一致。` +
|
||||
'本工具不會把內容補到議題末尾——段落名打錯時那樣做比什麼都不做更難收拾',
|
||||
);
|
||||
}
|
||||
if (result.status === 'ambiguous') {
|
||||
throw new ScriptError(
|
||||
'SECTION_AMBIGUOUS',
|
||||
`議題 #${index} 上有 ${result.count} 個「${section}」段落,分不出要換哪一個;` +
|
||||
'請先到議題上把重複的標題改成看得出差別的名稱',
|
||||
);
|
||||
}
|
||||
const body = result.body;
|
||||
|
||||
// 標記之前先確認這幾則留言真的在這顆議題上:標錯地方的 reaction 很難發現
|
||||
await checkComments(login, repo, index, merged);
|
||||
|
||||
// 描述沒變就不送:空的 PATCH 會把議題的 updated_at 推新,看起來像有人動過
|
||||
const 描述已更新 = body !== issue.body;
|
||||
const requests = [
|
||||
...(描述已更新 ? [{ method: 'PATCH', path: issuePath, body: { body } }] : []),
|
||||
...merged.map((id) => ({
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/comments/${id}/reactions`,
|
||||
body: { content: '+1' },
|
||||
})),
|
||||
];
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, section, 描述已更新, 已標記: merged, requests };
|
||||
}
|
||||
|
||||
// 順序是先寫描述再標記:標記是「這則已經收進去了」的結論,
|
||||
// 反過來的話,描述寫失敗時那幾則已經被標成處理過,再也不會被提出來
|
||||
for (const { method, path, body: payload } of requests) {
|
||||
expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
index,
|
||||
url: issue.html_url,
|
||||
section,
|
||||
描述已更新,
|
||||
已標記: merged,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
/** 逗號分隔的留言 id。整併卻不標記的話,下次會重複處理同一則,所以這個 flag 是必填。 */
|
||||
function parseMerged(value) {
|
||||
const ids = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((item) => parseIndex(item, '--merged'));
|
||||
|
||||
if (ids.length === 0) {
|
||||
throw new ScriptError('MISSING_FLAG', '--merged 至少要有一則留言 id');
|
||||
}
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認這幾則留言都在這顆議題上。
|
||||
* 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。
|
||||
*/
|
||||
async function checkComments(login, repo, index, merged) {
|
||||
const seen = new Set();
|
||||
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
seen.add(comment.id);
|
||||
}
|
||||
|
||||
const missing = merged.filter((id) => !seen.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'COMMENT_NOT_FOUND',
|
||||
`議題 #${index} 上沒有這幾則留言:${missing.join('、')};` +
|
||||
'請確認 id 來自這顆議題(必要時重跑 issue-extract 或 wp-extract)',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,65 @@ export function tickLine(body, raw, section) {
|
||||
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 換掉一個段落的內容,標題與其餘段落一字不動。
|
||||
*
|
||||
* 整併留言裡的決策時用它。不整份重寫的理由跟 upsertLineInSection 一樣,只是代價更大:
|
||||
* 重寫會把別人在其他段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
*
|
||||
* 同名標題出現不只一次時交回 `ambiguous`,不賭第一個——理由與 tickLine 相同,
|
||||
* 而這裡蓋掉的是一整段而不是一行,猜錯的代價更高。
|
||||
*
|
||||
* 段落不存在時交回 `not-found` 讓呼叫端報錯,不補在結尾:「找不到那一段」多半是段落名
|
||||
* 打錯,這時把內容塞到議題末尾,比什麼都不做更難收拾。
|
||||
*
|
||||
* @param {string} body 議題 body
|
||||
* @param {string} section 段落名稱,例如 '目標'
|
||||
* @param {string} content 新的段落內容(不含 `## 標題` 那一行)
|
||||
* @returns {{status: 'replaced'|'not-found'|'ambiguous', body?: string, count: number}}
|
||||
*/
|
||||
export function replaceSection(body, section, content) {
|
||||
const rows = [...eachLine(body)];
|
||||
const headings = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (rows[i].inFence) continue;
|
||||
if (rows[i].line.match(/^##\s+(.+?)\s*$/)?.[1] === section) headings.push(i);
|
||||
}
|
||||
|
||||
if (headings.length === 0) return { status: 'not-found', count: 0 };
|
||||
if (headings.length > 1) return { status: 'ambiguous', count: headings.length };
|
||||
|
||||
const [start] = headings;
|
||||
const lines = rows.map((row) => row.line);
|
||||
// 下一個段落的標題;沒有就是到結尾
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (!rows[i].inFence && /^##\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 段落與段落之間的空行屬於版面,不屬於內容:換內容時把它留著。
|
||||
// 原本就沒有空行(兩個標題緊貼)時補一個,免得新內容黏在下一個標題上。
|
||||
let tail = end;
|
||||
while (tail > start + 1 && lines[tail - 1].trim() === '') tail -= 1;
|
||||
const spacer = end === lines.length || end > tail ? lines.slice(tail, end) : [''];
|
||||
|
||||
// 換行沿用 body 原本的那一種:CRLF 的 body 裡混進 LF,會讓抽取契約交出的 raw
|
||||
// 對不上原文,之後就勾不動那幾行了
|
||||
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
||||
const normalized = content.trim().split(/\r?\n/);
|
||||
|
||||
return {
|
||||
status: 'replaced',
|
||||
body: [...lines.slice(0, start + 1), '', ...normalized, ...spacer, ...lines.slice(end)]
|
||||
.map((line) => line.replace(/\r$/, ''))
|
||||
.join(eol),
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||||
*
|
||||
|
||||
@@ -47,7 +47,7 @@ main(async () => {
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -66,7 +66,7 @@ main(async () => {
|
||||
驗收標準: listSection(sections, '驗收標準'),
|
||||
影響範圍: listSection(sections, '影響範圍'),
|
||||
未決事項: listSection(sections, '未決事項'),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+61
-14
@@ -663,6 +663,28 @@ function checkTimeTracker(info) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀一個由 flag 指定的文字檔。
|
||||
*
|
||||
* 「讀一個 --xxx-file 或直接失敗」原本在四支腳本裡各寫一份,錯誤碼還有三種拼法。
|
||||
* 同一種情況要有同一個碼,呼叫端才分辨得出到底是哪一步壞了。
|
||||
*
|
||||
* @param {string} path 檔案路徑
|
||||
* @param {string} flag 出現在錯誤訊息裡的 flag 名,例如 '--body-file'
|
||||
* @param {{allowEmpty?: boolean}} options 內容可不可以是空的;預設不可以
|
||||
* @returns {string}
|
||||
*/
|
||||
export function readTextFile(path, flag, { allowEmpty = false } = {}) {
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('FILE_NOT_FOUND', `找不到 ${flag} 指定的檔案 ${path}`);
|
||||
}
|
||||
const content = readFileSync(path, 'utf8');
|
||||
if (!allowEmpty && content.trim() === '') {
|
||||
throw new ScriptError('FILE_EMPTY', `${flag} 指定的檔案 ${path} 是空的`);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -697,32 +719,57 @@ export const UNMERGED_COMMENT_NOTE =
|
||||
* 數出尚未被整併回描述的留言則數。
|
||||
*
|
||||
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
|
||||
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
|
||||
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長,
|
||||
* 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,
|
||||
* 讀不完寧可報錯。
|
||||
*
|
||||
* @param {{base: string, token: string}} login
|
||||
* @param {string} repo owner/name
|
||||
* @param {number} index
|
||||
* @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function countUnmergedComments(login, repo, index) {
|
||||
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
|
||||
export async function countUnmergedComments(login, repo, index, me) {
|
||||
let unmerged = 0;
|
||||
|
||||
for await (const comments of pages(login, commentsPath, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${commentsPath} 的留言太多,數不完未整併的則數`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
const path = `/repos/${repo}/issues/comments/${comment.id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
if (!reactions.some((reaction) => reaction.content === '+1')) unmerged += 1;
|
||||
}
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1;
|
||||
}
|
||||
return unmerged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||
*/
|
||||
export async function* listIssueComments(login, repo, index) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) yield comment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已整併。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* 別人按讚是「我同意」,當成已整併會讓那一則的決策永遠不被收進描述——
|
||||
* 而那正是 /sdlc-sync 要解決的事。
|
||||
*
|
||||
* @param {string} me 目前登入帳號;preflight 的回傳帶得出來
|
||||
*/
|
||||
export async function mergedByMe(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);
|
||||
}
|
||||
|
||||
// ── 碼錶 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+27
-18
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
|
||||
* 讀 PR 或議題上的留言。
|
||||
*
|
||||
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是
|
||||
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。
|
||||
* PR 有三類:一般留言、review 總評、行內留言,分散在三個端點——漏掉任何一類就會有
|
||||
* reviewer 的意見沒被處理,而那正是 `/sdlc-fix` 存在的理由。
|
||||
* 純議題只有一般留言,`/sdlc-sync` 要的就是那一份。
|
||||
*
|
||||
* **每個 PR 都是議題,但議題不一定是 PR。** 所以先讀 `/issues/{index}`(兩種都有),
|
||||
* 看它有沒有 `pull_request` 才決定要不要去翻 review;反過來先打 `/pulls/{index}`,
|
||||
* 對純議題會 404,整個流程在讀到第一則留言之前就斷了。
|
||||
*
|
||||
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
|
||||
* 規則寫兩份遲早會各自演化。
|
||||
@@ -12,8 +17,7 @@
|
||||
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
fetchIssue,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
@@ -22,9 +26,9 @@ import {
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import {
|
||||
COMMENT_REQUEST_NOTE,
|
||||
fetchPull,
|
||||
plannedRequests,
|
||||
ISSUE_OR_PULL_NOTE,
|
||||
commonRequests,
|
||||
readGeneralComments,
|
||||
readPullComments,
|
||||
unhandledCount,
|
||||
} from './pr-threads.js';
|
||||
@@ -43,24 +47,29 @@ main(async () => {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
requests: plannedRequests(repo, index),
|
||||
note: COMMENT_REQUEST_NOTE,
|
||||
requests: commonRequests(repo, index),
|
||||
note: ISSUE_OR_PULL_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
const me = user.login;
|
||||
|
||||
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
||||
const pull = await fetchPull(login, repo, index);
|
||||
const 留言 = await readPullComments(login, repo, index, me);
|
||||
// 先讀議題:PR 也是議題,反過來不成立。這一步同時決定要不要去翻 review
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const isPull = issue.pull_request != null;
|
||||
const 留言 = isPull
|
||||
? await readPullComments(login, repo, index, me)
|
||||
: await readGeneralComments(login, repo, index, me);
|
||||
|
||||
return {
|
||||
repo,
|
||||
index: pull.number,
|
||||
title: pull.title,
|
||||
url: pull.html_url,
|
||||
state: pull.state,
|
||||
index: issue.number,
|
||||
類型: isPull ? 'PR' : '議題',
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state,
|
||||
留言,
|
||||
未處理數: unhandledCount(留言),
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
* --body-file <描述檔> --index 13
|
||||
* [--issue-repo owner/name] [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
readTextFile,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
@@ -87,7 +87,7 @@ main(async () => {
|
||||
const head = flags.head;
|
||||
const base = flags.base;
|
||||
const index = parseIndex(flags.index);
|
||||
const body = readBody(flags['body-file']);
|
||||
const body = readTextFile(flags['body-file'], '--body-file');
|
||||
|
||||
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
|
||||
checkSections(body);
|
||||
@@ -160,12 +160,6 @@ async function findOpenPull(login, repo, head) {
|
||||
}
|
||||
|
||||
|
||||
function readBody(path) {
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`);
|
||||
}
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */
|
||||
function checkSections(body) {
|
||||
|
||||
+38
-34
@@ -7,7 +7,8 @@
|
||||
* 才會被發現。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:
|
||||
* - 一般留言、review 總評 → 自己打的 `+1` reaction
|
||||
* - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
|
||||
* 抽取契約數未整併則數時用的是同一條規則)
|
||||
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
|
||||
*
|
||||
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
|
||||
@@ -17,7 +18,7 @@
|
||||
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
|
||||
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
import { ScriptError, expectOk, giteaRequest, pages } from './lib.js';
|
||||
import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
|
||||
|
||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
|
||||
const DRAFT = 'PENDING';
|
||||
@@ -33,7 +34,7 @@ const DRAFT = 'PENDING';
|
||||
export async function readPullComments(login, repo, index, me) {
|
||||
const pullPath = `/repos/${repo}/pulls/${index}`;
|
||||
return [
|
||||
...(await readGeneral(login, repo, index, me)),
|
||||
...(await readGeneralComments(login, repo, index, me)),
|
||||
...(await readReviews(login, repo, index, pullPath, me)),
|
||||
];
|
||||
}
|
||||
@@ -54,6 +55,25 @@ export function plannedRequests(repo, index) {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 不確定是議題還是 PR 時,一定會發的那兩個請求。
|
||||
*
|
||||
* `pr-comments` 收得下兩種輸入,而它在試跑階段還沒讀過議題、不知道是哪一種。
|
||||
* 與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的,
|
||||
* 其餘交給 note 說明。`pr-watch` 的輸入一定是 PR,繼續用 plannedRequests。
|
||||
*/
|
||||
export function commonRequests(repo, index) {
|
||||
return [
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
||||
];
|
||||
}
|
||||
|
||||
/** `commonRequests` 列不完的那部分:是 PR 的話還要再讀三處。 */
|
||||
export const ISSUE_OR_PULL_NOTE =
|
||||
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的行內留言' +
|
||||
'與 timeline。次數取決於留言數,事前無法列舉。';
|
||||
|
||||
/**
|
||||
* `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
|
||||
*/
|
||||
@@ -83,28 +103,24 @@ export async function fetchPull(login, repo, index) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。
|
||||
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點——
|
||||
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
|
||||
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||
*/
|
||||
async function readGeneral(login, repo, index, me) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
export async function readGeneralComments(login, repo, index, me) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
review: null,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await mergedByMe(login, repo, comment.id, me),
|
||||
可標記: true,
|
||||
});
|
||||
}
|
||||
return 留言;
|
||||
}
|
||||
@@ -136,7 +152,7 @@ async function readReviews(login, repo, index, pullPath, me) {
|
||||
作者: review.user?.login ?? '',
|
||||
內容: review.body,
|
||||
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
|
||||
已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me),
|
||||
已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
|
||||
可標記: commentId !== undefined,
|
||||
});
|
||||
}
|
||||
@@ -199,15 +215,3 @@ async function reviewCommentIds(login, repo, index) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ main(async () => {
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -88,7 +88,7 @@ main(async () => {
|
||||
相依: { blocks, depends },
|
||||
assignee: issue.assignee?.login ?? null,
|
||||
碼錶中: await hasRunningStopwatch(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user