diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/labels-list.js b/scripts/labels-list.js new file mode 100644 index 0000000..faf1a1b --- /dev/null +++ b/scripts/labels-list.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * 列出目標 repo 的既有標籤。 + * + * 這支腳本刻意只讀不寫:本專案不自動建立 Gitea 標籤,上游指令挑標籤時 + * 只能從這裡回傳的清單裡選,選不到就不貼。 + * + * 用法:node scripts/labels-list.js --repo owner/name [--host <網址>] [--dry-run] + */ +import { + expectOk, + giteaRequest, + main, + parseFlags, + parseRepo, + preflight, + referencesDir, + resolveLogin, + templatesDir, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const path = `/repos/${repo}/labels`; + + if (flags['dry-run']) { + return { + dryRun: true, + repo, + requests: [{ method: 'GET', path }], + paths: { templates: templatesDir(), references: referencesDir() }, + }; + } + + const login = resolveLogin({ host: flags.host }); + await preflight(login, repo); + + const labels = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + + return { + repo, + count: labels.length, + labels: labels.map((label) => ({ + id: label.id, + name: label.name, + color: label.color, + description: label.description ?? '', + })), + }; +}); diff --git a/scripts/lib.js b/scripts/lib.js new file mode 100644 index 0000000..da82456 --- /dev/null +++ b/scripts/lib.js @@ -0,0 +1,440 @@ +/** + * 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; + } + const value = eq === -1 ? argv[(i += 1)] : 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; +} + +/** + * 驗證並正規化 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 }); + process.exit(0); + } catch (error) { + const code = error instanceof ScriptError ? error.code : 'UNEXPECTED'; + const message = error?.message ?? String(error); + write({ ok: false, error: { code, message } }); + process.exit(1); + } +} + +function write(payload) { + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +// ── 認證來源 ─────────────────────────────────────────────────────── + +/** + * 決定要用哪個 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 開啟', + ); + } +} + +// ── 冪等查重 ─────────────────────────────────────────────────────── + +/** + * 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。 + * 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。 + * @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(); + const pageSize = 50; + + for (let page = 1; ; page += 1) { + const response = await giteaRequest(login, 'GET', `/repos/${repo}/issues`, { + query: { state: 'all', limit: pageSize, page }, + }); + const issues = expectOk(response, `GET /repos/${repo}/issues`) ?? []; + const hit = issues.find((issue) => (issue.title ?? '').trim() === wanted); + if (hit) return hit; + if (issues.length < pageSize) return null; + } +}