Files
tea-sdlc/test/claim.test.js
T
jiantw83andClaude Opus 5 feaf40ab78 feat(timer): 碼錶移出領取,等工作樹建成之後才起
原本的順序是「放行 → 設 assignee 與標籤 → 起錶 → 處理分支」,而工作樹建立
失敗會中止整個領取——錶已經起了才失敗,使用者會被計一段什麼都沒做的時間,
而工時要準正是工時報表的立足點。

claim 只留領取鎖的兩件事(assignee 與標籤),起錶交給新的 timer.js,由流程
正本排在 branch-prep 之後。timer 已經跑在這顆議題上時什麼都不做:中斷後重跑
是它最常見的處境,重新起錶會把已經累積的時間切成兩段;跑在別顆上則照舊擋下,
不代勞停錶。

三支腳本讀碼錶的那段各留一份,趁這次收進 lib。

議題 #40

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

307 lines
12 KiB
JavaScript

/**
* 領取工作包的鎖。
*
* 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗
* 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段
* 什麼都沒做的時間。
*
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
* 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半,
* 比直接放行更難收拾。
*/
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 = 11;
const ME = 'tester';
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, options = {}) {
const { assignees = [], labels = [], stopwatches = [], repoLabels } = options;
const base = healthyRoutes(REPO, {
'GET /api/v1/user': { status: 200, body: { login: ME } },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 sdlc-feat 領取工作包、起錶並備妥分支',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name, i) => ({ id: 60 + i, name })),
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
});
if (repoLabels !== undefined) {
base[`GET /api/v1/repos/${REPO}/labels`] = {
status: 200,
body: repoLabels.map((name, i) => ({ id: 55 + i, name })),
};
}
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
/**
* 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。
* 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上,
* 不會改動任何東西(見 lib.js 的 checkIssueWrite)。
*/
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
// ── 決策表:無鎖 ───────────────────────────────────────────────────
test('沒有鎖時放行:設 assignee、貼進行中', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.assignee, ME);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起');
assert.equal(json.data.已認領過, false);
});
test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run([], stub);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[
`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`,
`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`,
],
'錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間',
);
});
test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
await run([], stub);
const patch = writes(stub).find((r) => r.method === 'PATCH');
assert.deepEqual(patch.body.assignees, [ME]);
const label = writes(stub).find((r) => r.path.endsWith('/labels'));
assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56');
});
// ── 決策表:他人已認領 ─────────────────────────────────────────────
test('他人已認領時擋下,並指名是誰', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
assert.match(json.error.message, /someone-else/);
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => {
const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
// ── 決策表:自己的碼錶在跑 ─────────────────────────────────────────
test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX)],
repoLabels: ['進行中'],
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE');
assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶');
assert.deepEqual(writes(stub), []);
});
test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(7)],
repoLabels: ['進行中'],
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.deepEqual(writes(stub), []);
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')],
repoLabels: ['進行中'],
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /別的專案/);
});
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
test('自己已認領但沒有錶時放行,並如實說這顆本來就是自己的', async (t) => {
const stub = await withStub(t, {}, { assignees: [ME], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.已認領過, true);
assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後');
});
test('進行中標籤已經在議題上時不重複貼', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run([], stub);
assert.equal(json.data.已認領過, true);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(
writes(stub).some((r) => r.path.endsWith('/labels')),
false,
'已經貼著的標籤不必再貼一次',
);
});
// ── 標籤:本 plugin 不自動建立標籤 ─────────────────────────────────
test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
assert.match(json.error.message, /進行中/);
assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖');
});
// ── 錯誤 ───────────────────────────────────────────────────────────
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
}, { repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[
`PATCH /repos/${REPO}/issues/${INDEX}`,
`POST /repos/${REPO}/issues/${INDEX}/labels`,
],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => {
// 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run(['--dry-run'], stub);
const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path);
assert.ok(reads.includes('/api/v1/user'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`));
assert.ok(reads.includes('/api/v1/user/stopwatches'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`));
});
test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(
json.data.requests,
[],
'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報',
);
});
test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { json } = await run(['--dry-run'], stub);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
});