Files
tea-sdlc/scripts/lib.js
T
jiantw83andClaude Opus 5 ee474d0439 refactor(lib): 讀檔與列留言收進 lib,並讓已整併只認自己打的 +1
「讀一個 --xxx-file 或直接失敗」原本在四支腳本各寫一份,錯誤碼還有三種拼法
(BODY_FILE_NOT_FOUND/BODY_FILE_MISSING/CONTENT_FILE_NOT_FOUND)。同一種情況要有同一個
碼,呼叫端才分辨得出是哪一步壞了。留言分頁的那段咒語也是第三份,一併收成 listIssueComments。

countUnmergedComments 原本接受任何人的 +1,而讀留言那邊只認自己的——兩端對「已整併」的
定義不一致。後果是隊友對決策留言按個讚,未處理留言數就掉到 0,analyze 與 feat 再也不提示,
那則決策永遠不會被收進描述。統一成只認自己打的:別人按讚是「我同意」,不是「已經收進去了」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:19:05 +00:00

899 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tea-sdlc 所有腳本的共用地基。
*
* 這一層負責六件事,其餘腳本只寫自己的業務:
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
* 3. git 執行 —— 全專案唯一的子行程出口
* 4. 四層前置檢查
* 5. 冪等查重
* 6. 兩支抽取腳本共用的議題讀取
*
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/** 帶錯誤碼的失敗。呼叫端靠 code 分辨是哪一步壞了,訊息則要能指出去哪裡改。 */
export class ScriptError extends Error {
/**
* @param {string} code 可區分的錯誤碼,例如 TIME_TRACKER_OFF
* @param {string} message 給人看的訊息,必要時附上「該改哪裡」
*/
constructor(code, message) {
super(message);
this.code = code;
}
}
// ── 路徑定位 ───────────────────────────────────────────────────────
/** 本檔位置 → plugin 根。不依賴 cwd,也不依賴環境變數。 */
export function pluginRoot() {
return dirname(dirname(fileURLToPath(import.meta.url)));
}
/** 輸出模板所在目錄 */
export function templatesDir() {
return join(pluginRoot(), 'templates');
}
/** 規則正本所在目錄 */
export function referencesDir() {
return join(pluginRoot(), 'references');
}
/** 流程正本所在目錄 */
export function promptsDir() {
return join(pluginRoot(), 'prompts');
}
/**
* 所有工作樹的集中處:`~/.tea-sdlc/worktrees`。
* 集中在一個地方,清理時只有一處要看。`TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,
* 正常使用不必設。
*/
function worktreesRoot() {
const home = process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc');
return join(home, 'worktrees');
}
/**
* 由「哪顆工作包」純函式推導出「它的工作樹在哪」。
*
* 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑,
* 換一台機器或換一個 agent 也一樣,不存在就重建。
*
* 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與
* `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。
* 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。
*
* 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。
*
* @param {string} repo owner/name
* @param {string} branch 分支名,可含斜線
* @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼}
*/
export function worktreePath(repo, branch) {
const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`;
const hash = createHash('sha256').update(key).digest('hex').slice(0, 12);
return join(worktreesRoot(), hash);
}
/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */
function normalizeRef(value) {
return value
.split('/')
.map((segment) => segment.trim())
.join('/')
.toLowerCase();
}
// ── 套件 manifest ─────────────────────────────────────────────────
/**
* 套件 manifest。版本字串只有這一個來源——轉接檔嵌的版本、status 回報的版本與
* prompt 比對的版本都從這裡來,另外寫死一份就會有兩個真相。
* @returns {object}
*/
export function packageManifest() {
const path = join(pluginRoot(), 'package.json');
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch (cause) {
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `讀不到 ${path}:${cause.message};請重新安裝 tea-sdlc`);
}
}
/** 目前安裝的 tea-sdlc 版本 */
export function packageVersion() {
return packageManifest().version;
}
// ── 輸入:具名 flag ────────────────────────────────────────────────
/**
* 解析具名 flag。位置參數與不認得的 flag 一律拒絕,不默默忽略。
* @param {string[]} argv 通常是 process.argv.slice(2)
* @param {{required?: string[], optional?: string[], booleans?: string[]}} spec
* @returns {Record<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) {
// --key=value:等號右邊就是值,即使它本身以 -- 開頭也沒有歧義。
// commit 訊息、PR 描述這種內容裡出現 --flag 是常態,不該因此被當成打錯 flag。
flags[name] = arg.slice(eq + 1);
continue;
}
i += 1;
const value = argv[i];
if (value === undefined || value.startsWith('--')) {
throw new ScriptError(
'MISSING_FLAG',
`--${name} 需要一個值;值本身以 -- 開頭時請改用 --${name}=值 的寫法`,
);
}
flags[name] = value;
}
for (const name of required) {
if (flags[name] === undefined) {
throw new ScriptError('MISSING_FLAG', `缺少必填 flag --${name}`);
}
}
return flags;
}
/**
* 驗證議題編號。四支腳本都要做這件事,錯誤碼也該一致。
* @param {string|number} value
* @param {string} what 出現在錯誤訊息裡的欄位名,例如 '--index'
* @returns {number}
*/
export function parseIndex(value, what = '--index') {
if (!/^[1-9]\d*$/.test(String(value))) {
throw new ScriptError('BAD_INDEX', `${what} 需為正整數,收到的是 ${value}`);
}
return Number(value);
}
/**
* 驗證並正規化 owner/name 形式的 repo。
* @param {string} value
* @returns {string}
*/
export function parseRepo(value) {
const parts = value.split('/');
if (parts.length !== 2 || parts.some((p) => p.trim() === '')) {
throw new ScriptError('BAD_REPO', `--repo 需為 owner/name 格式,收到的是 ${value}`);
}
return parts.map((p) => p.trim()).join('/');
}
// ── 輸出:單行 JSON,或原樣內容 ───────────────────────────────────
/**
* 包住要原樣印出的內容。`main` 看到它就不包 JSON envelope,直接把 text 逐字印出去。
*
* 只有一種輸出用得上它:要餵給模型讀的流程正本。把幾百行 markdown 包進單行 JSON
* 再逼模型反跳脫,只會增加它讀錯的機率;JSON envelope 的價值是可程式化判斷成敗,
* 而那條路徑的成功就是內容本身。失敗仍走 envelope —— 成功是內容,失敗才需要結構。
*/
export class RawText {
/** @param {string} text 要逐字印出的內容,不補也不修任何字元 */
constructor(text) {
this.text = String(text);
}
}
/**
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
*/
export async function main(run) {
try {
const data = await run();
if (data instanceof RawText) {
write(data.text, 0);
return;
}
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
} catch (error) {
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
const message = error?.message ?? String(error);
write(`${JSON.stringify({ ok: false, error: { code, message } })}\n`, 1);
}
}
/**
* 印出結果後才結束行程。
* stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出,
* 所以要等 write 的 callback 回來再退出。
*/
function write(text, exitCode) {
process.stdout.write(text, () => process.exit(exitCode));
}
// ── 認證來源 ───────────────────────────────────────────────────────
/**
* 決定要用哪個 Gitea 站台與哪一組 token。
* 預設沿用使用者既有的 `tea login`,不另外要求設定;環境變數只是測試與 CI 的覆寫出口。
* @param {{host?: string}} options host 可為完整網址或主機名
* @returns {{base: string, token: string}} base 已含 /api/v1
*/
export function resolveLogin({ host } = {}) {
const envBase = process.env.TEA_SDLC_API_BASE?.trim();
const envToken = process.env.TEA_SDLC_TOKEN?.trim();
if (envBase && envToken) {
return { base: stripSlash(envBase), token: envToken };
}
const configPath = process.env.TEA_SDLC_CONFIG?.trim() || defaultTeaConfig();
if (!existsSync(configPath)) {
throw new ScriptError(
'LOGIN_INVALID',
`找不到 tea 設定檔 ${configPath};請先執行 tea login add 登入 Gitea`,
);
}
const logins = parseTeaLogins(readFileSync(configPath, 'utf8'));
if (logins.length === 0) {
throw new ScriptError(
'LOGIN_INVALID',
`tea 設定檔 ${configPath} 裡沒有任何登入;請先執行 tea login add 登入 Gitea`,
);
}
const chosen = host
? logins.find((login) => sameHost(login.url, host))
: logins.find((login) => login.default) ?? logins[0];
if (!chosen) {
throw new ScriptError(
'LOGIN_INVALID',
`tea 設定檔裡找不到 ${host} 的登入;請先執行 tea login add 登入該站台`,
);
}
return { base: `${stripSlash(chosen.url)}/api/v1`, token: chosen.token };
}
function defaultTeaConfig() {
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
return join(base, 'tea', 'config.yml');
}
/**
* 從 tea 的 config.yml 取出登入清單。
* 只認得 tea 實際寫出的那一種扁平結構,不是通用 YAML 剖析器 —— 本專案零外部套件,
* 而需要的資訊只有 url 與 token 兩個欄位。
* @param {string} text config.yml 的內容
* @returns {{name: string, url: string, token: string, default: boolean}[]}
*/
function parseTeaLogins(text) {
const logins = [];
let inLogins = false;
let current = null;
for (const line of text.split('\n')) {
if (/^logins:/.test(line)) {
inLogins = true;
current = null;
continue;
}
if (/^\S/.test(line)) {
inLogins = false;
current = null;
continue;
}
if (!inLogins) continue;
const item = line.match(/^\s*-\s*name:\s*(.*)$/);
if (item) {
current = { name: unquote(item[1]) };
logins.push(current);
continue;
}
const pair = line.match(/^\s+([A-Za-z_]+):\s*(.*)$/);
if (pair && current) current[pair[1]] = unquote(pair[2]);
}
return logins
.filter((login) => login.url && login.token)
.map((login) => ({ ...login, default: login.default === 'true' }));
}
function unquote(value) {
const trimmed = value.trim();
return trimmed.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
}
function sameHost(url, host) {
return hostOf(url) === hostOf(host);
}
function hostOf(value) {
const withScheme = /^https?:\/\//.test(value) ? value : `https://${value}`;
try {
return new URL(withScheme).host;
} catch {
return value;
}
}
function stripSlash(value) {
return value.replace(/\/+$/, '');
}
// ── Gitea:全專案唯一的 HTTP 出口 ─────────────────────────────────
/**
* 對 Gitea 發一次請求。所有腳本的 Gitea 呼叫都必須經過這裡,
* 測試才能用 TEA_SDLC_API_BASE 把整個專案指向本機 stub server。
* @param {{base: string, token: string}} login
* @param {string} method
* @param {string} path /api/v1 之後的路徑,例如 /repos/o/r/labels
* @param {{body?: object, query?: Record<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 {
// stderr 要收進來而不是繼承:git 把 checkout/fetch 的進度訊息全寫在 stderr,
// 不收的話它們會混進腳本的輸出,而腳本的契約是「stdout 一行 JSON、stderr 乾淨」。
// 失敗時這些內容仍讀得到(error.stderr),錯誤訊息不會因此變模糊。
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
const detail = (error.stderr || error.message || '').toString().trim();
throw new ScriptError('GIT_FAILED', `git ${args.join(' ')} 失敗:${detail}`);
}
}
/**
* 開一個目標專案的 git repo,回傳綁在它身上的執行器。
*
* 碰目標專案 git 的腳本都從這裡進去:路徑不是 repo 時的錯誤碼要一致,
* 而「把 cwd 綁進 runGit」這件事寫第三遍就該收起來了。
*
* @param {string} path 目標專案的根目錄
* @returns {(...args: string[]) => string} 綁定 cwd 的 git 執行器
*/
export function openGitRepo(path) {
if (!existsSync(join(path, '.git'))) {
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
}
return (...args) => runGit(args, { cwd: path });
}
/**
* 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。
*
* 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方,
* 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。
*
* 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的
* 位置,所以別的 clone 也可能在同一條路徑上留下東西。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean,
* files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}}
*/
export function inspectWorktree(worktree) {
const 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] };
if (!existsSync(worktree)) return { ...空的, reason: 'missing' };
if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' };
const files = runGit(['status', '--porcelain'], { cwd: worktree })
.split('\n')
.filter((line) => line !== '')
// 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元——
// runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了,
// 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。
.map((line) => line.replace(/^\s*\S{1,2}\s+/, ''));
return {
path: worktree,
exists: true,
isWorktree: true,
dirty: files.length > 0,
files,
reason: files.length > 0 ? 'dirty' : 'removable',
};
}
/**
* 這條路徑是不是一棵「連結出去的」工作樹。
* 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄,
* 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。
*/
function linkedWorktree(worktree) {
try {
return statSync(join(worktree, '.git')).isFile();
} catch {
return false;
}
}
/**
* 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。
*
* **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事:
* 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因,
* 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。
*
* 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者
* 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
* reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上
*/
export function removeWorktree(worktree) {
const state = inspectWorktree(worktree);
if (state.reason !== 'removable') return { ...state, removed: false };
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
runGit(['worktree', 'remove', worktree], { cwd: worktree });
return { ...state, removed: true, reason: 'removed' };
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
* 依序檢查四層,任一層不通過即拋出帶碼的錯誤並指出該改哪裡。
* 前一層沒過就不往下打,避免把一個設定問題報成四個。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @returns {Promise<{repo: object, user: object}>} 通過時把查到的 repo 與帳號一起交回去,
* 省下呼叫端再查一次——第二層本來就已經問過「我是誰」了
*/
export async function preflight(login, repo) {
checkEnvironment();
const user = await checkLogin(login);
const info = await fetchRepo(login, repo);
await checkIssueWrite(login, repo, info);
checkTimeTracker(info);
return { repo: info, user };
}
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
function checkEnvironment() {
checkBinaries(['git', 'tea']);
checkPluginLayout();
}
/** 每個執行檔的安裝指引。訊息只提真的缺的那幾個,不要叫人去裝他已經有的東西。 */
const INSTALL_HINT = {
git: 'git 見 https://git-scm.com',
tea: 'tea 見 https://gitea.com/gitea/tea',
node: 'Node 見 https://nodejs.org',
};
/**
* 這些執行檔缺了哪些。本工具不自動安裝任何執行環境——在使用者的機器上裝東西
* 應該是他自己的決定,所以這裡只回報,由呼叫端決定要警告還是中止。
* @param {string[]} binaries
* @returns {{missing: string[], hint: string}} 都在時 missing 為空陣列
*/
export function missingBinaries(binaries) {
const missing = binaries.filter((binary) => onPath(binary) === null);
const hints = missing.map((binary) => INSTALL_HINT[binary]).filter(Boolean);
return {
missing,
hint: missing.length === 0
? ''
: `PATH 上找不到 ${missing.join('、')};請先安裝(${hints.join('、')}),本工具不會替你安裝`,
};
}
/**
* 要求這些執行檔都在 PATH 上,缺了就中止。
* 會真的去碰 Gitea 或 git 的路徑用它;只是寫檔案的路徑用 missingBinaries 警告就好。
* @param {string[]} binaries
*/
export function checkBinaries(binaries) {
const { missing, hint } = missingBinaries(binaries);
if (missing.length > 0) throw new ScriptError('ENV_MISSING', hint);
}
/** plugin 的四個正本目錄都在不在。裝壞了要在做事之前就講,不要跑到一半才找不到檔案。 */
export function checkPluginLayout() {
for (const dir of [promptsDir(), templatesDir(), referencesDir()]) {
if (!existsSync(dir)) {
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `plugin 目錄不完整,找不到 ${dir};請重新安裝 tea-sdlc`);
}
}
}
/**
* 在 PATH 上找一個執行檔,找到回完整路徑,找不到回 null。
* @param {string} binary
* @returns {string|null}
*/
export function onPath(binary) {
for (const dir of (process.env.PATH ?? '').split(':')) {
if (dir === '') continue;
try {
accessSync(join(dir, binary), constants.X_OK);
return join(dir, binary);
} catch {
// 這個目錄沒有,換下一個
}
}
return null;
}
/** 第二層:Gitea 登入是否有效。順手把帳號交出去,呼叫端常要知道「我是誰」。 */
async function checkLogin(login) {
const response = await giteaRequest(login, 'GET', '/user');
if (response.status === 401 || response.status === 403) {
throw new ScriptError(
'LOGIN_INVALID',
`Gitea 不接受目前的 token(HTTP ${response.status});請重新執行 tea login add 更新登入`,
);
}
return expectOk(response, 'GET /user');
}
async function fetchRepo(login, repo) {
const response = await giteaRequest(login, 'GET', `/repos/${repo}`);
if (response.status === 404) {
throw new ScriptError('REPO_NOT_FOUND', `找不到 repo ${repo},或目前的帳號沒有讀取權`);
}
return expectOk(response, `GET /repos/${repo}`);
}
/**
* 第三層:帳號對 issues unit 是否有寫入權。
* 必須實測:Gitea 的 team unit 權限可以獨立於 repo 的 push 權限,
* permissions.push 為真不代表 issues 寫得進去。
* 探針打在不存在的議題 index 0 —— 有寫入權會通過權限中介層後回 404,沒有則直接 403,
* 兩種結果都不會改動任何東西。
*/
async function checkIssueWrite(login, repo, info) {
if (info.has_issues === false) {
throw new ScriptError(
'ISSUES_UNIT_OFF',
`repo ${repo} 沒有啟用議題功能;請到 Settings → Advanced Settings 勾選 Issues`,
);
}
const probe = await giteaRequest(login, 'PATCH', `/repos/${repo}/issues/0`, { body: {} });
if (probe.status === 403) {
throw new ScriptError(
'NO_ISSUE_WRITE',
`目前的帳號對 ${repo} 的 issues unit 沒有寫入權;請到該 repo 的 Settings → Collaborators,` +
'或組織的 Teams → Units 把 Issues 設為 Write',
);
}
if (probe.status !== 404) {
expectOk(probe, `PATCH /repos/${repo}/issues/0`);
}
}
/** 第四層:repo 是否已開啟時間追蹤 */
function checkTimeTracker(info) {
if (info.internal_tracker?.enable_time_tracker !== true) {
throw new ScriptError(
'TIME_TRACKER_OFF',
'repo 尚未開啟時間追蹤,工時碼錶無法運作;請到 Settings → Advanced Settings → Enable Time Tracker 開啟',
);
}
}
/**
* 讀一個由 flag 指定的文字檔。
*
* 「讀一個 --xxx-file 或直接失敗」原本在四支腳本裡各寫一份,錯誤碼還有三種拼法。
* 同一種情況要有同一個碼,呼叫端才分辨得出到底是哪一步壞了。
*
* @param {string} path 檔案路徑
* @param {string} flag 出現在錯誤訊息裡的 flag 名,例如 '--body-file'
* @param {{allowEmpty?: boolean}} options 內容可不可以是空的;預設不可以
* @returns {string}
*/
export function readTextFile(path, flag, { allowEmpty = false } = {}) {
if (!existsSync(path)) {
throw new ScriptError('FILE_NOT_FOUND', `找不到 ${flag} 指定的檔案 ${path}`);
}
const content = readFileSync(path, 'utf8');
if (!allowEmpty && content.trim() === '') {
throw new ScriptError('FILE_EMPTY', `${flag} 指定的檔案 ${path} 是空的`);
}
return content;
}
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
/**
* 讀一顆議題。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,
* 後者是權限沒開,兩種的下一步完全不同。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @returns {Promise<object>}
*/
export async function fetchIssue(login, repo, index) {
const path = `/repos/${repo}/issues/${index}`;
const response = await giteaRequest(login, 'GET', path);
if (response.status === 404) {
throw new ScriptError('ISSUE_NOT_FOUND', `${repo} 沒有編號 ${index} 的議題`);
}
if (response.status === 403) {
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 議題 ${index} 的讀取權`);
}
return expectOk(response, `GET ${path}`);
}
/**
* 抽取腳本 `--dry-run` 的共同附註:留言的 reaction 要逐則查,事前列不出來。
* 與 countUnmergedComments 同進退——說明的是它發出的那些請求。
*/
export const UNMERGED_COMMENT_NOTE =
'每則留言還會各查一次 reaction,用來數出未整併的則數;則數取決於留言數,事前無法列舉。';
/**
* 數出尚未被整併回描述的留言則數。
*
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長,
* 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,
* 讀不完寧可報錯。
*
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過
* @returns {Promise<number>}
*/
export async function countUnmergedComments(login, repo, index, me) {
let unmerged = 0;
for await (const comment of listIssueComments(login, repo, index)) {
if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1;
}
return unmerged;
}
/**
* 逐頁走過一顆議題(或 PR)的一般留言。
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
* @returns {AsyncGenerator<object>} 一則一則交出去
*/
export async function* listIssueComments(login, repo, index) {
const path = `/repos/${repo}/issues/${index}/comments`;
for await (const comments of pages(login, path, {
limitCode: 'COMMENT_LIMIT',
limitHint: `${path} 的留言太多,讀不完整份清單`,
})) {
for (const comment of comments) yield comment;
}
}
/**
* 這一則是不是「我」標記過已整併。
*
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
* 別人按讚是「我同意」,當成已整併會讓那一則的決策永遠不被收進描述——
* 而那正是 /sdlc-sync 要解決的事。
*
* @param {string} me 目前登入帳號;preflight 的回傳帶得出來
*/
export async function mergedByMe(login, repo, id, me) {
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
}
// ── 碼錶 ───────────────────────────────────────────────────────────
/**
* 目前跑在自己身上的碼錶。
*
* Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」,
* 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。
* @param {{base: string, token: string}} login
* @returns {Promise<object[]>}
*/
export async function listStopwatches(login) {
const path = '/user/stopwatches';
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
}
/**
* 「你的錶正跑在別顆議題上」的擋路錯誤。領取與起錶都會撞到它,訊息只寫一份。
*
* 一定要明說停錶不會動到工作樹:使用者常以為停錶等於放棄那顆工作包,於是寧可不停,
* 而工時就記到別顆議題去了。碼錶只管時間,工作樹只管檔案,兩者互不相干。
* @param {object} watch listStopwatches 裡的一顆錶
* @param {string} 動作 擋在哪件事之前,例如「領取」「起錶」
*/
export function stopwatchElsewhere(watch, 動作) {
return new ScriptError(
'STOPWATCH_ON_OTHER_ISSUE',
`你的碼錶正跑在 ${watch.repo_owner_name}/${watch.repo_name} 的議題 ` +
`#${watch.issue_index} 上,${動作}前請先手動停錶,否則工時會記到那一顆去;` +
'停錶只停計時,不會動到任何既有的工作樹',
);
}
/**
* 這些碼錶裡,跑在指定議題上的那一顆。
* 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。
* @param {object[]} watches listStopwatches 的結果
* @param {string} repo owner/name
* @param {number} index
* @returns {object|null}
*/
export function stopwatchOnIssue(watches, repo, index) {
return (
watches.find(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
) ?? null
);
}
// ── 標籤 ───────────────────────────────────────────────────────────
/**
* 取得 repo 上的既有標籤。
* 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單,
* 挑不到合適的就不貼。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @returns {Promise<object[]>}
*/
export async function listLabels(login, repo) {
const path = `/repos/${repo}/labels`;
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
}
// ── 冪等查重 ───────────────────────────────────────────────────────
/**
* 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。
* 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {string} title 要找的標題
* @returns {Promise<object|null>} 找到的議題,或 null
*/
/**
* 逐頁走訪一個回傳陣列的 Gitea 端點。
*
* 所有「必須看完整份清單」的走訪都走這裡:讀不完就要大聲報錯,不能無聲回傳半份。
* 半份清單比報錯更危險——查重會漏掉既有議題而重建一顆,數留言會少算而讓下游
* 拿著過期描述做事。
* @param {{base: string, token: string}} login
* @param {string} path
* @param {{query?: object, pageSize?: number, maxPages?: number, limitCode?: string, limitHint?: string}} options
* @returns {AsyncGenerator<object[]>} 每次 yield 一頁
*/
export async function* pages(login, path, options = {}) {
const {
query = {},
pageSize = 50,
maxPages = 200,
limitCode = 'PAGE_LIMIT',
limitHint = `${path} 的資料量超出可走訪範圍`,
} = options;
for (let page = 1; page <= maxPages; page += 1) {
const response = await giteaRequest(login, 'GET', path, {
query: { ...query, limit: pageSize, page },
});
const items = expectOk(response, `GET ${path}`) ?? [];
yield items;
if (items.length < pageSize) return;
}
throw new ScriptError(limitCode, `${limitHint}(已讀 ${maxPages * pageSize} 筆仍未讀完)`);
}
/**
* 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。
* 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {string} title 要找的標題
* @returns {Promise<object|null>} 找到的議題,或 null
*/
export async function findIssueByTitle(login, repo, title) {
const wanted = title.trim();
for await (const issues of pages(login, `/repos/${repo}/issues`, {
query: { state: 'all' },
limitCode: 'DEDUPE_LIMIT',
limitHint: `翻不完 ${repo} 的議題,無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`,
})) {
const hit = issues.find((issue) => (issue.title ?? '').trim() === wanted);
if (hit) return hit;
}
return null;
}