/** * 領取工作包的鎖。 * * 錶不在這一支起——它等工作樹建好之後才由 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.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.match( json.error.message, /停錶只停計時,不會動到任何既有的工作樹/, '以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了', ); 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'); });