feat/sdlc-plan-requirement-issue/main #21
@@ -0,0 +1,329 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const TITLE = '把口語需求轉成結構化需求議題';
|
||||
|
||||
/** 建議題要帶 body,寫成檔案傳進去,避免長 markdown 擠在命令列上 */
|
||||
function writeBody(text = '## 總覽\n\n一句話說明這件事在做什麼。\n') {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'body-'));
|
||||
const path = join(dir, 'requirement.md');
|
||||
writeFileSync(path, text);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 預設情境:repo 裡已經有兩個標籤,且沒有任何同名議題 */
|
||||
function defaultRoutes(overrides = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] },
|
||||
[`POST /api/v1/repos/${REPO}/issues`]: (req) => ({
|
||||
status: 201,
|
||||
body: {
|
||||
number: 42,
|
||||
title: req.body.title,
|
||||
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/42`,
|
||||
labels: (req.body.labels ?? []).map((id) => ({ id, name: `label-${id}` })),
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function withStub(t, overrides = {}) {
|
||||
return withStubGitea(t, defaultRoutes(overrides));
|
||||
}
|
||||
|
||||
// ── 建立議題 ───────────────────────────────────────────────────────
|
||||
|
||||
test('建立議題並回傳編號與網址', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.number, 42);
|
||||
assert.equal(json.data.title, TITLE);
|
||||
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/42`);
|
||||
assert.equal(json.data.created, true);
|
||||
});
|
||||
|
||||
test('body 由檔案讀入,原樣送出不做加工', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const body = '## 總覽\n\n多行的\n\n內容。\n';
|
||||
|
||||
await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(body)],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
const post = stub.requests.find((r) => r.method === 'POST');
|
||||
assert.equal(post.body.body, body);
|
||||
assert.equal(post.body.title, TITLE);
|
||||
});
|
||||
|
||||
test('body 檔案不存在時,帶著路徑失敗且不碰 Gitea', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', '/nonexistent/body.md'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'BODY_FILE_MISSING');
|
||||
assert.match(json.error.message, /\/nonexistent\/body\.md/);
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('缺少必填 flag 時失敗', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('issue-create.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
});
|
||||
|
||||
// ── 標籤:只能挑既有的 ─────────────────────────────────────────────
|
||||
|
||||
test('以名稱指定標籤,送出時換成既有標籤的 id', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(json.ok, true, JSON.stringify(json));
|
||||
const post = stub.requests.find((r) => r.method === 'POST');
|
||||
assert.deepEqual(post.body.labels, [55]);
|
||||
});
|
||||
|
||||
test('多個標籤以逗號分隔', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript(
|
||||
'issue-create.js',
|
||||
[
|
||||
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(),
|
||||
'--labels', 'ready-for-agent,進行中',
|
||||
],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
const post = stub.requests.find((r) => r.method === 'POST');
|
||||
assert.deepEqual(post.body.labels, [55, 56]);
|
||||
});
|
||||
|
||||
test('指定不存在的標籤時中止,並列出可選的標籤', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'needs-triage'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_LABEL');
|
||||
assert.match(json.error.message, /needs-triage/);
|
||||
assert.match(json.error.message, /ready-for-agent/, '訊息要列出可選的標籤');
|
||||
});
|
||||
|
||||
test('不建立標籤:沒有任何請求寫到 labels 端點', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
const labelWrites = stub.requests.filter(
|
||||
(r) => r.path.endsWith('/labels') && r.method !== 'GET',
|
||||
);
|
||||
assert.deepEqual(labelWrites, []);
|
||||
});
|
||||
|
||||
test('未指定標籤時不查標籤,也不在請求裡帶 labels', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(stub.requests.some((r) => r.path.endsWith('/labels')), false);
|
||||
const post = stub.requests.find((r) => r.method === 'POST');
|
||||
assert.equal('labels' in post.body, false);
|
||||
});
|
||||
|
||||
// ── 冪等查重 ───────────────────────────────────────────────────────
|
||||
|
||||
test('同標題議題已存在時不重建,回傳既有那一顆', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: {
|
||||
status: 200,
|
||||
body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }],
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.created, false);
|
||||
assert.equal(json.data.number, 9);
|
||||
assert.equal(stub.requests.some((r) => r.method === 'POST'), false, '不得重建議題');
|
||||
});
|
||||
|
||||
test('標題只差前後空白仍視為同一顆,不重建', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: {
|
||||
status: 200,
|
||||
body: [{ number: 9, title: ` ${TITLE} `, html_url: 'https://example.com/9' }],
|
||||
},
|
||||
});
|
||||
|
||||
const { json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(json.data.created, false);
|
||||
assert.equal(json.data.number, 9);
|
||||
});
|
||||
|
||||
test('查重看的是 open 與 closed 兩種狀態', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
const lookup = stub.requests.find((r) => r.method === 'GET' && r.path.endsWith('/issues'));
|
||||
assert.equal(lookup.query.state, 'all');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將發出的請求,但一個字都不寫進 Gitea', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /repos/${REPO}/issues`],
|
||||
);
|
||||
assert.equal(
|
||||
stub.requests.some((r) => r.method !== 'GET'),
|
||||
false,
|
||||
'試跑可以讀,但不得發出任何寫入請求',
|
||||
);
|
||||
});
|
||||
|
||||
test('--dry-run 的預覽忠實反映將送出的 body,標籤已換成 id', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const body = '## 總覽\n\n要送出去的內容。\n';
|
||||
|
||||
const { json } = await runScript(
|
||||
'issue-create.js',
|
||||
[
|
||||
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(body),
|
||||
'--labels', 'ready-for-agent', '--dry-run',
|
||||
],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
const planned = json.data.requests[0].body;
|
||||
assert.equal(planned.title, TITLE);
|
||||
assert.equal(planned.body, body);
|
||||
assert.deepEqual(planned.labels, [55], '預覽要看得出標籤會被貼上,而不是整個消失');
|
||||
assert.deepEqual(json.data.labels, ['ready-for-agent']);
|
||||
});
|
||||
|
||||
test('--dry-run 就抓得到標籤錯字,不必等到實跑', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
[
|
||||
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(),
|
||||
'--labels', 'needs-triage', '--dry-run',
|
||||
],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_LABEL');
|
||||
});
|
||||
|
||||
test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: {
|
||||
status: 200,
|
||||
body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }],
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.requests, [], '已存在就不該預告要建立議題');
|
||||
assert.equal(json.data.existing.number, 9);
|
||||
});
|
||||
|
||||
// ── 前置檢查仍然生效 ───────────────────────────────────────────────
|
||||
|
||||
test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
permissions: { admin: true, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'issue-create.js',
|
||||
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
|
||||
assert.equal(stub.requests.some((r) => r.method === 'POST'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user