領取鎖的四種狀態各一例,而且每一種擋的情況都驗「一個字都沒寫進 Gitea」——擋下來卻已經 改了一半,比直接放行更難收拾。 分支命名是純字串規則,表格驅動:開發分支三種寫法、功能分支兩層,加上中文、超長、大寫、 底線與連續連字號的輸入驗證。 git 的部分在臨時 repo 上跑真的 git,釘住三件事後來由 code review 抓出來的實際缺陷: 遠端分支的比對必須用全名(否則 feat/x/main 會冒名頂替 main)、工作區不乾淨要在動手前 就擋、來源分支與遠端分歧要回可區分的錯誤碼而不是 git 的原始訊息。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/**
|
|
* 領取工作包的鎖。
|
|
*
|
|
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
|
|
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
|
|
* 各有測試,而且每一種都要驗「一個字都沒寫進 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.碼錶中, true);
|
|
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`,
|
|
`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
|
],
|
|
'錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶',
|
|
);
|
|
});
|
|
|
|
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.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事');
|
|
});
|
|
|
|
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`,
|
|
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
|
],
|
|
);
|
|
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.map((r) => `${r.method} ${r.path}`),
|
|
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
|
|
'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');
|
|
});
|