feat: 移除計時並停用報表預覽

This commit is contained in:
2026-09-21 17:41:44 +08:00
parent 5a259a95af
commit 7f723adff8
34 changed files with 100 additions and 6113 deletions
+2 -41
View File
@@ -2,17 +2,9 @@
/**
* 領取一顆工作包:上鎖、貼標籤。
*
* 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),
* 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。
* 鎖用 assignee 加標籤,不用碼錶;碼錶與耗時統計已移除。
*
* **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js):
* 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。
*
* 四種狀態的處置:
* - 他人已認領 → 擋。不會兩個人做同一件事。
* - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。
* - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。
* - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。
* 他人已認領時擋下,自己已認領時可冪等重跑;所有判斷都在寫入前完成。
*
* 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。
* `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步,
@@ -27,15 +19,12 @@ import {
fetchIssue,
giteaRequest,
listLabels,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
/** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */
@@ -62,11 +51,7 @@ main(async () => {
const assignees = (issue.assignees ?? []).map((user) => user.login);
const labels = (issue.labels ?? []).map((label) => label.name);
// 所有會擋的判斷都做完才輪到寫入,試跑與實跑走同一條路——
// 試跑印得出漂亮的計畫、實跑卻被擋下來,那種落差最難查
checkClaimable(assignees, me, index);
await checkNoStopwatch(login, repo, index);
// 本 plugin 不建標籤,缺了就整件事不做,不要只設一半的鎖
const inProgress = (await listLabels(login, repo)).find((label) => label.name === IN_PROGRESS);
if (!inProgress) {
@@ -105,8 +90,6 @@ main(async () => {
url: issue.html_url,
assignee: me,
labels,
// 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動
碼錶中: false,
已認領過,
};
});
@@ -124,25 +107,3 @@ function checkClaimable(assignees, me, index) {
}
}
/**
* 自己的錶跑在任何議題上都擋,需要手動停錶後再領。
*
* 不代勞停錶:那一段時間該記在哪顆議題上只有人知道,腳本自作主張會把工時記錯地方。
* 錯誤碼分兩種,因為使用者的下一步不同——跑在本議題是「你已經在做了」,
* 跑在別的議題是「你忘了停掉那一顆」。
*/
async function checkNoStopwatch(login, repo, index) {
const watches = await listStopwatches(login);
if (watches.length === 0) return;
if (stopwatchOnIssue(watches, repo, index)) {
throw new ScriptError(
'STOPWATCH_ON_THIS_ISSUE',
`你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` +
'若要重新計時,請先在 Gitea 上手動停錶再執行一次——' +
'停錶只停計時,不會動到你既有的工作樹',
);
}
throw stopwatchElsewhere(watches[0], '領取');
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
/**
* Upload overview screenshots to a Gitea issue and update its attachment index.
* The manifest is produced by the preview step and contains local PNG paths and hashes.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
giteaUpload,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { upsertLineInSection } from './issue-body.js';
const INDEX_LINE = '圖解版總覽附件:';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'manifest'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8'));
const screenshots = manifest.screenshots;
if (!Array.isArray(screenshots) || screenshots.length === 0) {
throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots');
}
for (const item of screenshots) {
if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) {
throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在');
}
}
const login = resolveLogin({ host: flags.host });
const issuePath = `/repos/${repo}/issues/${index}`;
const assetsPath = `${issuePath}/assets`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: assetsPath },
...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })),
],
};
}
await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`);
const existing = Array.isArray(assets) ? assets : [];
const indexed = parseIndexedAssets(issue.body ?? '');
const uploaded = [];
for (const item of screenshots) {
const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256);
const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256);
const asset = indexedAsset ?? existingAsset;
if (asset) {
uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true });
continue;
}
const response = expectOk(
await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName),
`POST ${assetsPath}`,
);
uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false });
}
const body = updateIndex(issue.body ?? '', uploaded);
const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`);
return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url };
});
function parseIndexedAssets(body) {
const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? '';
return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({
role: match[1],
fileName: match[2],
sha256: match[3],
url: match[4],
}));
}
function updateIndex(body, assets) {
const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`);
return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`);
}
+1 -92
View File
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
/** 帶錯誤碼的失敗。呼叫端靠 code 分辨是哪一步壞了,訊息則要能指出去哪裡改。 */
export class ScriptError extends Error {
/**
* @param {string} code 可區分的錯誤碼,例如 TIME_TRACKER_OFF
* @param {string} code 可區分的錯誤碼,例如 REPORT_UNAVAILABLE
* @param {string} message 給人看的訊息,必要時附上「該改哪裡」
*/
constructor(code, message) {
@@ -776,7 +776,6 @@ export async function preflight(login, repo) {
const user = await checkLogin(login);
const info = await fetchRepo(login, repo);
await checkIssueWrite(login, repo, info);
checkTimeTracker(info);
return { repo: info, user };
}
@@ -896,15 +895,6 @@ async function checkIssueWrite(login, repo, info) {
}
}
/** 第四層: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 指定的文字檔。
@@ -1013,87 +1003,6 @@ export async function mergedByMe(login, repo, id, me) {
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
);
}
/**
* 「這顆議題上沒有碼錶在跑」的回法不只一種:看過 500,也看過 409。
* 狀態碼隨站台版本而異,所以認的是「狀態碼在這一組裡 **且** 訊息說的是碼錶」——
* 只看訊息會把真的伺服器錯誤一起吞掉,只看狀態碼會把別的衝突也當成沒錶。
*/
const NO_STOPWATCH_STATUS = [409, 500];
/**
* 停錶。停在指定議題上,只停那一顆——端點本身就是議題範圍的,
* 停錶不會波及別顆議題上的錶,那正是領取鎖那條規則要守住的事。
*
* 錶沒在跑不算失敗:停錶多半排在別的事情做完之後(開完 PR、回報完),
* 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。
*
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @returns {Promise<boolean>} 這次真的停了一支錶才是 true
*/
export async function stopStopwatch(login, repo, index) {
const path = `/repos/${repo}/issues/${index}/stopwatch/stop`;
const response = await giteaRequest(login, 'POST', path, { body: {} });
if (response.status >= 200 && response.status < 300) return true;
if (
NO_STOPWATCH_STATUS.includes(response.status) &&
/stopwatch/i.test(response.body?.message ?? '')
) {
return false;
}
expectOk(response, `POST ${path}`);
return false;
}
// ── 標籤 ───────────────────────────────────────────────────────────
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env node
/**
* Capture an overview using an available non-managed backend.
* Managed browser preview remains the preferred platform-level path; this CLI handles
* Firefox and deterministic SVG rasterization when those binaries are installed.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['output'],
optional: ['url', 'svg', 'backend', 'width', 'height'],
});
const output = resolve(flags.output);
mkdirSync(dirname(output), { recursive: true });
const backend = chooseBackend(flags.backend, flags.url, flags.svg);
if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height);
else captureSvg(flags.svg, output, flags.width, flags.height);
return { backend, output };
});
function chooseBackend(requested, url, svg) {
if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') {
throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`);
}
if (requested === 'firefox') {
requireBinary('firefox');
if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url');
return 'firefox';
}
if (requested === 'svg') {
requireSvgInput(svg);
requireAnyBinary(['rsvg-convert', 'resvg']);
return 'svg';
}
if (url && hasBinary('firefox')) return 'firefox';
if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg';
throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg');
}
function captureFirefox(url, output, width, height) {
const args = ['--headless'];
if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`);
args.push('--screenshot', output, url);
const result = spawnSync('firefox', args, { encoding: 'utf8' });
if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`);
}
function captureSvg(svg, output, width, height) {
requireSvgInput(svg);
if (hasBinary('rsvg-convert')) {
const args = [`--output=${output}`];
if (width) args.push(`--width=${width}`);
if (height) args.push(`--height=${height}`);
args.push(resolve(svg));
const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`);
} else {
const args = [];
if (width) args.push('-w', String(width));
args.push(resolve(svg), output);
const result = spawnSync('resvg', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`);
}
if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案');
}
function requireSvgInput(svg) {
if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg');
}
function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); }
function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); }
function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } }
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env node
/**
* Validate an overview artifact JSON document and render a self-contained HTML file.
* The renderer never calls Gitea; callers provide a JSON snapshot from the extractors.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
const SCHEMA_VERSION = 1;
const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']);
const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']);
export function validateOverview(document) {
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件');
if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`);
if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) {
fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index');
}
if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement');
if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列');
for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position);
validateDiagrams(document.requirement.diagrams, 'requirement');
return document;
}
function validateWorkPackage(value, position) {
if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`);
for (const key of ['title', 'description', 'scope', 'issue']) {
if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`);
}
if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`);
for (const key of ['repos', 'depends', 'todos', 'acceptance']) {
if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`);
}
if (value.interfaces !== undefined) {
if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`);
for (const contract of value.interfaces) validateInterface(contract, position);
}
validateDiagrams(value.diagrams, `workPackages[${position}]`);
if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`);
}
function validateInterface(contract, position) {
const kinds = {
http: ['method', 'path', 'request', 'response', 'errors', 'example'],
cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'],
function: ['signature', 'input', 'output', 'errors', 'example'],
event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'],
storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'],
};
if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`);
for (const key of kinds[contract.interfaceType]) {
if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`);
}
}
function validateDiagrams(diagrams, owner) {
if (diagrams === undefined) return;
if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`);
for (const diagram of diagrams) {
if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`);
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`);
const ids = new Set(diagram.nodes.map((node) => node.id));
for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`);
}
}
export function renderOverview(document) {
validateOverview(document);
const requirement = document.requirement;
const packages = document.workPackages;
const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(requirement.title ?? '需求總覽')}</title><style>${styles()}</style></head><body><main><header><h1>${escapeHtml(requirement.title ?? '需求總覽')}</h1><p class="meta">來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}</p></header><p class="lede">${escapeHtml(requirement.summary ?? '')}</p>${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}<section id="work-packages"><h2>工作包</h2>${packages.map(renderWorkPackage).join('')}</section>${renderFormulas(requirement.formulas)}<footer>schemaVersion ${document.schemaVersion} · 產生時間 ${escapeHtml(document.overview?.generatedAt ?? '')}</footer></main></body></html>`;
return html;
}
export function renderFallbackSvg(document) {
validateOverview(document);
const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const height = 180 + diagrams.length * 300;
const diagramMarkup = diagrams.map((diagram, index) => `<g transform="translate(40 ${150 + index * 300})">${renderSvgContents(diagram)}</g>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${height}" viewBox="0 0 1200 ${height}"><rect width="100%" height="100%" fill="#ffffff"/><text x="40" y="55" font-size="30" font-family="sans-serif">${escapeHtml(document.requirement.title ?? '需求總覽')}</text><text x="40" y="95" font-size="18" font-family="sans-serif">${escapeHtml(document.requirement.summary ?? '')}</text>${diagramMarkup}</svg>`;
}
function renderWorkPackage(workPackage, index) {
const slug = slugify(workPackage.title) || `work-package-${index + 1}`;
const done = workPackage.todos.filter((todo) => todo.done).length;
return `<article id="work-package-${slug}"><h3>${escapeHtml(workPackage.title)}</h3><p>${escapeHtml(workPackage.description)}</p><dl><dt>類型</dt><dd>${escapeHtml(workPackage.type)}</dd><dt>進度</dt><dd>${done}/${workPackage.todos.length} 項待辦</dd><dt>依賴</dt><dd>${escapeHtml(workPackage.depends.join(', ') || '無')}</dd><dt>repo</dt><dd>${escapeHtml(workPackage.repos.join(', ') || '無')}</dd></dl><p><a href="${escapeAttr(workPackage.issue)}">查看工作包詳細內容</a></p></article>`;
}
function renderDiagrams(diagrams) {
return diagrams.map((diagram, index) => `<section id="diagram-${index + 1}"><h2>${escapeHtml(diagram.title ?? diagram.kind)}</h2><div class="diagram">${renderSvg(diagram)}</div>${diagram.omitted ? `<p class="omitted">未產生:${escapeHtml(diagram.omitted)}</p>` : ''}</section>`).join('');
}
function renderSvg(diagram) {
const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1)));
const width = Math.max(640, columns * 220);
const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80);
const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }]));
const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `<line x1="${from.x + 150}" y1="${from.y + 24}" x2="${to.x}" y2="${to.y + 24}" marker-end="url(#arrow)"/><text x="${(from.x + to.x + 150) / 2}" y="${(from.y + to.y) / 2 + 18}">${escapeHtml(edge.label ?? '')}</text>` : ''; }).join('');
const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `<g><rect x="${point.x}" y="${point.y}" width="150" height="48" rx="8"/><text x="${point.x + 75}" y="${point.y + 29}">${escapeHtml(node.label ?? node.id)}</text></g>`; }).join('');
return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(diagram.title ?? diagram.kind)}"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>${edges}${nodes}</svg>`;
}
function renderSvgContents(diagram) {
return renderSvg(diagram).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
}
function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `<section id="${id}"><h2>${title}</h2><ul>${values.map((value) => `<li>${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}</li>`).join('')}</ul></section>`; }
function renderFormulas(formulas) {
if (!Array.isArray(formulas) || formulas.length === 0) return '';
return `<section id="formulas"><h2>公式</h2>${formulas.map((formula) => {
const latex = String(formula.latex ?? '');
const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex);
return `<article class="formula"><h3>${escapeHtml(formula.title ?? '公式')}</h3><p>${escapeHtml(formula.description ?? '')}</p>${supported ? `<svg class="formula-svg" viewBox="0 0 700 60" role="img" aria-label="${escapeAttr(latex)}"><text x="12" y="38">${escapeHtml(latex)}</text></svg>` : `<p class="omitted">公式未渲染,原文如下:</p><pre>${escapeHtml(latex)}</pre>`}</article>`;
}).join('')}</section>`;
}
function styles() { return `:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--surface:#f6f8fa;--accent:#0969da}*{box-sizing:border-box}body{margin:0;padding:48px 16px 96px;background:var(--bg);color:var(--fg);font:16px/1.7 -apple-system,"Noto Sans TC","Microsoft JhengHei",sans-serif}main{max-width:980px;margin:0 auto}header{border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:40px}h1{font-size:30px;line-height:1.3}h2{font-size:15px;letter-spacing:.08em;color:var(--muted);margin:32px 0 16px}h3{line-height:1.4}.meta,.omitted{color:var(--muted)}.lede{font-size:21px;padding:20px 24px;background:var(--surface);border-left:3px solid var(--accent)}ul{padding-left:24px}.diagram{padding:20px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:auto}.diagram svg{display:block;min-width:620px;height:auto}.diagram rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.diagram text{fill:var(--fg);font-size:14px;text-anchor:middle}.diagram line{stroke:var(--accent);stroke-width:2}.diagram marker path{fill:var(--accent)}article{border-top:1px solid var(--line);padding:16px 0}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;color:var(--muted)}dt{font-weight:600}dd{margin:0}a{color:var(--accent)}pre{white-space:pre-wrap;background:var(--surface);padding:12px;border-radius:6px}footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--surface:#151b23;--accent:#4493f8}}`; }
function slugify(value) { return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;'); }
const escapeAttr = escapeHtml;
function fail(code, message) { throw new ScriptError(code, message); }
if (process.argv[1] === new URL(import.meta.url).pathname) {
main(async () => {
const flags = parseFlags(process.argv.slice(2), { required: ['input', 'output'], optional: ['manifest', 'svg'] });
const inputPath = resolve(flags.input);
const outputPath = resolve(flags.output);
const document = JSON.parse(readFileSync(inputPath, 'utf8'));
validateOverview(document);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, renderOverview(document));
const result = { schemaVersion: SCHEMA_VERSION, html: outputPath, sha256: createHash('sha256').update(readFileSync(outputPath)).digest('hex') };
if (flags.svg) {
const svgPath = resolve(flags.svg);
mkdirSync(dirname(svgPath), { recursive: true });
writeFileSync(svgPath, renderFallbackSvg(document));
result.svg = svgPath;
}
if (flags.manifest) { mkdirSync(dirname(resolve(flags.manifest)), { recursive: true }); writeFileSync(resolve(flags.manifest), `${JSON.stringify({ ...result, screenshots: [] }, null, 2)}\n`); }
return result;
});
}
Regular → Executable
+18 -191
View File
@@ -1,80 +1,8 @@
#!/usr/bin/env node
/**
* 開立 PR,然後停錶。
*
* 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。
*
* 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序
* 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。
*
* 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」
* 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。
*
* 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段
* 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。
*
* **錶停在議題所在的 repo,不是 PR 所在的 repo。** 工作包議題與目標專案常常不是同一個
* repo(議題在需求的 repo,程式碼在 `repos` 列的那些),拿 PR 的 repo 去停錶,停到的是
* 別人的議題,而自己的錶還在跑。預設兩者相同,不同時用 `--issue-repo` 指出來。
*
* 重跑不會開出第二顆 PR:先查同一個 head 有沒有開著的 PR,有就回傳它並把 `created`
* 設為 `false`,然後照樣停錶——那一步可能正是上次中斷的地方。
*
* 用法:
* node scripts/pr-create.js --repo owner/name --head <分支> --base <分支>
* --body-file <描述檔> --index 13
* [--issue-repo owner/name] [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
giteaRequest,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
readTextFile,
resolveLogin,
stopStopwatch,
} from './lib.js';
import { ScriptError, expectOk, giteaRequest, main, parseFlags, parseIndex, parseRepo, preflight, readTextFile, resolveLogin } from './lib.js';
/** 描述的固定段落,順序即 reviewer 閱讀的順序 */
const SECTIONS = [
'摘要',
'需求議題',
'工作包議題',
'變更內容',
'設計重點',
'解決的問題',
'影響的功能',
'測試結果',
];
/**
* 「測試結果」裡等於沒寫的那幾句。
* 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。
*/
const EMPTY_TALK = new Set([
'無',
'沒有',
'N/A',
'n/a',
'已測試',
'已測試通過',
'測試通過',
'測試皆通過',
'測試皆已通過',
'全部通過',
'全數通過',
'皆通過',
'無異常',
'沒有問題',
'一切正常',
'正常',
'ok',
'OK',
]);
const SECTIONS = ['摘要', '需求議題', '工作包議題', '變更內容', '設計重點', '解決的問題', '影響的功能', '測試結果'];
const EMPTY_TALK = new Set(['無', '沒有', 'N/A', 'n/a', '已測試', '已測試通過', '測試通過', '正常', 'ok', 'OK']);
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
@@ -83,132 +11,31 @@ main(async () => {
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
// 議題預設與 PR 同一個 repo;跨 repo 的工作包要用 --issue-repo 指出來
const issueRepo = parseRepo(flags['issue-repo'] ?? flags.repo);
const head = flags.head;
const base = flags.base;
const index = parseIndex(flags.index);
const body = readTextFile(flags['body-file'], '--body-file');
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
checkSections(body);
checkTestResult(body);
const pullsPath = `/repos/${repo}/pulls`;
const stopPath = `/repos/${issueRepo}/issues/${index}/stopwatch/stop`;
const payload = { title: head, head, base, body };
// 試跑也把登入解出來:沒跑過 tea login 的話,這一步就會說出來,不必等到實跑
const payload = { title: flags.head, head: flags.head, base: flags.base, body };
const login = resolveLogin({ host: flags.host });
if (flags['dry-run']) {
return {
dryRun: true,
repo,
issueRepo,
index,
head,
base,
title: head,
requests: [
{ method: 'POST', path: pullsPath, body: payload },
{ method: 'POST', path: stopPath, body: {} },
],
};
}
if (flags['dry-run']) return { dryRun: true, repo, issueRepo, index, head: flags.head, base: flags.base, title: flags.head, requests: [{ method: 'POST', path: pullsPath, body: payload }] };
await preflight(login, repo);
// 冪等:同一個 head 已經有開著的 PR 就用它,重跑不會開出第二顆
const existing = await findOpenPull(login, repo, head);
const pull = existing ?? expectOk(
await giteaRequest(login, 'POST', pullsPath, { body: payload }),
`POST ${pullsPath}`,
);
// 錶只在 PR 確實存在之後才停。既有的 PR 也要停——那一步可能正是上次中斷的地方。
const stopped = await stopStopwatch(login, issueRepo, index);
return {
repo,
issueRepo,
index,
created: existing === null,
title: pull.title,
url: pull.html_url,
number: pull.number,
head,
base,
碼錶已停: stopped,
...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }),
};
const existing = await findOpenPull(login, repo, flags.head);
const pull = existing ?? expectOk(await giteaRequest(login, 'POST', pullsPath, { body: payload }), `POST ${pullsPath}`);
return { repo, issueRepo, index, created: existing === null, title: pull.title, url: pull.html_url, number: pull.number, head: flags.head, base: flags.base };
});
/**
* 找同一個 head 上開著的 PR。
* 重跑時 Gitea 會對重複的 PR 回 422,而那個錯誤看不出「其實已經開好了」——
* 先查一次,重跑就是安靜地接上。
*/
function checkSections(body) {
const headings = [...body.matchAll(/^## (.+)$/gm)].map((match) => match[1].trim());
if (headings.length !== SECTIONS.length || headings.some((heading, i) => heading !== SECTIONS[i])) throw new ScriptError('BAD_PR_BODY', `PR 描述必須依序包含:${SECTIONS.join('、')}`);
}
function checkTestResult(body) {
const match = body.match(/^## 測試結果\s*\n([\s\S]*?)(?=^## |$)/m);
if (!match || EMPTY_TALK.has(match[1].trim())) throw new ScriptError('BAD_PR_TEST_RESULT', '測試結果必須填入實際執行的命令與輸出');
}
async function findOpenPull(login, repo, head) {
const path = `/repos/${repo}/pulls`;
const pulls = expectOk(
await giteaRequest(login, 'GET', path, { query: { state: 'open' } }),
`GET ${path}`,
) ?? [];
return pulls.find((pull) => pull.head?.ref === head) ?? null;
}
/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */
function checkSections(body) {
const found = [...body.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]);
const missing = SECTIONS.filter((section) => !found.includes(section));
if (missing.length > 0) {
throw new ScriptError(
'MISSING_SECTION',
`PR 描述缺少這幾段:${missing.join('、')};` +
`固定的段落順序為 ${SECTIONS.join('/')},reviewer 每次都在同一個位置找同一件事`,
);
}
const order = found.filter((section) => SECTIONS.includes(section));
if (order.join('\n') !== SECTIONS.join('\n')) {
throw new ScriptError(
'SECTION_ORDER',
`PR 描述的段落順序不對:收到的是 ${order.join('/')},應為 ${SECTIONS.join('/')}`,
);
}
}
/**
* 「測試結果」不能是空話。
* 判斷很窄——整段的每一行都是已知的偷懶寫法才算。窄是刻意的:
* 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好,
* 所以只要混進了一行真的輸出就放行。
*/
function checkTestResult(body) {
const lines = body.split('\n');
// 找行首的那個標題,而不是 indexOf:描述裡引用到「## 測試結果」這幾個字是常有的事
const start = lines.findIndex((line) => /^##\s+測試結果\s*$/.test(line));
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => /^##\s+/.test(line));
const content = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim();
const written = content.split('\n').filter((line) => line.trim() !== '');
// 每一行都是空話才算空話:混了實際輸出就放行,這一關不評價測試寫得好不好
const allEmptyTalk =
written.length > 0 &&
written.every((line) => EMPTY_TALK.has(line.trim().replace(/[。..]$/, '')));
if (content === '' || allEmptyTalk) {
throw new ScriptError(
'EMPTY_TEST_RESULT',
'「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' +
'手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫',
);
}
const pulls = expectOk(await giteaRequest(login, 'GET', path, { query: { state: 'open' } }), `GET ${path}`) ?? [];
return pulls.find((pull) => pull.head?.label === head || pull.head?.ref === head) ?? null;
}
Regular → Executable
+8 -374
View File
@@ -1,384 +1,18 @@
#!/usr/bin/env node
/**
* 產出工時報表:本週、指定月份或指定年份。
*
* 只印在終端,不對任何管道張貼——給誰看是使用者的決定,不是這支腳本的。
* 這支腳本自己只讀不寫;唯一的非 GET 是四層前置檢查裡那支探測寫入權的 PATCH
* (打在不存在的議題 0 上,不會改動任何東西),那是全專案共用的前置檢查,不是報表在寫東西。
*
* 期間怎麼切是這支腳本唯一的難處,規則固定成三句話:
* 一週為週一至週日;跨月的那一週依「該週週五所屬月份」歸屬;
* W1–W5 指該週五是當月第幾個週五。
* 週五當錨點的好處是一筆工時只會落在一個月裡,跨月週不會被兩邊各算一次。
*
* 工時來源是 `/user/times`——它永遠只回傳自己的工時,不必有 issue manager 權限,
* 也就不會把別人的工時混進自己的報表。repo 的篩選因此在本地做。
*
* 估算讀的是議題「關聯」段落裡的「估算人天」那一行,不是 Gitea 的 time_estimate 欄位:
* 該欄位的 API 寫不進去(見 issue-update 的說明),議題上唯一可信的估算就是那一行。
*
* 用法:
* node scripts/report.js --repo owner/name
* [--week | --month YYYY-MM | --year YYYY] [--today YYYY-MM-DD]
* [--day-hours 8] [--host <網址>] [--dry-run]
* 週報、月報、年報目前停用。
* 時間追蹤與耗時統計已移除,不能再產生可信的工時報表。
*/
import {
ScriptError,
fetchIssue,
main,
pages,
parseFlags,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { labelledNumber, parseSections } from './issue-body.js';
import { ScriptError, main, parseFlags, parseRepo } from './lib.js';
/** 一人天預設幾小時。跳不跳假日是團隊政策,這裡只給一個可被 --day-hours 換掉的預設。 */
const DEFAULT_DAY_HOURS = 8;
const TIMES_PATH = '/user/times';
const REPORT_UNAVAILABLE = '週報、月報、年報目前不可用;時間追蹤功能已移除。';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo'],
optional: ['month', 'year', 'today', 'day-hours', 'host'],
booleans: ['week', 'dry-run'],
optional: ['month', 'year', 'today', 'host'],
booleans: ['week'],
});
const repo = parseRepo(flags.repo);
const dayHours = parseDayHours(flags['day-hours']);
const period = resolvePeriod(flags);
if (flags['dry-run']) {
return {
dryRun: true,
repo,
期間: publicPeriod(period),
requests: [{ method: 'GET', path: TIMES_PATH }],
};
}
const login = resolveLogin({ host: flags.host });
await preflight(login, repo);
const entries = await fetchTimes(login, period);
const bodies = await fetchMissingBodies(login, repo, period, entries);
return summarise({ repo, period, dayHours, entries, bodies });
parseRepo(flags.repo);
throw new ScriptError('REPORT_UNAVAILABLE', REPORT_UNAVAILABLE);
});
// ── 期間 ───────────────────────────────────────────────────────────
/**
* 把三個互斥的期間 flag 收斂成一段日期範圍與它的分段。
* @returns {{類型: string, 標籤: string, 起: string, 迄: string, 分段: {名稱: string, 起: string, 迄: string}[]}}
*/
function resolvePeriod(flags) {
const chosen = ['week', 'month', 'year'].filter((name) => flags[name] !== undefined);
if (chosen.length > 1) {
throw new ScriptError(
'PERIOD_CONFLICT',
`--week、--month、--year 三選一,收到的是 ${chosen.map((n) => `--${n}`).join(' 與 ')}`,
);
}
// --today 只決定「本週」是哪一週,對月報年報毫無作用。默默忽略一個使用者明確給的值,
// 會讓他以為報表切在別的地方;寧可擋下來。
if (flags.today !== undefined && (flags.month !== undefined || flags.year !== undefined)) {
throw new ScriptError('PERIOD_CONFLICT', '--today 只搭配 --week 使用,月報與年份報表用不到它');
}
if (flags.month !== undefined) return monthPeriod(parseMonth(flags.month));
if (flags.year !== undefined) return yearPeriod(parseYear(flags.year));
return weekPeriod(parseToday(flags.today));
}
/** 本週:本週一至今日。還沒發生的日子不該出現在報表的期間裡。 */
function weekPeriod(today) {
const start = mondayOf(today);
return { 類型: 'week', 標籤: `${start} ~ ${today}`, 起: start, 迄: today, 分段: [] };
}
/** 月報:以當月的每個週五各拉出一週,週一至週日 */
function monthPeriod(month) {
const weeks = fridaysIn(month).map((friday, i) => ({
名稱: `W${i + 1}`,
起: addDays(friday, -4),
迄: addDays(friday, 2),
}));
return { 類型: 'month', 標籤: month, 起: weeks[0].起, 迄: weeks.at(-1).迄, 分段: weeks };
}
/** 年報:十二個月各自套月報的切法,分段小計到月為止 */
function yearPeriod(year) {
const months = Array.from({ length: 12 }, (_, i) => {
const month = `${year}-${String(i + 1).padStart(2, '0')}`;
const { 起, 迄 } = monthPeriod(month);
return { 名稱: month, 起, 迄 };
});
return { 類型: 'year', 標籤: String(year), 起: months[0].起, 迄: months.at(-1).迄, 分段: months };
}
/** 當月的所有週五,由早到晚 */
function fridaysIn(month) {
const [year, index] = month.split('-').map(Number);
const fridays = [];
for (let day = 1; day <= 31; day += 1) {
const date = new Date(Date.UTC(year, index - 1, day));
if (date.getUTCMonth() !== index - 1) break;
if (date.getUTCDay() === 5) fridays.push(iso(date));
}
return fridays;
}
// ── 期間參數的把關 ─────────────────────────────────────────────────
function parseMonth(value) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--month 需為 YYYY-MM,收到的是 ${value}`);
}
return value;
}
function parseYear(value) {
if (!/^\d{4}$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--year 需為四位數年份,收到的是 ${value}`);
}
return Number(value);
}
/** 沒給就取系統日期的「今天」。給了就以它為準,讓報表能回頭補印過去的某一週。 */
function parseToday(value) {
if (value === undefined) return localDate(new Date());
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || iso(new Date(`${value}T00:00:00Z`)) !== value) {
throw new ScriptError('BAD_PERIOD', `--today 需為真實存在的 YYYY-MM-DD,收到的是 ${value}`);
}
return value;
}
function parseDayHours(value) {
if (value === undefined) return DEFAULT_DAY_HOURS;
const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) {
throw new ScriptError('BAD_DAY_HOURS', `--day-hours 需為正數,收到的是 ${value}`);
}
return hours;
}
// ── 日期算術 ───────────────────────────────────────────────────────
//
// 一律以 YYYY-MM-DD 字串進出、以 UTC 的 Date 當中間格式:日曆上的「哪一天」
// 不該被本機時區的日光節約搬動。時區只在一個地方出現——把工時的時刻換算成
// 「使用者那天」的 localDate。
function iso(date) {
return date.toISOString().slice(0, 10);
}
function addDays(date, days) {
const moment = new Date(`${date}T00:00:00Z`);
moment.setUTCDate(moment.getUTCDate() + days);
return iso(moment);
}
/** 該日期所屬那一週的週一。週界以週一切,週日屬於前面那一週。 */
function mondayOf(date) {
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
return addDays(date, -((weekday + 6) % 7));
}
/** 時刻 → 使用者在的時區裡的那一天。週界是以人在的時區切的,不是 UTC。 */
function localDate(moment) {
const year = moment.getFullYear();
const month = String(moment.getMonth() + 1).padStart(2, '0');
const day = String(moment.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** 某一天的本地零時,轉成 Gitea 要的 RFC 3339 */
function startOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString();
}
/** 某一天的本地尾聲,轉成 Gitea 要的 RFC 3339 */
function endOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString();
}
// ── 取工時 ─────────────────────────────────────────────────────────
/**
* 取回期間內、屬於自己的所有工時。
* since/before 只是先讓伺服器砍掉大半;真正的期間判斷仍在本地做,
* 因為期間是以使用者的時區切的,而伺服器不知道使用者在哪個時區。
*/
async function fetchTimes(login, period) {
const entries = [];
for await (const page of pages(login, TIMES_PATH, {
query: { since: startOfDay(period.起), before: endOfDay(period.迄) },
limitCode: 'TIME_LIMIT',
limitHint: `${TIMES_PATH} 的工時筆數超出可走訪範圍,這份報表會是不完整的`,
})) {
entries.push(...page);
}
return entries;
}
/**
* 補齊估算讀不到的議題 body。
*
* 估算只存在於議題 body 的那一行,而 `/user/times` 內嵌的議題不保證帶 body——
* 少了它,整份報表的估算與落差會靜靜地全變成 null,而報表仍然回報成功。
* 因此缺 body 的議題各補一次 GET:筆數是「這段期間碰過的議題數」,不是工時筆數。
*/
async function fetchMissingBodies(login, repo, period, entries) {
const missing = new Set();
for (const entry of entries) {
const issue = entry.issue;
if (!issue || issue.number === undefined) continue;
if (issue.repository?.full_name !== repo || issue.body !== undefined) continue;
const date = localDate(new Date(entry.created));
if (date >= period.起 && date <= period.迄) missing.add(issue.number);
}
const bodies = new Map();
for (const index of missing) {
bodies.set(index, (await fetchIssue(login, repo, index)).body ?? '');
}
return bodies;
}
// ── 彙總 ───────────────────────────────────────────────────────────
function summarise({ repo, period, dayHours, entries, bodies }) {
const { total, skipped, bySegment, byIssue } = collect({ repo, period, entries, bodies });
// 工時多的排前面:週會上先講的是吃掉最多時間的那一顆
const issues = [...byIssue.values()]
.sort((a, b) => b.實際秒 - a.實際秒 || a.index - b.index)
.map((issue) => publicIssue(issue, dayHours));
const estimated = issues.filter((issue) => issue.估算人天 !== null);
const estimatedDays = estimated.reduce((sum, issue) => sum + issue.估算人天, 0);
const estimatedActual = estimated.reduce((sum, issue) => sum + issue.實際秒, 0);
// 總計的落差只拿「有估算的那些議題」的實際去比。拿全部實際去比只有部分議題的估算,
// 會讓沒估算的工時全部變成「超出估算」,落差就永遠是灌水的正數。
const gap = estimated.length === 0 ? null : gapSeconds(estimatedActual, estimatedDays, dayHours);
return {
repo,
期間: publicPeriod(period),
每日工時: dayHours,
總計: {
實際秒: total,
實際工時: formatHours(total),
估算人天: estimatedDays,
已估實際秒: estimatedActual,
已估實際工時: formatHours(estimatedActual),
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
},
分段: period.分段.map((segment) => ({
名稱: segment.名稱,
起: segment.起,
迄: segment.迄,
實際秒: bySegment.get(segment.名稱),
實際工時: formatHours(bySegment.get(segment.名稱)),
})),
議題: issues,
略過: skipped,
};
}
/**
* 把工時逐筆歸到週次與議題底下。
* 期間外的、別的 repo 的都在這裡被濾掉;查不到議題資訊的則被數起來——
* 它們不歸到任何數字,但也不能無聲消失。
*/
function collect({ repo, period, entries, bodies }) {
const bySegment = new Map(period.分段.map((segment) => [segment.名稱, 0]));
const byIssue = new Map();
let skipped = 0;
let total = 0;
for (const entry of entries) {
const date = localDate(new Date(entry.created));
if (date < period.起 || date > period.迄) continue;
const issue = entry.issue;
if (!issue?.repository?.full_name || issue.number === undefined) {
skipped += 1;
continue;
}
if (issue.repository.full_name !== repo) continue;
const seconds = Number(entry.time) || 0;
total += seconds;
const segment = period.分段.find((s) => date >= s.起 && date <= s.迄);
if (segment) bySegment.set(segment.名稱, bySegment.get(segment.名稱) + seconds);
const known = byIssue.get(issue.number);
if (known) {
known.實際秒 += seconds;
} else {
byIssue.set(issue.number, {
index: issue.number,
title: issue.title ?? '',
url: issue.html_url ?? '',
實際秒: seconds,
估算人天: estimateDays(issue.body ?? bodies.get(issue.number)),
});
}
}
return { total, skipped, bySegment, byIssue };
}
/** 期間的對外形狀不含分段定義——分段的數字在 data.分段 裡,不必重複一份 */
function publicPeriod(period) {
return { 類型: period.類型, 標籤: period.標籤, 起: period.起, 迄: period.迄 };
}
/** 落差 = 實際 − 估算。正數是超出估算,負數是還有餘裕。 */
function gapSeconds(actualSeconds, days, dayHours) {
return actualSeconds - days * dayHours * 3600;
}
/** 逐議題那一列的對外形狀。沒有估算就沒有落差,填 null 而非零:零會被讀成「剛好準」。 */
function publicIssue(issue, dayHours) {
const gap = issue.估算人天 === null ? null : gapSeconds(issue.實際秒, issue.估算人天, dayHours);
return {
index: issue.index,
title: issue.title,
url: issue.url,
實際秒: issue.實際秒,
實際工時: formatHours(issue.實際秒),
估算人天: issue.估算人天,
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
};
}
/**
* 從議題 body 讀出估算人天。
* 只認「關聯」段落裡的那一行——那是 issue-update 唯一寫得進去的位置,
* 其他地方出現的數字(例如描述裡順手提到的「大概三天」)不算數。
* @returns {number|null} 沒寫估算時為 null
*/
function estimateDays(body) {
return labelledNumber(parseSections(body ?? ''), '關聯', '估算人天');
}
/** 秒 → 「3h 30m」。秒數不進位成分鐘,免得湊出假的精確。 */
function formatHours(seconds) {
const minutes = Math.floor(Math.abs(seconds) / 60);
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/** 落差要一眼看出方向:超出估算帶 +,還有餘裕帶 − */
function formatGap(seconds) {
if (seconds === 0) return formatHours(0);
return `${seconds > 0 ? '+' : '-'}${formatHours(seconds)}`;
}
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env node
/**
* 補登一段沒有錶記到的工時。
*
* 規劃階段最耗時的那一段——讀齊輸入、逐項詢問、組出議題內容——發生在議題建立**之前**,
* 那時候沒有標的可起錶(議題還不存在)。這段時間只能事後補登,否則報表上的規劃永遠是零,
* 久了會讓人以為規劃不花時間,而那正是估算失準最常見的來源。
*
* **長度由這支腳本自己算**:終點減掉 `--since`。交給 agent 做減法,等於讓兩邊的時鐘與
* 時區各算一次,而算錯了報表上看不出來。
*
* 終點取哪一刻,看議題是不是這一輪建立的:
* - 議題建立於 `--since` 之後 → 終點是**議題的建立時間**。這是第一次跑,補的正是
* 「指令開始到議題建立」那一段。
* - 議題比 `--since` 還早 → 終點是**補登的當下**。這是對既有議題重跑,那一輪的規劃
* 時間照樣要進報表;拿舊的建立時間當終點會算出負數,等於把這一輪的工夫丟掉。
*
* **不設時間上限,照實補登。** 中途去開會的那兩個小時會一起被算進去——換來這個流程
* 不必為此多長一題出來問使用者。時間記多了看得出來,記不到就永遠找不回來。
*
* **重跑會累計,不會覆蓋**:每一輪各記一筆,報表上加總起來才是這顆議題真正花掉的規劃
* 時間。唯一跳過的情形是自己的錶已經跑在這顆議題上——補登排在起錶之前,錶在跑就代表
* 這一輪已經走到起錶那一步了,再補一次會與錶涵蓋的區間重疊。
*
* 只寫工時,不動任何錶——別顆議題上有錶在跑也照補,那兩件事互不相干。
*
* 用法:
* node scripts/time-log.js --repo owner/name --index 42 --since <ISO 8601 時間>
* [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchOnIssue,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'since'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const since = parseSince(flags.since);
const dryRun = flags['dry-run'] === true;
const timesPath = `/repos/${repo}/issues/${index}/times`;
const login = resolveLogin({ host: flags.host });
// 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「錶已經在跑了」
if (!dryRun) await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const 建立時間 = Date.parse(issue.created_at);
if (Number.isNaN(建立時間)) {
throw new ScriptError(
'NO_CREATED_AT',
`${repo} 的議題 ${index} 沒有可解讀的建立時間,補登的終點判斷不出來`,
);
}
// 這一輪建立的議題就補到建立那一刻;既有的議題則補到現在,那一輪的工夫一樣要進報表
const 這輪建立 = 建立時間 > since;
const 迄 = 這輪建立 ? 建立時間 : Date.now();
const 秒數 = Math.round((迄 - since) / 1000);
const 略過 = await skipReason(login, repo, index, 秒數);
const planned = 略過 === null ? [{ method: 'POST', path: timesPath, body: { time: 秒數 } }] : [];
const 報告 = {
repo,
index: issue.number,
title: issue.title,
url: issue.html_url,
since: new Date(since).toISOString(),
迄: new Date(迄).toISOString(),
依據: 這輪建立 ? '議題建立' : '補登當下',
秒數,
補登: 略過 === null,
...(略過 ? { note: 略過 } : {}),
};
if (dryRun) {
return { dryRun: true, ...報告, requests: planned };
}
for (const { method, path, body } of planned) {
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
}
return 報告;
});
/**
* 不該補的理由,沒有就回 null。
*
* 只有兩種:長度非正的那一段根本不存在;錶已經跑在這顆議題上,代表這一輪已經走到起錶
* 那一步,再補就與錶涵蓋的區間重疊。重跑本身不是理由——每一輪的規劃時間都要記上去。
*/
async function skipReason(login, repo, index, 秒數) {
if (秒數 <= 0) {
return '指令開始時間不早於現在,沒有可補登的區間;兩邊時鐘差幾秒是常事,這不算失敗。';
}
if (stopwatchOnIssue(await listStopwatches(login), repo, index)) {
return '碼錶已經跑在這顆議題上。補登排在起錶之前,錶在跑就代表這一輪補過了,補下去會與錶重疊。';
}
return null;
}
/**
* 解析 `--since`。擋在打 Gitea 之前:值打錯是最常見的輸入錯誤,
* 而它在補登之前唯一的症狀就是長度不對,事後從報表上看不出來。
* @returns {number} epoch 毫秒
*/
function parseSince(value) {
const at = Date.parse(value);
if (Number.isNaN(at)) {
throw new ScriptError(
'BAD_SINCE',
`--since 需為可解析的 ISO 8601 時間(例如 2026-09-17T10:05:00Z),收到的是 ${value}`,
);
}
return at;
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
/**
* 起錶與停錶。
*
* 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。
*
* **領取工作包時**(sdlc-feat)兩者的時機不同:鎖要在開工之前就上好,錶則要等到
* **工作樹真的建好之後**才起。工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被
* 計了一段什麼都沒做的時間,而工時要準正是工時報表的立足點。規劃與分析沒有工作樹,
* 那條規則對它們不適用——它們的標的是需求議題本身,議題存在就起得了錶。
*
* 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道,
* 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經
* 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。
*
* `--stop` 停錶,而且**只停 `--index` 指的那一顆**。每個階段停掉自己起的那支錶,
* 錶就不會跨階段跑——跑完就去開會而錶跑一整天,報表當場失真。反過來,別顆議題上的錶
* 一律不碰:Gitea 在別顆議題上起新錶會靜默地停掉並記錄前一顆,那種靜默結算正是
* 領取鎖那條規則當初要擋的,這裡不能反過來製造它。
*
* 停錶時錶本來就沒在跑不算失敗:這一步多半排在別的事情做完之後(開完 PR、回報之前),
* 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。
*
* 用法:
* node scripts/timer.js --repo owner/name --index 40 [--stop] [--host <網址>] [--dry-run]
*/
import {
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopStopwatch,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'],
optional: ['host'],
booleans: ['stop', 'dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const issuePath = `/repos/${repo}/issues/${index}`;
const dryRun = flags['dry-run'] === true;
const 要停錶 = flags.stop === true;
const login = resolveLogin({ host: flags.host });
// 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經在計時了」
if (!dryRun) await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const watches = await listStopwatches(login);
const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null;
// 起錶才要擋:別顆議題上的錶會讓工時記錯地方。停錶只動這一顆,擋不擋都影響不到它
if (!要停錶 && !已在計時 && watches.length > 0) throw stopwatchElsewhere(watches[0], '起錶');
// 起錶:已經在跑就不重起,重新起錶會把已經累積的時間切成兩段
// 停錶:沒在這顆上跑就沒得停,別顆議題上的錶不碰
const 動作 = 要停錶
? { 端點: 'stop', 要發請求: 已在計時 }
: { 端點: 'start', 要發請求: !已在計時 };
const planned = 動作.要發請求
? [{ method: 'POST', path: `${issuePath}/stopwatch/${動作.端點}`, body: {} }]
: [];
const 報告 = { repo, index: issue.number, title: issue.title, url: issue.html_url };
if (dryRun) {
return { dryRun: true, ...報告, requests: planned, 已在計時 };
}
if (要停錶) {
// 端點回「沒有錶在跑」的狀態碼隨站台版本而異,所以停錶走 lib 那條容錯路徑;
// 讀到的現況與實際狀態差一步(錶剛被別處停掉)也不該把整件事報成失敗
const stopped = planned.length > 0 && (await stopStopwatch(login, repo, index));
return {
...報告,
碼錶已停: stopped,
...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,這一步略過;別顆議題上的錶不由這裡代停。' }),
};
}
for (const { method, path, body } of planned) {
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
}
return { ...報告, 碼錶中: true, 已在計時 };
});
+1 -16
View File
@@ -6,7 +6,7 @@
* 1. 待辦是巢狀的——每一項待辦底下掛它自己的驗收,並各自帶回未經修改的 `raw`,
* 下游靠 `raw` 做精確字串替換來勾選 checkbox,只改那一行,不重寫整份 body。
* 2. 介面契約是四欄表格,四欄都要留著。
* 3. body 說不出的三個活狀態要現查:相依、領取人、碼錶。
* 3. body 說不出的兩個活狀態要現查:相依與領取人。
*
* 用法:
* node scripts/wp-extract.js --repo owner/name --index 9 [--host <網址>] [--dry-run]
@@ -15,7 +15,6 @@ import {
UNMERGED_COMMENT_NOTE,
countUnmergedComments,
fetchIssue,
listStopwatches,
main,
pages,
parseFlags,
@@ -23,7 +22,6 @@ import {
parseRepo,
preflight,
resolveLogin,
stopwatchOnIssue,
} from './lib.js';
import {
checklistInSection,
@@ -56,7 +54,6 @@ main(async () => {
{ method: 'GET', path: issuePath },
{ method: 'GET', path: `${issuePath}/dependencies` },
{ method: 'GET', path: `${issuePath}/blocks` },
{ method: 'GET', path: '/user/stopwatches' },
{ method: 'GET', path: `${issuePath}/comments` },
],
note: UNMERGED_COMMENT_NOTE,
@@ -86,8 +83,6 @@ main(async () => {
整體驗收: listSection(sections, '整體驗收'),
repos: listSection(sections, 'repo 列表'),
相依: { blocks, depends },
assignee: issue.assignee?.login ?? null,
碼錶中: await hasRunningStopwatch(login, repo, index),
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
};
});
@@ -110,13 +105,3 @@ async function fetchLinked(login, path, kind) {
return indexes;
}
/**
* 這顆議題上是不是有碼錶在跑。
*
* Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),所以這個欄位的真正語意是
* 「**我**的碼錶正跑在這顆議題上」。它用來提醒自己忘了停錶,不是用來判斷別人有沒有在做
* ——領取鎖看的是 assignee。
*/
async function hasRunningStopwatch(login, repo, index) {
return stopwatchOnIssue(await listStopwatches(login), repo, index) !== null;
}