Files
tea-sdlc/scripts/lib.js
T
jiantw83 95e6026950 refactor(lib): 開啟目標專案 git repo 的那幾行收進 lib
「路徑不是 repo 就報 NOT_A_GIT_REPO」加上「把 cwd 綁進 runGit」原本在 branch-prep 與
commit-split 各寫一份。錯誤碼要一致,而這件事寫第三遍就該收起來了。
2026-09-17 08:23:26 +00:00

681 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
}
/** 流程正本所在目錄 */
export function promptsDir() {
return join(pluginRoot(), 'prompts');
}
// ── 套件 manifest ─────────────────────────────────────────────────
/**
* 套件 manifest。版本字串只有這一個來源——轉接檔嵌的版本、status 回報的版本與
* prompt 比對的版本都從這裡來,另外寫死一份就會有兩個真相。
* @returns {object}
*/
export function packageManifest() {
const path = join(pluginRoot(), 'package.json');
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch (cause) {
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `讀不到 ${path}:${cause.message};請重新安裝 tea-sdlc`);
}
}
/** 目前安裝的 tea-sdlc 版本 */
export function packageVersion() {
return packageManifest().version;
}
// ── 輸入:具名 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,或原樣內容 ───────────────────────────────────
/**
* 包住要原樣印出的內容。`main` 看到它就不包 JSON envelope,直接把 text 逐字印出去。
*
* 只有一種輸出用得上它:要餵給模型讀的流程正本。把幾百行 markdown 包進單行 JSON
* 再逼模型反跳脫,只會增加它讀錯的機率;JSON envelope 的價值是可程式化判斷成敗,
* 而那條路徑的成功就是內容本身。失敗仍走 envelope —— 成功是內容,失敗才需要結構。
*/
export class RawText {
/** @param {string} text 要逐字印出的內容,不補也不修任何字元 */
constructor(text) {
this.text = String(text);
}
}
/**
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
*/
export async function main(run) {
try {
const data = await run();
if (data instanceof RawText) {
write(data.text, 0);
return;
}
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
} catch (error) {
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
const message = error?.message ?? String(error);
write(`${JSON.stringify({ ok: false, error: { code, message } })}\n`, 1);
}
}
/**
* 印出結果後才結束行程。
* stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出,
* 所以要等 write 的 callback 回來再退出。
*/
function write(text, exitCode) {
process.stdout.write(text, () => 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 {
// stderr 要收進來而不是繼承:git 把 checkout/fetch 的進度訊息全寫在 stderr,
// 不收的話它們會混進腳本的輸出,而腳本的契約是「stdout 一行 JSON、stderr 乾淨」。
// 失敗時這些內容仍讀得到(error.stderr),錯誤訊息不會因此變模糊。
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
const detail = (error.stderr || error.message || '').toString().trim();
throw new ScriptError('GIT_FAILED', `git ${args.join(' ')} 失敗:${detail}`);
}
}
/**
* 開一個目標專案的 git repo,回傳綁在它身上的執行器。
*
* 碰目標專案 git 的腳本都從這裡進去:路徑不是 repo 時的錯誤碼要一致,
* 而「把 cwd 綁進 runGit」這件事寫第三遍就該收起來了。
*
* @param {string} path 目標專案的根目錄
* @returns {(...args: string[]) => string} 綁定 cwd 的 git 執行器
*/
export function openGitRepo(path) {
if (!existsSync(join(path, '.git'))) {
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
}
return (...args) => runGit(args, { cwd: path });
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
* 依序檢查四層,任一層不通過即拋出帶碼的錯誤並指出該改哪裡。
* 前一層沒過就不往下打,避免把一個設定問題報成四個。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @returns {Promise<{repo: object, user: object}>} 通過時把查到的 repo 與帳號一起交回去,
* 省下呼叫端再查一次——第二層本來就已經問過「我是誰」了
*/
export async function preflight(login, repo) {
checkEnvironment();
const user = await checkLogin(login);
const info = await fetchRepo(login, repo);
await checkIssueWrite(login, repo, info);
checkTimeTracker(info);
return { repo: info, user };
}
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
function checkEnvironment() {
checkBinaries(['git', 'tea']);
checkPluginLayout();
}
/** 每個執行檔的安裝指引。訊息只提真的缺的那幾個,不要叫人去裝他已經有的東西。 */
const INSTALL_HINT = {
git: 'git 見 https://git-scm.com',
tea: 'tea 見 https://gitea.com/gitea/tea',
node: 'Node 見 https://nodejs.org',
};
/**
* 這些執行檔缺了哪些。本工具不自動安裝任何執行環境——在使用者的機器上裝東西
* 應該是他自己的決定,所以這裡只回報,由呼叫端決定要警告還是中止。
* @param {string[]} binaries
* @returns {{missing: string[], hint: string}} 都在時 missing 為空陣列
*/
export function missingBinaries(binaries) {
const missing = binaries.filter((binary) => onPath(binary) === null);
const hints = missing.map((binary) => INSTALL_HINT[binary]).filter(Boolean);
return {
missing,
hint: missing.length === 0
? ''
: `PATH 上找不到 ${missing.join('、')};請先安裝(${hints.join('、')}),本工具不會替你安裝`,
};
}
/**
* 要求這些執行檔都在 PATH 上,缺了就中止。
* 會真的去碰 Gitea 或 git 的路徑用它;只是寫檔案的路徑用 missingBinaries 警告就好。
* @param {string[]} binaries
*/
export function checkBinaries(binaries) {
const { missing, hint } = missingBinaries(binaries);
if (missing.length > 0) throw new ScriptError('ENV_MISSING', hint);
}
/** plugin 的四個正本目錄都在不在。裝壞了要在做事之前就講,不要跑到一半才找不到檔案。 */
export function checkPluginLayout() {
for (const dir of [promptsDir(), templatesDir(), referencesDir()]) {
if (!existsSync(dir)) {
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `plugin 目錄不完整,找不到 ${dir};請重新安裝 tea-sdlc`);
}
}
}
/**
* 在 PATH 上找一個執行檔,找到回完整路徑,找不到回 null。
* @param {string} binary
* @returns {string|null}
*/
export function onPath(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 更新登入`,
);
}
return 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;
}