feat(腳本契約): 建立共用函式庫、四層前置檢查與 labels-list

scripts/lib.js 立起所有腳本共用的地基:具名 flag 解析、單行 JSON 輸出
{ok, data, error:{code, message}}、Gitea API 與 git 各自唯一的出口、
四層前置檢查、依標題的冪等查重。

前置檢查依序為執行環境(git/tea 在 PATH 上、plugin 目錄完整)、Gitea 登入
有效、帳號對 issues unit 的寫入權、repo 已開啟時間追蹤;任一層不通過即帶著
可區分的錯誤碼中止,訊息一律指出該去哪裡改設定,前一層沒過就不再往下打。

issues unit 的寫入權採實測而非讀 permissions.push——Gitea 的 team unit 權限
可獨立於 repo 的 push 權限。探針打在不存在的議題 index 0:有寫入權會通過權限
中介層後回 404,沒有則直接 403,兩種結果都不改動任何東西。

認證預設沿用使用者既有的 tea login(讀 tea 的 config.yml),環境變數
TEA_SDLC_API_BASE / TEA_SDLC_TOKEN 只作為測試與 CI 的覆寫出口。

labels-list 為第一支腳本,刻意只讀不寫,呼應「不自動建立 Gitea 標籤」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 04:26:50 +00:00
co-authored by Claude Opus 5
parent 6408b96fb9
commit 48fd69cfde
3 changed files with 495 additions and 0 deletions
+440
View File
@@ -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<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;
}
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>|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<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() {
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<object|null>} 找到的議題,或 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;
}
}