/** * tea-sdlc 所有腳本的共用地基。 * * 這一層負責七件事,其餘腳本只寫自己的業務: * 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}}) * 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口 * 3. git 執行 —— 全專案唯一的子行程出口 * 4. 工作樹的一生 —— 路徑推導、建立(含重建)、現況、移除 * 5. 四層前置檢查 * 6. 冪等查重 * 7. 兩支抽取腳本共用的議題讀取 * * 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。 */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { accessSync, constants, existsSync, readFileSync, realpathSync, rmSync, statSync, } 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'); } /** * 所有工作樹的集中處:`~/.tea-sdlc/worktrees`。 * 集中在一個地方,清理時只有一處要看。`TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口, * 正常使用不必設。 */ function worktreesRoot() { const home = process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc'); return join(home, 'worktrees'); } /** * 由「哪顆工作包」純函式推導出「它的工作樹在哪」。 * * 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑, * 換一台機器或換一個 agent 也一樣,不存在就重建。 * * 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與 * `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。 * 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。 * * 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。 * * @param {string} repo owner/name * @param {string} branch 分支名,可含斜線 * @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼} */ export function worktreePath(repo, branch) { const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`; const hash = createHash('sha256').update(key).digest('hex').slice(0, 12); return join(worktreesRoot(), hash); } /** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */ function normalizeRef(value) { return value .split('/') .map((segment) => segment.trim()) .join('/') .toLowerCase(); } // ── 套件 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} */ 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) { // --key=value:等號右邊就是值,即使它本身以 -- 開頭也沒有歧義。 // commit 訊息、PR 描述這種內容裡出現 --flag 是常態,不該因此被當成打錯 flag。 flags[name] = arg.slice(eq + 1); continue; } i += 1; const value = argv[i]; if (value === undefined || value.startsWith('--')) { throw new ScriptError( 'MISSING_FLAG', `--${name} 需要一個值;值本身以 -- 開頭時請改用 --${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); } } /** * 失敗,但手上的東西還是要交出去。 * * 丟 ScriptError 的失敗只剩 code 與 message,因為那種失敗通常是「什麼都還沒做」。 * 有一種失敗不是這樣:事情做完了、檔案也寫出去了,只是驗不過。那時使用者最需要 * 知道的正是「已經寫了哪些、哪一個平台不通」,把 data 丟掉等於逼他自己去翻。 * * envelope 形狀不變,只是 {ok:false, error} 旁邊多一個 data:只讀 error.code 的 * 呼叫端照常運作。 */ export class Failure { /** * @param {string} code 可區分的錯誤碼 * @param {string} message 給人看的訊息,要說得出病灶與修復方式 * @param {object} data 已經做完的部分,原樣放進 envelope */ constructor(code, message, data) { this.code = code; this.message = message; this.data = data; } } /** * 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。 * stderr 永遠保持乾淨,呼叫端只需要讀 stdout。 * 回傳 RawText 時改印原樣內容,不包 envelope;回傳 Failure 時印 {ok:false} 並退出碼 1, * 但把 data 一起帶出去。其餘行為不變。 * @param {() => Promise|object|RawText|Failure} run 回傳要放進 data 的物件 */ export async function main(run) { try { const data = await run(); if (data instanceof RawText) { write(data.text, 0); return; } if (data instanceof Failure) { const { code, message } = data; write(`${JSON.stringify({ ok: false, error: { code, message }, data: data.data })}\n`, 1); 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}} 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}`); } } /** * 要求目標專案有 origin 遠端。 * 工作樹的起點一律取自遠端,沒有 origin 就什麼都做不了——兩支腳本擋的是同一件事, * 只有「為什麼需要它」那一句不同,所以那一句由呼叫端給。 * @param {string} 用途 出現在訊息裡的理由,例如「工作樹的起點一律取自 origin/{來源分支}」 */ export function requireOrigin(git, path, 用途) { if (!git('remote').split('\n').includes('origin')) { throw new ScriptError('NO_ORIGIN', `${path} 沒有 origin 遠端;${用途},請先設定 origin`); } } /** * 開一個目標專案的 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 }); } /** * 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。 * * 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方, * 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。 * * 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的 * 位置,所以別的 clone 也可能在同一條路徑上留下東西。 * * @param {string} worktree 推導出的工作樹路徑 * @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean, * files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}} */ export function inspectWorktree(worktree) { const 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] }; if (!existsSync(worktree)) return { ...空的, reason: 'missing' }; if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' }; const files = runGit(['status', '--porcelain'], { cwd: worktree }) .split('\n') .filter((line) => line !== '') // 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元—— // runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了, // 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。 .map((line) => line.replace(/^\s*\S{1,2}\s+/, '')); return { path: worktree, exists: true, isWorktree: true, dirty: files.length > 0, files, reason: files.length > 0 ? 'dirty' : 'removable', }; } /** * 這條路徑是不是一棵「連結出去的」工作樹。 * 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄, * 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。 */ function linkedWorktree(worktree) { try { return statSync(join(worktree, '.git')).isFile(); } catch { return false; } } /** * 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。 * * **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事: * 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因, * 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。 * * 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者 * 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。 * * @param {string} worktree 推導出的工作樹路徑 * @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}} * reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上 */ export function removeWorktree(worktree) { const state = inspectWorktree(worktree); if (state.reason !== 'removable') return { ...state, removed: false }; // 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪 runGit(['worktree', 'remove', worktree], { cwd: worktree }); return { ...state, removed: true, reason: 'removed' }; } /** * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 * * 建立與**重建**共用這一份:換一台機器接手時工作樹本來就不存在,而重建若另寫一套, * 兩邊遲早會在「起點取自哪裡」「要不要設 upstream」這種地方分岔。 * * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, * 所以試跑與實跑在同一個地方被擋下來。 * * @param {(...args: string[]) => string} git 綁在目標專案上的 git 執行器 * @param {{source?: string, branch: string, worktree: string}} 目標 * 不給 `source` 就是「重建既有分支的工作樹」:分支必須已經存在, * 沒有「從來源長一支新的」這條路。 * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 */ export function planWorktree(git, { source, branch, worktree }) { const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree)); const 目錄還在 = existsSync(worktree); if (既有 && 目錄還在) { if (既有.branch !== branch) { throw new ScriptError( 'WORKTREE_PATH_TAKEN', `${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`, ); } // 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西 return { commands: [], 動作: '沿用既有工作樹' }; } if (!既有 && 目錄還在) { throw new ScriptError( 'WORKTREE_PATH_TAKEN', `${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` + '請確認裡面沒有還沒保存的東西之後移除它,再重跑', ); } // 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽 if (source !== undefined && !onRemote(git, source)) { throw new ScriptError( 'SOURCE_NOT_FOUND', `遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` + '或改指定一個已經存在於遠端的來源分支', ); } // 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過 const commands = 既有 ? [['worktree', 'prune']] : []; commands.push(['fetch', 'origin']); if (onLocal(git, branch)) { // 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度 commands.push(['worktree', 'add', worktree, branch]); return { commands, 動作: '接上本地既有' }; } if (onRemote(git, branch)) { commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]); return { commands, 動作: '接上遠端既有' }; } if (source === undefined) { // 重建的路走到這裡代表那一支分支已經不見了——憑空長一棵空的只會讓人以為進度還在 throw new ScriptError( 'BRANCH_NOT_FOUND', `分支 ${branch} 在本機與遠端都不存在,重建不出工作樹;` + '請確認分支名,或先把它推上遠端', ); } commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]); return { commands, 動作: '從來源建立' }; } /** * 照計畫把工作樹建起來,失敗時回到原狀。 * 與 `planWorktree` 成對:算歸算、做歸做,而試跑只跑前半段。 * @param {{commands: string[][]}} plan planWorktree 的結果 */ export function createWorktree(git, plan, { worktree, branch }) { if (plan.commands.length === 0) return; // 既有的本地分支不是這次建的,回滾時不能連它一起刪掉 const 分支本來就在 = onLocal(git, branch); try { for (const args of plan.commands) git(...args); } catch (error) { rollback(git, { worktree, branch, 保留分支: 分支本來就在 }); throw error; } } /** * 這個 repo 目前有哪幾棵工作樹。 * `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`, * 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。 */ function listWorktrees(git) { return git('worktree', 'list', '--porcelain') .split('\n\n') .map((block) => { const path = block.match(/^worktree (.+)$/m)?.[1]; const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null; return path ? { path, branch } : null; }) .filter(Boolean); } /** * 兩條路徑指的是不是同一個地方。 * git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結 * (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。 */ function samePath(a, b) { return a === b || realOrSelf(a) === realOrSelf(b); } /** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ function realOrSelf(path) { try { return realpathSync(path); } catch { return path; } } /** * 遠端有沒有這一支分支。 * * 比對用全名 `refs/heads/`:`ls-remote --heads origin main` 的樣式比對吃的是 * ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對, * 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。 */ function onRemote(git, ref) { return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== ''; } function onLocal(git, ref) { return git('branch', '--list', ref).trim() !== ''; } /** * 建立失敗時把半成品清掉。 * * `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到 * 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰—— * 上面可能有別人的進度。 */ function rollback(git, { worktree, branch, 保留分支 }) { quietly(git, ['worktree', 'remove', '--force', worktree]); quietly(git, ['worktree', 'prune']); if (!保留分支) quietly(git, ['branch', '-D', branch]); // git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提), // 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN try { rmSync(worktree, { recursive: true, force: true }); } catch { // 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要 } } /** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ function quietly(git, args) { try { git(...args); } catch { // 清不掉就算了:原本的錯誤比清理的錯誤重要 } } // ── 四層前置檢查 ─────────────────────────────────────────────────── /** * 依序檢查四層,任一層不通過即拋出帶碼的錯誤並指出該改哪裡。 * 前一層沒過就不往下打,避免把一個設定問題報成四個。 * @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 開啟', ); } } /** * 讀一個由 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; } // ── 議題讀取:兩支抽取腳本共用 ──────────────────────────────────── /** * 讀一顆議題。「不存在」與「沒有讀取權」要分得開——前者是編號打錯, * 後者是權限沒開,兩種的下一步完全不同。 * @param {{base: string, token: string}} login * @param {string} repo owner/name * @param {number} index * @returns {Promise} */ 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 負責標記)。留言多時請求數會跟著長, * 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完, * 讀不完寧可報錯。 * * @param {{base: string, token: string}} login * @param {string} repo owner/name * @param {number} index * @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過 * @returns {Promise} */ export async function countUnmergedComments(login, repo, index, me) { let unmerged = 0; 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} 一則一則交出去 */ 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); } // ── 碼錶 ─────────────────────────────────────────────────────────── /** * 目前跑在自己身上的碼錶。 * * Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」, * 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。 * @param {{base: string, token: string}} login * @returns {Promise} */ export async function listStopwatches(login) { const path = '/user/stopwatches'; return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; } /** * 「你的錶正跑在別顆議題上」的擋路錯誤。領取與起錶都會撞到它,訊息只寫一份。 * * 一定要明說停錶不會動到工作樹:使用者常以為停錶等於放棄那顆工作包,於是寧可不停, * 而工時就記到別顆議題去了。碼錶只管時間,工作樹只管檔案,兩者互不相干。 * @param {object} watch listStopwatches 裡的一顆錶 * @param {string} 動作 擋在哪件事之前,例如「領取」「起錶」 */ export function stopwatchElsewhere(watch, 動作) { return new ScriptError( 'STOPWATCH_ON_OTHER_ISSUE', `你的碼錶正跑在 ${watch.repo_owner_name}/${watch.repo_name} 的議題 ` + `#${watch.issue_index} 上,${動作}前請先手動停錶,否則工時會記到那一顆去;` + '停錶只停計時,不會動到任何既有的工作樹', ); } /** * 這些碼錶裡,跑在指定議題上的那一顆。 * 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。 * @param {object[]} watches listStopwatches 的結果 * @param {string} repo owner/name * @param {number} index * @returns {object|null} */ export function stopwatchOnIssue(watches, repo, index) { return ( watches.find( (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, ) ?? null ); } // ── 標籤 ─────────────────────────────────────────────────────────── /** * 取得 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; }