feat/sdlc-plan-requirement-issue/main #21

Merged
admin merged 6 commits from feat/sdlc-plan-requirement-issue/main into master 2026-09-17 04:51:03 +00:00
Showing only changes of commit 9b9ab33a53 - Show all commits
+329
View File
@@ -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);
});