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:
@@ -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 ?? '',
|
||||
})),
|
||||
};
|
||||
});
|
||||
+440
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user