Files
tea-sdlc/scripts/lib.js
T
jiantw83andClaude Opus 5 17178f0c4d feat(佈署): 以 npm 裝出 tea-sdlc 指令並產生各平台轉接檔
單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣
交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。

轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上
的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次
Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。

prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。
失敗仍走 envelope——成功是內容,失敗才需要結構。

status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出
「status 掛了」與「status 成功查到你環境有問題」。

install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響
轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記
的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡
實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會
看到 PROMPT_NOT_FOUND。

流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發,
全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。

議題 #26 #27 #28 #29 #17

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:59:37 +00:00

657 lines
24 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 {
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() {
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 更新登入`,
);
}
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;
}