Files
tea-sdlc/test/time-log.test.js
T
jiantw83andClaude Opus 5 81d02bffcf feat(timer,time-log): 起錶配上停錶,並補登議題建立之前的時間
碼錶只在領取工作包時起動,所以工時報表上規劃與分析永遠是零。久了會讓人以為
規劃不花時間,而那正是估算失準最常見的來源。

timer 加上 --stop,而且**只停 --index 指的那一顆**。每個階段停掉自己起的那一支,
錶就不會跨階段跑——跑完就去開會而錶跑一整天,報表當場失真。反過來,別顆議題上的錶
一律不碰:Gitea 在別顆議題上起新錶會靜默地停掉並記錄前一顆,那種靜默結算正是領取鎖
那條規則當初要擋的,不能在這裡反過來製造它。錶本來就沒在跑不算失敗,這一步多半排在
回報之前,報成失敗只會讓人以為前面那件事沒做成而重跑一次。

time-log 補登議題建立之前那一段——讀齊輸入、逐項詢問、組出議題內容,往往是整個 plan
最耗時的部分,而那時候議題還不存在,沒有標的可起錶。長度由腳本自己算(議題的建立時間
減掉 --since),交給 agent 做減法等於讓兩邊的時鐘各算一次,而算錯了報表上看不出來。
**不設時間上限、照實補登**:中途去開會的兩小時會一起算進去,換來這個流程不必為此
多長一題出來問使用者。

補登不是冪等的動作,所以兩種情況跳過不補:錶已經跑在這顆議題上(補登排在起錶之前,
錶在跑就代表這一段補過了),以及這顆議題上已經有工時(整個流程跑完過一次)。工時記
重複比記不到更難在報表上被發現,所以判斷偏向不補。

時間追蹤在現有環境下可能是關著的,真實路徑跑不起來;兩支都靠 --dry-run 與 stub
server 測,共 27 條。

議題 #57

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

194 lines
7.4 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.
/**
* 補登工時。
*
* 規劃階段最耗時的那一段發生在議題建立之前——那時候沒有標的可起錶,時間只能事後補登。
* 這一支的價值全在「補多少」與「什麼時候不該補」:長度由議題的建立時間減掉指令開始
* 時間算出來,不由 agent 自己做減法;已經補過的不再補一次,否則重跑會把同一段時間
* 記兩遍,而報表看不出來那是重複的。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 42;
/** 議題建立於此刻;每支測試的 --since 都相對它往前推 */
const CREATED = '2026-09-17T10:30:00Z';
/** CREATED 往前推 n 秒的 ISO 時間 */
const 早於建立 = (seconds) => new Date(Date.parse(CREATED) - seconds * 1000).toISOString();
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, { stopwatches = [], times = [] } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '為規劃與分析階段計時並補登規劃時間',
created_at: CREATED,
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 200, body: times },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 200, body: { id: 1 } },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (since, args, stub) =>
runScript('time-log.js', ['--repo', REPO, '--index', String(INDEX), '--since', since, ...args], {
env: envFor(stub),
});
/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
test('補登時長等於指令開始到議題建立的差值', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(早於建立(25 * 60), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.equal(json.data.秒數, 25 * 60);
const [write] = writes(stub);
assert.equal(write.path, `/api/v1/repos/${REPO}/issues/${INDEX}/times`);
assert.deepEqual(write.body, { time: 25 * 60 });
});
test('長度由腳本自己算:議題的建立時間減掉 --since,agent 不必做減法', async (t) => {
const stub = await withStub(t);
const { json } = await run(早於建立(90), [], stub);
assert.equal(json.data.秒數, 90);
assert.equal(json.data.議題建立時間, CREATED);
});
test('不設時間上限:中間去開會的那幾個小時照實補登,不改口問使用者', async (t) => {
const stub = await withStub(t);
const 十小時 = 10 * 60 * 60;
const { code, json } = await run(早於建立(十小時), [], stub);
assert.equal(code, 0, '時間長不是失敗;記多了看得出來,記不到就永遠找不回來');
assert.equal(json.data.秒數, 十小時);
assert.deepEqual(writes(stub).at(0).body, { time: 十小時 });
});
test('錶已經跑在這顆議題上時跳過:補登排在起錶之前,代表這一步做過了', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, false);
assert.match(json.data.note, /起錶/);
assert.deepEqual(writes(stub), [], '重跑不該把同一段時間記第二遍');
});
test('這顆議題上已經有自己的工時時跳過,重跑不會愈補愈多', async (t) => {
const stub = await withStub(t, {}, { times: [{ id: 3, time: 1500 }] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, false);
assert.match(json.data.note, /工時/);
assert.deepEqual(writes(stub), []);
});
test('錶跑在別顆議題上不影響補登:補登只寫工時,不動任何錶', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.deepEqual(
writes(stub).map((r) => r.path),
[`/api/v1/repos/${REPO}/issues/${INDEX}/times`],
'不得順手停掉別顆議題上的錶',
);
});
test('指令開始時間不早於議題建立時間時什麼都不補,也不算失敗', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(new Date(Date.parse(CREATED) + 1000).toISOString(), [], stub);
assert.equal(code, 0, '兩邊時鐘差幾秒是常事,不該讓整個流程停在這裡');
assert.equal(json.data.補登, false);
assert.deepEqual(writes(stub), []);
});
test('--since 不是可解析的時間時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { code, json } = await run('剛剛', [], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'BAD_SINCE');
assert.equal(stub.requests.length, 0);
});
test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { json } = await run(早於建立(600), [], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('repo 的時間追蹤關著時,訊息與前置檢查第四層說的是同一件事', async (t) => {
// 議題讀得到卻在 times 上 404,代表時間追蹤關著;試跑不跑前置檢查,這句話得由這裡說
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 404, body: { message: 'not found' } },
});
const { code, json } = await run(早於建立(600), ['--dry-run'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.match(json.error.message, /Enable Time Tracker/);
});
test('--dry-run 印出將發出的補登,但一個字都不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.秒數, 1800);
assert.deepEqual(json.data.requests, [
{ method: 'POST', path: `/repos/${REPO}/issues/${INDEX}/times`, body: { time: 1800 } },
]);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:已經補過的預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { times: [{ id: 3, time: 1500 }] });
const { json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.補登, false);
});