工作包的抽取契約(#9)要做的事與需求議題那一支有三件完全重疊:讀議題、把「不存在」 與「沒有讀取權」分成兩種錯誤碼、以 +1 reaction 數出未整併的留言則數。這三件事的規則 只該有一份,複製一份到新腳本等於日後改規則要記得改兩個地方。 fetchIssue、countUnmergedComments 與試跑時那句附註一起搬到 lib,issue-extract 改為 呼叫它們,行為不變。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
573 lines
21 KiB
JavaScript
573 lines
21 KiB
JavaScript
/**
|
||
* tea-sdlc 所有腳本的共用地基。
|
||
*
|
||
* 這一層負責六件事,其餘腳本只寫自己的業務:
|
||
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
|
||
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
|
||
* 3. git 執行 —— 全專案唯一的子行程出口
|
||
* 4. 四層前置檢查
|
||
* 5. 冪等查重
|
||
* 6. 兩支抽取腳本共用的議題讀取
|
||
*
|
||
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
|
||
*/
|
||
import { execFileSync } from 'node:child_process';
|
||
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
|
||
import { homedir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/** 帶錯誤碼的失敗。呼叫端靠 code 分辨是哪一步壞了,訊息則要能指出去哪裡改。 */
|
||
export class ScriptError extends Error {
|
||
/**
|
||
* @param {string} code 可區分的錯誤碼,例如 TIME_TRACKER_OFF
|
||
* @param {string} message 給人看的訊息,必要時附上「該改哪裡」
|
||
*/
|
||
constructor(code, message) {
|
||
super(message);
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
// ── 路徑定位 ───────────────────────────────────────────────────────
|
||
|
||
/** 本檔位置 → plugin 根。不依賴 cwd,也不依賴環境變數。 */
|
||
export function pluginRoot() {
|
||
return dirname(dirname(fileURLToPath(import.meta.url)));
|
||
}
|
||
|
||
/** 輸出模板所在目錄 */
|
||
export function templatesDir() {
|
||
return join(pluginRoot(), 'templates');
|
||
}
|
||
|
||
/** 規則正本所在目錄 */
|
||
export function referencesDir() {
|
||
return join(pluginRoot(), 'references');
|
||
}
|
||
|
||
// ── 輸入:具名 flag ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 解析具名 flag。位置參數與不認得的 flag 一律拒絕,不默默忽略。
|
||
* @param {string[]} argv 通常是 process.argv.slice(2)
|
||
* @param {{required?: string[], optional?: string[], booleans?: string[]}} spec
|
||
* @returns {Record<string, string|boolean>}
|
||
*/
|
||
export function parseFlags(argv, spec = {}) {
|
||
const { required = [], optional = [], booleans = [] } = spec;
|
||
const known = new Set([...required, ...optional, ...booleans]);
|
||
const flags = {};
|
||
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const arg = argv[i];
|
||
if (!arg.startsWith('--')) {
|
||
throw new ScriptError('UNKNOWN_FLAG', `不認得的參數 ${arg};輸入只接受具名 flag`);
|
||
}
|
||
const eq = arg.indexOf('=');
|
||
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||
if (!known.has(name)) {
|
||
throw new ScriptError('UNKNOWN_FLAG', `不認得的 flag --${name}`);
|
||
}
|
||
if (booleans.includes(name)) {
|
||
flags[name] = true;
|
||
continue;
|
||
}
|
||
if (eq === -1) i += 1;
|
||
const value = eq === -1 ? argv[i] : arg.slice(eq + 1);
|
||
if (value === undefined || value.startsWith('--')) {
|
||
throw new ScriptError('MISSING_FLAG', `--${name} 需要一個值`);
|
||
}
|
||
flags[name] = value;
|
||
}
|
||
|
||
for (const name of required) {
|
||
if (flags[name] === undefined) {
|
||
throw new ScriptError('MISSING_FLAG', `缺少必填 flag --${name}`);
|
||
}
|
||
}
|
||
return flags;
|
||
}
|
||
|
||
/**
|
||
* 驗證議題編號。四支腳本都要做這件事,錯誤碼也該一致。
|
||
* @param {string|number} value
|
||
* @param {string} what 出現在錯誤訊息裡的欄位名,例如 '--index'
|
||
* @returns {number}
|
||
*/
|
||
export function parseIndex(value, what = '--index') {
|
||
if (!/^[1-9]\d*$/.test(String(value))) {
|
||
throw new ScriptError('BAD_INDEX', `${what} 需為正整數,收到的是 ${value}`);
|
||
}
|
||
return Number(value);
|
||
}
|
||
|
||
/**
|
||
* 驗證並正規化 owner/name 形式的 repo。
|
||
* @param {string} value
|
||
* @returns {string}
|
||
*/
|
||
export function parseRepo(value) {
|
||
const parts = value.split('/');
|
||
if (parts.length !== 2 || parts.some((p) => p.trim() === '')) {
|
||
throw new ScriptError('BAD_REPO', `--repo 需為 owner/name 格式,收到的是 ${value}`);
|
||
}
|
||
return parts.map((p) => p.trim()).join('/');
|
||
}
|
||
|
||
// ── 輸出:單行 JSON ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* 每支腳本的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
|
||
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
||
* @param {() => Promise<object>|object} run 回傳要放進 data 的物件
|
||
*/
|
||
export async function main(run) {
|
||
try {
|
||
const data = await run();
|
||
write({ ok: true, data }, 0);
|
||
} catch (error) {
|
||
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
||
const message = error?.message ?? String(error);
|
||
write({ ok: false, error: { code, message } }, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 印出結果後才結束行程。
|
||
* stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出,
|
||
* 所以要等 write 的 callback 回來再退出。
|
||
*/
|
||
function write(payload, exitCode) {
|
||
process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(exitCode));
|
||
}
|
||
|
||
// ── 認證來源 ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 決定要用哪個 Gitea 站台與哪一組 token。
|
||
* 預設沿用使用者既有的 `tea login`,不另外要求設定;環境變數只是測試與 CI 的覆寫出口。
|
||
* @param {{host?: string}} options host 可為完整網址或主機名
|
||
* @returns {{base: string, token: string}} base 已含 /api/v1
|
||
*/
|
||
export function resolveLogin({ host } = {}) {
|
||
const envBase = process.env.TEA_SDLC_API_BASE?.trim();
|
||
const envToken = process.env.TEA_SDLC_TOKEN?.trim();
|
||
if (envBase && envToken) {
|
||
return { base: stripSlash(envBase), token: envToken };
|
||
}
|
||
|
||
const configPath = process.env.TEA_SDLC_CONFIG?.trim() || defaultTeaConfig();
|
||
if (!existsSync(configPath)) {
|
||
throw new ScriptError(
|
||
'LOGIN_INVALID',
|
||
`找不到 tea 設定檔 ${configPath};請先執行 tea login add 登入 Gitea`,
|
||
);
|
||
}
|
||
|
||
const logins = parseTeaLogins(readFileSync(configPath, 'utf8'));
|
||
if (logins.length === 0) {
|
||
throw new ScriptError(
|
||
'LOGIN_INVALID',
|
||
`tea 設定檔 ${configPath} 裡沒有任何登入;請先執行 tea login add 登入 Gitea`,
|
||
);
|
||
}
|
||
|
||
const chosen = host
|
||
? logins.find((login) => sameHost(login.url, host))
|
||
: logins.find((login) => login.default) ?? logins[0];
|
||
if (!chosen) {
|
||
throw new ScriptError(
|
||
'LOGIN_INVALID',
|
||
`tea 設定檔裡找不到 ${host} 的登入;請先執行 tea login add 登入該站台`,
|
||
);
|
||
}
|
||
|
||
return { base: `${stripSlash(chosen.url)}/api/v1`, token: chosen.token };
|
||
}
|
||
|
||
function defaultTeaConfig() {
|
||
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
||
return join(base, 'tea', 'config.yml');
|
||
}
|
||
|
||
/**
|
||
* 從 tea 的 config.yml 取出登入清單。
|
||
* 只認得 tea 實際寫出的那一種扁平結構,不是通用 YAML 剖析器 —— 本專案零外部套件,
|
||
* 而需要的資訊只有 url 與 token 兩個欄位。
|
||
* @param {string} text config.yml 的內容
|
||
* @returns {{name: string, url: string, token: string, default: boolean}[]}
|
||
*/
|
||
function parseTeaLogins(text) {
|
||
const logins = [];
|
||
let inLogins = false;
|
||
let current = null;
|
||
|
||
for (const line of text.split('\n')) {
|
||
if (/^logins:/.test(line)) {
|
||
inLogins = true;
|
||
current = null;
|
||
continue;
|
||
}
|
||
if (/^\S/.test(line)) {
|
||
inLogins = false;
|
||
current = null;
|
||
continue;
|
||
}
|
||
if (!inLogins) continue;
|
||
|
||
const item = line.match(/^\s*-\s*name:\s*(.*)$/);
|
||
if (item) {
|
||
current = { name: unquote(item[1]) };
|
||
logins.push(current);
|
||
continue;
|
||
}
|
||
const pair = line.match(/^\s+([A-Za-z_]+):\s*(.*)$/);
|
||
if (pair && current) current[pair[1]] = unquote(pair[2]);
|
||
}
|
||
|
||
return logins
|
||
.filter((login) => login.url && login.token)
|
||
.map((login) => ({ ...login, default: login.default === 'true' }));
|
||
}
|
||
|
||
function unquote(value) {
|
||
const trimmed = value.trim();
|
||
return trimmed.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
|
||
}
|
||
|
||
function sameHost(url, host) {
|
||
return hostOf(url) === hostOf(host);
|
||
}
|
||
|
||
function hostOf(value) {
|
||
const withScheme = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
||
try {
|
||
return new URL(withScheme).host;
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function stripSlash(value) {
|
||
return value.replace(/\/+$/, '');
|
||
}
|
||
|
||
// ── Gitea:全專案唯一的 HTTP 出口 ─────────────────────────────────
|
||
|
||
/**
|
||
* 對 Gitea 發一次請求。所有腳本的 Gitea 呼叫都必須經過這裡,
|
||
* 測試才能用 TEA_SDLC_API_BASE 把整個專案指向本機 stub server。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} method
|
||
* @param {string} path /api/v1 之後的路徑,例如 /repos/o/r/labels
|
||
* @param {{body?: object, query?: Record<string, string|number>}} options
|
||
* @returns {Promise<{status: number, body: any}>} 不因非 2xx 拋錯,狀態碼交給呼叫端判斷
|
||
*/
|
||
export async function giteaRequest(login, method, path, { body, query } = {}) {
|
||
const url = new URL(`${login.base}${path}`);
|
||
for (const [key, value] of Object.entries(query ?? {})) {
|
||
url.searchParams.set(key, String(value));
|
||
}
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch(url, {
|
||
method,
|
||
headers: {
|
||
Authorization: `token ${login.token}`,
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
},
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
} catch (cause) {
|
||
throw new ScriptError('NETWORK_ERROR', `連不上 Gitea(${method} ${path}):${cause.message}`);
|
||
}
|
||
|
||
const text = await response.text();
|
||
return { status: response.status, body: text ? safeJson(text) : null };
|
||
}
|
||
|
||
/**
|
||
* 把「非預期狀態碼」收斂成帶狀態碼的錯誤,成功則回傳 body。
|
||
* @param {{status: number, body: any}} response
|
||
* @param {string} what 出現在錯誤訊息裡的請求描述
|
||
*/
|
||
export function expectOk(response, what) {
|
||
if (response.status < 200 || response.status >= 300) {
|
||
const detail = response.body?.message ? `:${response.body.message}` : '';
|
||
throw new ScriptError('HTTP_ERROR', `Gitea 回應 ${response.status}(${what})${detail}`);
|
||
}
|
||
return response.body;
|
||
}
|
||
|
||
function safeJson(text) {
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
// ── git:全專案唯一的子行程出口 ───────────────────────────────────
|
||
|
||
/**
|
||
* 執行一次 git,回傳修掉前後空白的 stdout。
|
||
* @param {string[]} args
|
||
* @param {{cwd?: string}} options
|
||
* @returns {string}
|
||
*/
|
||
export function runGit(args, { cwd } = {}) {
|
||
try {
|
||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||
} catch (error) {
|
||
const detail = (error.stderr || error.message || '').toString().trim();
|
||
throw new ScriptError('GIT_FAILED', `git ${args.join(' ')} 失敗:${detail}`);
|
||
}
|
||
}
|
||
|
||
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 依序檢查四層,任一層不通過即拋出帶碼的錯誤並指出該改哪裡。
|
||
* 前一層沒過就不往下打,避免把一個設定問題報成四個。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @returns {Promise<object>} 通過時回傳 repo 資訊,省下呼叫端再查一次
|
||
*/
|
||
export async function preflight(login, repo) {
|
||
checkEnvironment();
|
||
await checkLogin(login);
|
||
const info = await fetchRepo(login, repo);
|
||
await checkIssueWrite(login, repo, info);
|
||
checkTimeTracker(info);
|
||
return info;
|
||
}
|
||
|
||
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
||
function checkEnvironment() {
|
||
const missing = ['git', 'tea'].filter((binary) => which(binary) === null);
|
||
if (missing.length > 0) {
|
||
throw new ScriptError(
|
||
'ENV_MISSING',
|
||
`PATH 上找不到 ${missing.join('、')};請先安裝(tea 見 https://gitea.com/gitea/tea)後再執行`,
|
||
);
|
||
}
|
||
for (const dir of [templatesDir(), referencesDir()]) {
|
||
if (!existsSync(dir)) {
|
||
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `plugin 目錄不完整,找不到 ${dir};請重新安裝 tea-sdlc`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function which(binary) {
|
||
for (const dir of (process.env.PATH ?? '').split(':')) {
|
||
if (dir === '') continue;
|
||
try {
|
||
accessSync(join(dir, binary), constants.X_OK);
|
||
return join(dir, binary);
|
||
} catch {
|
||
// 這個目錄沒有,換下一個
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 第二層:Gitea 登入是否有效 */
|
||
async function checkLogin(login) {
|
||
const response = await giteaRequest(login, 'GET', '/user');
|
||
if (response.status === 401 || response.status === 403) {
|
||
throw new ScriptError(
|
||
'LOGIN_INVALID',
|
||
`Gitea 不接受目前的 token(HTTP ${response.status});請重新執行 tea login add 更新登入`,
|
||
);
|
||
}
|
||
expectOk(response, 'GET /user');
|
||
}
|
||
|
||
async function fetchRepo(login, repo) {
|
||
const response = await giteaRequest(login, 'GET', `/repos/${repo}`);
|
||
if (response.status === 404) {
|
||
throw new ScriptError('REPO_NOT_FOUND', `找不到 repo ${repo},或目前的帳號沒有讀取權`);
|
||
}
|
||
return expectOk(response, `GET /repos/${repo}`);
|
||
}
|
||
|
||
/**
|
||
* 第三層:帳號對 issues unit 是否有寫入權。
|
||
* 必須實測:Gitea 的 team unit 權限可以獨立於 repo 的 push 權限,
|
||
* permissions.push 為真不代表 issues 寫得進去。
|
||
* 探針打在不存在的議題 index 0 —— 有寫入權會通過權限中介層後回 404,沒有則直接 403,
|
||
* 兩種結果都不會改動任何東西。
|
||
*/
|
||
async function checkIssueWrite(login, repo, info) {
|
||
if (info.has_issues === false) {
|
||
throw new ScriptError(
|
||
'ISSUES_UNIT_OFF',
|
||
`repo ${repo} 沒有啟用議題功能;請到 Settings → Advanced Settings 勾選 Issues`,
|
||
);
|
||
}
|
||
|
||
const probe = await giteaRequest(login, 'PATCH', `/repos/${repo}/issues/0`, { body: {} });
|
||
if (probe.status === 403) {
|
||
throw new ScriptError(
|
||
'NO_ISSUE_WRITE',
|
||
`目前的帳號對 ${repo} 的 issues unit 沒有寫入權;請到該 repo 的 Settings → Collaborators,` +
|
||
'或組織的 Teams → Units 把 Issues 設為 Write',
|
||
);
|
||
}
|
||
if (probe.status !== 404) {
|
||
expectOk(probe, `PATCH /repos/${repo}/issues/0`);
|
||
}
|
||
}
|
||
|
||
/** 第四層:repo 是否已開啟時間追蹤 */
|
||
function checkTimeTracker(info) {
|
||
if (info.internal_tracker?.enable_time_tracker !== true) {
|
||
throw new ScriptError(
|
||
'TIME_TRACKER_OFF',
|
||
'repo 尚未開啟時間追蹤,工時碼錶無法運作;請到 Settings → Advanced Settings → Enable Time Tracker 開啟',
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
|
||
|
||
/**
|
||
* 讀一顆議題。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,
|
||
* 後者是權限沒開,兩種的下一步完全不同。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @param {number} index
|
||
* @returns {Promise<object>}
|
||
*/
|
||
export async function fetchIssue(login, repo, index) {
|
||
const path = `/repos/${repo}/issues/${index}`;
|
||
const response = await giteaRequest(login, 'GET', path);
|
||
|
||
if (response.status === 404) {
|
||
throw new ScriptError('ISSUE_NOT_FOUND', `${repo} 沒有編號 ${index} 的議題`);
|
||
}
|
||
if (response.status === 403) {
|
||
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 議題 ${index} 的讀取權`);
|
||
}
|
||
return expectOk(response, `GET ${path}`);
|
||
}
|
||
|
||
/**
|
||
* 抽取腳本 `--dry-run` 的共同附註:留言的 reaction 要逐則查,事前列不出來。
|
||
* 與 countUnmergedComments 同進退——說明的是它發出的那些請求。
|
||
*/
|
||
export const UNMERGED_COMMENT_NOTE =
|
||
'每則留言還會各查一次 reaction,用來數出未整併的則數;則數取決於留言數,事前無法列舉。';
|
||
|
||
/**
|
||
* 數出尚未被整併回描述的留言則數。
|
||
*
|
||
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
|
||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
|
||
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
|
||
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
|
||
*
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @param {number} index
|
||
* @returns {Promise<number>}
|
||
*/
|
||
export async function countUnmergedComments(login, repo, index) {
|
||
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
|
||
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;
|
||
}
|
||
}
|
||
return unmerged;
|
||
}
|
||
|
||
// ── 標籤 ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 取得 repo 上的既有標籤。
|
||
* 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單,
|
||
* 挑不到合適的就不貼。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @returns {Promise<object[]>}
|
||
*/
|
||
export async function listLabels(login, repo) {
|
||
const path = `/repos/${repo}/labels`;
|
||
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||
}
|
||
|
||
// ── 冪等查重 ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。
|
||
* 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @param {string} title 要找的標題
|
||
* @returns {Promise<object|null>} 找到的議題,或 null
|
||
*/
|
||
/**
|
||
* 逐頁走訪一個回傳陣列的 Gitea 端點。
|
||
*
|
||
* 所有「必須看完整份清單」的走訪都走這裡:讀不完就要大聲報錯,不能無聲回傳半份。
|
||
* 半份清單比報錯更危險——查重會漏掉既有議題而重建一顆,數留言會少算而讓下游
|
||
* 拿著過期描述做事。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} path
|
||
* @param {{query?: object, pageSize?: number, maxPages?: number, limitCode?: string, limitHint?: string}} options
|
||
* @returns {AsyncGenerator<object[]>} 每次 yield 一頁
|
||
*/
|
||
export async function* pages(login, path, options = {}) {
|
||
const {
|
||
query = {},
|
||
pageSize = 50,
|
||
maxPages = 200,
|
||
limitCode = 'PAGE_LIMIT',
|
||
limitHint = `${path} 的資料量超出可走訪範圍`,
|
||
} = options;
|
||
|
||
for (let page = 1; page <= maxPages; page += 1) {
|
||
const response = await giteaRequest(login, 'GET', path, {
|
||
query: { ...query, limit: pageSize, page },
|
||
});
|
||
const items = expectOk(response, `GET ${path}`) ?? [];
|
||
yield items;
|
||
if (items.length < pageSize) return;
|
||
}
|
||
|
||
throw new ScriptError(limitCode, `${limitHint}(已讀 ${maxPages * pageSize} 筆仍未讀完)`);
|
||
}
|
||
|
||
/**
|
||
* 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。
|
||
* 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。
|
||
* @param {{base: string, token: string}} login
|
||
* @param {string} repo owner/name
|
||
* @param {string} title 要找的標題
|
||
* @returns {Promise<object|null>} 找到的議題,或 null
|
||
*/
|
||
export async function findIssueByTitle(login, repo, title) {
|
||
const wanted = title.trim();
|
||
|
||
for await (const issues of pages(login, `/repos/${repo}/issues`, {
|
||
query: { state: 'all' },
|
||
limitCode: 'DEDUPE_LIMIT',
|
||
limitHint: `翻不完 ${repo} 的議題,無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`,
|
||
})) {
|
||
const hit = issues.find((issue) => (issue.title ?? '').trim() === wanted);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|