/** * tea-sdlc 所有腳本的共用地基。 * * 這一層負責五件事,其餘腳本只寫自己的業務: * 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}}) * 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口 * 3. git 執行 —— 全專案唯一的子行程出口 * 4. 四層前置檢查 * 5. 冪等查重 * * 外部相依集中在 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} */ 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} 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}} 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} 通過時回傳 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 開啟', ); } } // ── 標籤 ─────────────────────────────────────────────────────────── /** * 取得 repo 上的既有標籤。 * 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單, * 挑不到合適的就不貼。 * @param {{base: string, token: string}} login * @param {string} repo owner/name * @returns {Promise} */ 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} 找到的議題,或 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} 每次 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} 找到的議題,或 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; }