原本的順序是「放行 → 設 assignee 與標籤 → 起錶 → 處理分支」,而工作樹建立 失敗會中止整個領取——錶已經起了才失敗,使用者會被計一段什麼都沒做的時間, 而工時要準正是工時報表的立足點。 claim 只留領取鎖的兩件事(assignee 與標籤),起錶交給新的 timer.js,由流程 正本排在 branch-prep 之後。timer 已經跑在這顆議題上時什麼都不做:中斷後重跑 是它最常見的處境,重新起錶會把已經累積的時間切成兩段;跑在別顆上則照舊擋下, 不代勞停錶。 三支腳本讀碼錶的那段各留一份,趁這次收進 lib。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
307 lines
12 KiB
JavaScript
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');
|
|
});
|