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); });