Merge pull request 'feat/script-contract-preflight/main' (#20) from feat/script-contract-preflight/main into master
Reviewed-on: #20 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #20.
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 ?? '',
|
||||
})),
|
||||
};
|
||||
});
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證並正規化 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 }, 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<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;
|
||||
// 翻頁上限。查不完就停手並報錯,不能無聲回 null——那會讓呼叫端把既有議題再建一次,
|
||||
// 正好是冪等查重要防的事。
|
||||
const maxPages = 200;
|
||||
|
||||
for (let page = 1; page <= maxPages; 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;
|
||||
}
|
||||
|
||||
throw new ScriptError(
|
||||
'DEDUPE_LIMIT',
|
||||
`翻完 ${maxPages * pageSize} 筆議題仍未查完 ${repo},無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 以子行程執行 scripts/ 底下的腳本,回傳它印出的 JSON 與 exit code。
|
||||
* 這是本專案唯一的測試接縫:測到的東西就是使用者真正會執行的東西。
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
/** 本檔位置 → repo 根,讓測試不依賴 cwd */
|
||||
export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
/** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */
|
||||
export const tmpRoot = join(repoRoot, '.tmp');
|
||||
|
||||
/**
|
||||
* @param {string} name 腳本檔名,例如 "labels-list.js"
|
||||
* @param {string[]} args 具名 flag 陣列
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string}} opts
|
||||
* env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea)
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>}
|
||||
*/
|
||||
export function runScript(name, args = [], opts = {}) {
|
||||
const { env = {}, cwd = repoRoot, path } = opts;
|
||||
// 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變
|
||||
const inherited = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => !k.startsWith('TEA_SDLC_')),
|
||||
);
|
||||
const childEnv = { ...inherited, ...env };
|
||||
if (path !== undefined) childEnv.PATH = path;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[join(repoRoot, 'scripts', name), ...args],
|
||||
// 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題
|
||||
{ cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
json: parseSingleLine(stdout),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 腳本的輸出契約是「單行 JSON」。這裡順便把契約本身斷言掉:
|
||||
* 多印一行、印出非 JSON,都會在這裡就炸掉。
|
||||
*/
|
||||
function parseSingleLine(stdout) {
|
||||
const lines = stdout.split('\n').filter((l) => l.trim() !== '');
|
||||
if (lines.length !== 1) {
|
||||
throw new Error(`預期單行 JSON,實際印出 ${lines.length} 行:\n${stdout}`);
|
||||
}
|
||||
return JSON.parse(lines[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一個只放得下指定執行檔的 PATH,用來讓「缺少 git / tea」的情境精確成立。
|
||||
* 直接從真實 PATH 裡剔除目錄行不通:git、tea、node 常住在同一個 /usr/bin。
|
||||
* @param {string[]} binaries 要保留的執行檔名
|
||||
* @returns {string} 只含一個目錄的 PATH
|
||||
*/
|
||||
export function pathWithOnly(binaries) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'path-'));
|
||||
for (const binary of binaries) {
|
||||
symlinkSync(which(binary), join(dir, binary));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function which(binary) {
|
||||
return execFileSync('sh', ['-c', `command -v ${binary}`], { encoding: 'utf8' }).trim();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 假的 Gitea:測試時用 TEA_SDLC_API_BASE 指向它,腳本便不會碰到真的伺服器。
|
||||
* 路由以 "METHOD /path" 為鍵,值可以是 {status, body} 或一個接 (req) 的函式。
|
||||
* 每一筆進來的請求都會被記錄,讓測試可以斷言「腳本到底發了哪些請求」。
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
/**
|
||||
* 啟動一台假 Gitea。
|
||||
* @param {Record<string, object|Function>} routes 路由表,鍵為 "METHOD /path"
|
||||
* @returns {Promise<{base: string, requests: object[], close: Function}>}
|
||||
*/
|
||||
export async function startStubGitea(routes = {}) {
|
||||
/** 收到的請求,依序記錄;測試靠它斷言請求次數與內容 */
|
||||
const requests = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
const key = `${req.method} ${url.pathname}`;
|
||||
requests.push({
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
authorization: req.headers.authorization ?? null,
|
||||
body: raw ? safeParse(raw) : null,
|
||||
});
|
||||
|
||||
const route = routes[key];
|
||||
if (route === undefined) {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: `stub has no route for ${key}` }));
|
||||
return;
|
||||
}
|
||||
const { status = 200, body = null } =
|
||||
typeof route === 'function' ? route(requests.at(-1)) : route;
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(body === null ? '' : JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
return {
|
||||
base: `http://127.0.0.1:${port}/api/v1`,
|
||||
requests,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一台「什麼都正常」的 Gitea 的路由表,測試再以 overrides 局部覆寫成想要的壞掉樣子。
|
||||
* @param {string} repo 目標 repo,格式 owner/name
|
||||
* @param {Record<string, object|Function>} overrides 要覆寫的路由
|
||||
*/
|
||||
export function healthyRoutes(repo, overrides = {}) {
|
||||
return {
|
||||
'GET /api/v1/user': { status: 200, body: { login: 'tester' } },
|
||||
[`GET /api/v1/repos/${repo}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
full_name: repo,
|
||||
has_issues: true,
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: true },
|
||||
},
|
||||
},
|
||||
// 寫入權探針:有 issues 寫入權時,Gitea 會通過權限中介層後才發現議題不存在
|
||||
[`PATCH /api/v1/repos/${repo}/issues/0`]: { status: 404, body: { message: 'issue does not exist' } },
|
||||
[`GET /api/v1/repos/${repo}/labels`]: {
|
||||
status: 200,
|
||||
body: [
|
||||
{ id: 55, name: 'ready-for-agent', color: '0e8a16', description: '可交給 agent 處理' },
|
||||
{ id: 56, name: '進行中', color: 'fbca04', description: '' },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function safeParse(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 在 .tmp/ 底下開一個真的 git repo。
|
||||
* git 操作不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpRoot } from './run-script.js';
|
||||
|
||||
/**
|
||||
* @returns {{dir: string, cleanup: Function}} dir 為已有一顆 commit 的 git repo
|
||||
*/
|
||||
export function makeTempRepo() {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'repo-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, {
|
||||
cwd: dir,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
||||
});
|
||||
|
||||
git('init', '-q', '-b', 'master');
|
||||
git('config', 'user.name', 'tester');
|
||||
git('config', 'user.email', 'tester@example.com');
|
||||
writeFileSync(join(dir, 'README.md'), '# temp\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'initial');
|
||||
|
||||
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
/** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */
|
||||
async function withStub(t, overrides = {}) {
|
||||
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
|
||||
t.after(() => stub.close());
|
||||
return stub;
|
||||
}
|
||||
|
||||
function envFor(stub) {
|
||||
return { TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' };
|
||||
}
|
||||
|
||||
test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json, stdout } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.deepEqual(
|
||||
json.data.labels.map((l) => l.name),
|
||||
['ready-for-agent', '進行中'],
|
||||
);
|
||||
assert.equal(stdout.trimEnd().includes('\n'), false, '輸出必須是單行');
|
||||
});
|
||||
|
||||
test('輸出帶上 repo 與標籤數,供下游不必再數一次', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.data.repo, REPO);
|
||||
assert.equal(json.data.count, 2);
|
||||
});
|
||||
|
||||
test('帶上 Authorization,且只對 Gitea 發出讀取請求', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
const labelCalls = stub.requests.filter((r) => r.path.endsWith('/labels'));
|
||||
assert.equal(labelCalls.length, 1);
|
||||
assert.equal(labelCalls[0].method, 'GET');
|
||||
assert.equal(labelCalls[0].authorization, 'token stub-token');
|
||||
});
|
||||
|
||||
test('不具備建立標籤的能力:沒有任何請求會寫到 labels 端點', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
const writesToLabels = stub.requests.filter(
|
||||
(r) => r.path.endsWith('/labels') && r.method !== 'GET',
|
||||
);
|
||||
assert.deepEqual(writesToLabels, []);
|
||||
});
|
||||
|
||||
test('不具備建立標籤的能力:看起來像要建標籤的 flag 一律被拒', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'labels-list.js',
|
||||
['--repo', REPO, '--create', 'needs-triage'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'UNKNOWN_FLAG');
|
||||
assert.equal(stub.requests.length, 0, '參數錯誤時不該碰 Gitea');
|
||||
});
|
||||
|
||||
test('空標籤清單不是錯誤', async (t) => {
|
||||
const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/labels`]: { status: 200, body: [] } });
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.labels, []);
|
||||
assert.equal(json.data.count, 0);
|
||||
});
|
||||
|
||||
test('Gitea 回非預期狀態碼時,帶著狀態碼失敗而不是硬吞', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/labels`]: { status: 500, body: { message: 'boom' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'HTTP_ERROR');
|
||||
assert.match(json.error.message, /500/);
|
||||
});
|
||||
|
||||
test('大量標籤不會被截斷:輸出仍是完整且可解析的單行 JSON', async (t) => {
|
||||
// 回歸測試:stdout 接到 pipe 時寫入是非同步的,寫完立刻 process.exit 會截斷長輸出
|
||||
const many = Array.from({ length: 8000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `label-${i}`,
|
||||
color: 'ffffff',
|
||||
description: '為了把輸出撐大到足以觸發截斷的敘述文字'.repeat(2),
|
||||
}));
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/labels`]: { status: 200, body: many },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.count, 8000);
|
||||
assert.equal(json.data.labels.at(-1).name, 'label-7999');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 這一支是唯一直接 import lib 的測試,其餘一律走子行程的 CLI 邊界。
|
||||
*
|
||||
* 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,但本工作包只交付
|
||||
* labels-list(讀取型、不碰 git、不需查重),CLI 邊界上還沒有消費者。等 #4 的
|
||||
* issue-create 與 #10 的 branch-prep 落地後,它們的 CLI 測試才是這兩件事的主場,
|
||||
* 屆時這支可以縮小或移除。在那之前直接測 export,好過讓契約完全沒有測試。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findIssueByTitle, runGit, pluginRoot, templatesDir, referencesDir } from '../scripts/lib.js';
|
||||
import { startStubGitea } from './helpers/stub-gitea.js';
|
||||
import { makeTempRepo } from './helpers/temp-repo.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
// ── 冪等查重 ───────────────────────────────────────────────────────
|
||||
|
||||
test('查重:標題完全相同的既有議題會被找到', async (t) => {
|
||||
const stub = await startStubGitea({
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: {
|
||||
status: 200,
|
||||
body: [
|
||||
{ number: 7, title: '建立工作包的抽取契約' },
|
||||
{ number: 8, title: '產生圖解版總覽網頁並寫回議題' },
|
||||
],
|
||||
},
|
||||
});
|
||||
t.after(() => stub.close());
|
||||
|
||||
const found = await findIssueByTitle(
|
||||
{ base: stub.base, token: 'stub-token' },
|
||||
REPO,
|
||||
'產生圖解版總覽網頁並寫回議題',
|
||||
);
|
||||
|
||||
assert.equal(found.number, 8);
|
||||
});
|
||||
|
||||
test('查重:沒有同名議題時回 null,讓呼叫端自行決定要不要建立', async (t) => {
|
||||
const stub = await startStubGitea({
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [{ number: 7, title: '別的議題' }] },
|
||||
});
|
||||
t.after(() => stub.close());
|
||||
|
||||
const found = await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '找不到的標題');
|
||||
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test('查重:同時看 open 與 closed,避免把已關閉的議題再建一次', async (t) => {
|
||||
const stub = await startStubGitea({
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] },
|
||||
});
|
||||
t.after(() => stub.close());
|
||||
|
||||
await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '任何標題');
|
||||
|
||||
assert.equal(stub.requests[0].query.state, 'all');
|
||||
});
|
||||
|
||||
test('查重:標題只差前後空白仍視為同一顆', async (t) => {
|
||||
const stub = await startStubGitea({
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: {
|
||||
status: 200,
|
||||
body: [{ number: 12, title: ' 走通腳本契約 ' }],
|
||||
},
|
||||
});
|
||||
t.after(() => stub.close());
|
||||
|
||||
const found = await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '走通腳本契約');
|
||||
|
||||
assert.equal(found.number, 12);
|
||||
});
|
||||
|
||||
// ── git 執行點 ─────────────────────────────────────────────────────
|
||||
|
||||
test('git 執行點:在臨時 repo 上跑真實 git,取得目前分支', (t) => {
|
||||
const repo = makeTempRepo();
|
||||
t.after(() => repo.cleanup());
|
||||
|
||||
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo.dir });
|
||||
|
||||
assert.equal(branch, 'master');
|
||||
});
|
||||
|
||||
test('git 執行點:輸出前後空白被修掉,呼叫端不必自己 trim', (t) => {
|
||||
const repo = makeTempRepo();
|
||||
t.after(() => repo.cleanup());
|
||||
|
||||
const log = runGit(['log', '--oneline'], { cwd: repo.dir });
|
||||
|
||||
assert.equal(log.endsWith('\n'), false);
|
||||
assert.match(log, /initial$/);
|
||||
});
|
||||
|
||||
test('git 執行點:git 失敗時拋出帶有 stderr 的錯誤,不回傳空字串', (t) => {
|
||||
const repo = makeTempRepo();
|
||||
t.after(() => repo.cleanup());
|
||||
|
||||
assert.throws(() => runGit(['rev-parse', 'no-such-ref'], { cwd: repo.dir }), /no-such-ref/);
|
||||
});
|
||||
|
||||
// ── 路徑定位 ───────────────────────────────────────────────────────
|
||||
|
||||
test('plugin 根由自身檔案位置回推,templates 與 references 都在底下', () => {
|
||||
assert.equal(templatesDir(), join(pluginRoot(), 'templates'));
|
||||
assert.equal(referencesDir(), join(pluginRoot(), 'references'));
|
||||
assert.ok(existsSync(templatesDir()));
|
||||
assert.ok(existsSync(referencesDir()));
|
||||
});
|
||||
|
||||
test('查重:翻頁有上限,查不完時報錯而不是無聲回 null', async (t) => {
|
||||
// 無聲回 null 會讓呼叫端把既有議題再建一次,正好是查重要防的事
|
||||
const full = Array.from({ length: 50 }, (_, i) => ({ number: i, title: `不相干的議題 ${i}` }));
|
||||
const stub = await startStubGitea({
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: full },
|
||||
});
|
||||
t.after(() => stub.close());
|
||||
|
||||
await assert.rejects(
|
||||
() => findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '永遠找不到的標題'),
|
||||
(error) => error.code === 'DEDUPE_LIMIT',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript, pathWithOnly } from './helpers/run-script.js';
|
||||
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
async function withStub(t, overrides = {}) {
|
||||
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
|
||||
t.after(() => stub.close());
|
||||
return stub;
|
||||
}
|
||||
|
||||
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
|
||||
|
||||
// ── 第一層:執行環境 ────────────────────────────────────────────────
|
||||
|
||||
test('第一層:PATH 上沒有 git 時中止,錯誤訊息指名 git', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['tea']),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ENV_MISSING');
|
||||
assert.match(json.error.message, /git/);
|
||||
assert.equal(stub.requests.length, 0, '環境不通過就不該發請求');
|
||||
});
|
||||
|
||||
test('第一層:PATH 上沒有 tea 時中止,錯誤訊息指名 tea 並給出安裝指引', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['git']),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ENV_MISSING');
|
||||
assert.match(json.error.message, /tea/);
|
||||
});
|
||||
|
||||
// ── 第二層:Gitea 登入 ─────────────────────────────────────────────
|
||||
|
||||
test('第二層:token 無效時中止,訊息指向 tea login', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: { message: 'unauthorized' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
assert.match(json.error.message, /tea login/);
|
||||
});
|
||||
|
||||
test('第二層:找不到任何登入資訊時中止', async (t) => {
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_API_BASE: '', TEA_SDLC_TOKEN: '', TEA_SDLC_CONFIG: '/nonexistent/tea.yml' },
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
});
|
||||
|
||||
// ── 第三層:issues unit 寫入權 ─────────────────────────────────────
|
||||
|
||||
test('第三層:repo 不存在或讀不到時,錯誤碼與權限問題分得開', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: { status: 404, body: { message: 'not found' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'REPO_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('第三層:repo 關閉議題功能時中止,訊息指出開啟位置', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: false,
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ISSUES_UNIT_OFF');
|
||||
assert.match(json.error.message, /Settings/);
|
||||
});
|
||||
|
||||
test('第三層:對 issues unit 實測寫入權,被擋下時中止', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
|
||||
assert.match(json.error.message, /issues/i);
|
||||
});
|
||||
|
||||
test('第三層:不能只看 permissions.push——push 為真但 issues unit 被擋,仍須失敗', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
// team 的 unit 權限可獨立於 repo 的 push 權限,所以這裡是真的也不算數
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: true },
|
||||
},
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
|
||||
});
|
||||
|
||||
test('第三層:探針打在不存在的議題 index 0,確保無副作用', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
const probes = stub.requests.filter((r) => r.method === 'PATCH');
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(probes[0].path, `/api/v1/repos/${REPO}/issues/0`);
|
||||
assert.deepEqual(probes[0].body, {}, '探針不得挾帶任何要寫入的欄位');
|
||||
});
|
||||
|
||||
// ── 第四層:時間追蹤 ───────────────────────────────────────────────
|
||||
|
||||
test('第四層:時間追蹤未開啟時中止,訊息指出 Enable Time Tracker 的位置', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
|
||||
assert.match(json.error.message, /Settings → Advanced Settings → Enable Time Tracker/);
|
||||
});
|
||||
|
||||
// ── 四層之間 ───────────────────────────────────────────────────────
|
||||
|
||||
test('四層的錯誤碼兩兩相異,呼叫端分得出是哪一層壞了', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
code: 'ENV_MISSING',
|
||||
run: async () => {
|
||||
const stub = await withStub(t);
|
||||
return runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['git']),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'LOGIN_INVALID',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: {} },
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'NO_ISSUE_WRITE',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: {} },
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'TIME_TRACKER_OFF',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
permissions: { admin: true, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const seen = [];
|
||||
for (const { code, run } of cases) {
|
||||
const { json } = await run();
|
||||
assert.equal(json.error.code, code);
|
||||
seen.push(json.error.code);
|
||||
}
|
||||
assert.equal(new Set(seen).size, seen.length);
|
||||
});
|
||||
|
||||
test('前一層失敗就停手,不會再往下打', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: {} },
|
||||
});
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.deepEqual(
|
||||
stub.requests.map((r) => r.path),
|
||||
['/api/v1/user'],
|
||||
'登入檢查沒過就不該再查 repo 或標籤',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 所有腳本共用的介面契約:具名 flag 輸入、單行 JSON 輸出、--dry-run、
|
||||
* 以及「從自身檔案位置回推 plugin 根」而非依賴 cwd。
|
||||
* 以 labels-list 當代表,因為契約由 lib 提供,每支腳本表現一致。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
async function withStub(t, overrides = {}) {
|
||||
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
|
||||
t.after(() => stub.close());
|
||||
return stub;
|
||||
}
|
||||
|
||||
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
|
||||
|
||||
// ── 輸入:具名 flag ────────────────────────────────────────────────
|
||||
|
||||
test('缺少必填 flag 時失敗,並指名缺的是哪一個', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', [], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--repo/);
|
||||
});
|
||||
|
||||
test('不認得的 flag 一律拒絕,不默默忽略', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO, '--verbose'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'UNKNOWN_FLAG');
|
||||
assert.match(json.error.message, /--verbose/);
|
||||
});
|
||||
|
||||
test('位置參數一律拒絕:輸入只接受具名 flag', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', [REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'UNKNOWN_FLAG');
|
||||
});
|
||||
|
||||
test('--repo 格式不是 owner/name 時失敗', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', 'tea-sdlc'], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'BAD_REPO');
|
||||
});
|
||||
|
||||
test('--key=value 與 --key value 兩種寫法等價', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const a = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
const b = await runScript('labels-list.js', [`--repo=${REPO}`], { env: envFor(stub) });
|
||||
|
||||
assert.deepEqual(a.json, b.json);
|
||||
});
|
||||
|
||||
// ── 輸出:單行 JSON ───────────────────────────────────────────────
|
||||
|
||||
test('成功時輸出 {ok:true, data},且沒有 error 欄位', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.ok, true);
|
||||
assert.ok(json.data);
|
||||
assert.equal('error' in json, false);
|
||||
});
|
||||
|
||||
test('失敗時輸出 {ok:false, error:{code,message}},且沒有 data 欄位', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', [], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(typeof json.error.code, 'string');
|
||||
assert.equal(typeof json.error.message, 'string');
|
||||
assert.equal('data' in json, false);
|
||||
});
|
||||
|
||||
test('失敗時 exit code 為 1,成功為 0', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const bad = await runScript('labels-list.js', [], { env: envFor(stub) });
|
||||
const good = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(bad.code, 1);
|
||||
assert.equal(good.code, 0);
|
||||
});
|
||||
|
||||
test('例外不會以 stack trace 洩漏出去,一律收斂成 JSON', async (t) => {
|
||||
// 指向一個沒有人在聽的 port,讓 fetch 直接拋連線錯誤
|
||||
const { code, json, stderr } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_API_BASE: 'http://127.0.0.1:1/api/v1', TEA_SDLC_TOKEN: 'x' },
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'NETWORK_ERROR');
|
||||
assert.equal(stderr, '', 'stderr 必須乾淨,訊息一律走 stdout 的 JSON');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.requests, [
|
||||
{ method: 'GET', path: `/repos/${REPO}/labels` },
|
||||
]);
|
||||
assert.equal(stub.requests.length, 0, '--dry-run 不得發出任何請求,連前置檢查也不發');
|
||||
});
|
||||
|
||||
// ── 路徑定位 ───────────────────────────────────────────────────────
|
||||
|
||||
test('在任意 cwd 下執行結果相同:腳本從自身位置回推 plugin 根', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const fromRoot = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
const fromSlash = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
cwd: '/',
|
||||
});
|
||||
const fromTmp = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
assert.equal(fromRoot.code, 0);
|
||||
assert.deepEqual(fromSlash.json, fromRoot.json);
|
||||
assert.deepEqual(fromTmp.json, fromRoot.json);
|
||||
});
|
||||
|
||||
test('--dry-run 回報的 templates/references 路徑為絕對路徑,且不隨 cwd 改變', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const a = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], { env: envFor(stub) });
|
||||
const b = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], {
|
||||
env: envFor(stub),
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
assert.match(a.json.data.paths.templates, /^\/.*\/templates$/);
|
||||
assert.match(a.json.data.paths.references, /^\/.*\/references$/);
|
||||
assert.deepEqual(b.json.data.paths, a.json.data.paths);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 認證來源:預設沿用使用者既有的 `tea login`,不另外要求設定一組 token。
|
||||
* TEA_SDLC_API_BASE / TEA_SDLC_TOKEN 只是給測試與 CI 的覆寫出口。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
/** 寫一份 tea 風格的 config.yml 到暫存目錄,回傳路徑 */
|
||||
function writeTeaConfig(logins) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'tea-'));
|
||||
const path = join(dir, 'config.yml');
|
||||
const body = logins
|
||||
.map((l) =>
|
||||
[
|
||||
` - name: ${l.name}`,
|
||||
` url: ${l.url}`,
|
||||
` token: ${l.token}`,
|
||||
` default: ${l.default ?? false}`,
|
||||
` ssh_key: ""`,
|
||||
` user: ${l.user ?? 'tester'}`,
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n');
|
||||
writeFileSync(path, `logins:\n${body}\npreferences:\n editor: false\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function withStub(t) {
|
||||
const stub = await startStubGitea(healthyRoutes(REPO));
|
||||
t.after(() => stub.close());
|
||||
return stub;
|
||||
}
|
||||
|
||||
/** stub 的 base 是 .../api/v1,tea config 記的是站台根網址 */
|
||||
const siteUrl = (stub) => stub.base.replace(/\/api\/v1$/, '');
|
||||
|
||||
test('沒有環境變數覆寫時,採用 tea 設定中 default 為真的登入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const config = writeTeaConfig([
|
||||
{ name: 'OTHER', url: 'https://gitea.example.com', token: 'wrong-token' },
|
||||
{ name: 'JSC', url: siteUrl(stub), token: 'config-token', default: true },
|
||||
]);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_CONFIG: config },
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(stub.requests[0].authorization, 'token config-token');
|
||||
});
|
||||
|
||||
test('--host 指定站台時,挑出對應的那一組登入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const config = writeTeaConfig([
|
||||
{ name: 'JSC', url: siteUrl(stub), token: 'jsc-token' },
|
||||
{ name: 'HP', url: 'https://gitea.example.com', token: 'hp-token', default: true },
|
||||
]);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'labels-list.js',
|
||||
['--repo', REPO, '--host', siteUrl(stub)],
|
||||
{ env: { TEA_SDLC_CONFIG: config } },
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(stub.requests[0].authorization, 'token jsc-token');
|
||||
});
|
||||
|
||||
test('--host 指定的站台沒有對應登入時,明確說是哪個站台缺登入', async (t) => {
|
||||
const config = writeTeaConfig([{ name: 'JSC', url: 'https://gitea.example.com', token: 't', default: true }]);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'labels-list.js',
|
||||
['--repo', REPO, '--host', 'https://gitea.missing.tw'],
|
||||
{ env: { TEA_SDLC_CONFIG: config } },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
assert.match(json.error.message, /gitea\.missing\.tw/);
|
||||
});
|
||||
|
||||
test('tea 設定檔不存在時,訊息指向 tea login add', async (t) => {
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_CONFIG: join(tmpRoot, 'definitely-not-here', 'config.yml') },
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
assert.match(json.error.message, /tea login add/);
|
||||
});
|
||||
|
||||
test('設定檔存在但沒有任何登入時,同樣指向 tea login add', async (t) => {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'tea-'));
|
||||
const path = join(dir, 'config.yml');
|
||||
writeFileSync(path, 'logins: []\npreferences:\n editor: false\n');
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_CONFIG: path },
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
assert.match(json.error.message, /tea login add/);
|
||||
});
|
||||
|
||||
test('只有一組登入且未標 default 時,就用那一組', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const config = writeTeaConfig([{ name: 'ONLY', url: siteUrl(stub), token: 'only-token' }]);
|
||||
|
||||
const { code } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_CONFIG: config },
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(stub.requests[0].authorization, 'token only-token');
|
||||
});
|
||||
Reference in New Issue
Block a user