diff --git a/AGENTS.md b/AGENTS.md index a223505..f053288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ | --- | --- | --- | | `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 | | `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` | -| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯 | +| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 | | `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts | | `install.js` | 平台偵測與轉接檔產生 | 唯一知道各平台目錄結構的地方 | | `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 | diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md index d31b86b..7608888 100644 --- a/prompts/sdlc-analyze.md +++ b/prompts/sdlc-analyze.md @@ -176,7 +176,33 @@ node scripts/project-add.js --repo --index <編號> --project "<看 看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。 本流程不建立 Milestone,也不建立專案。 -### 11. 回報 +### 11. 產生分析版的圖解總覽 + +用同一份 `templates/overview-artifact.html` 再產一份,但這一份要多出**工作包全景**: +把工作包之間的相依與截止日畫成一張圖,讓開發者看得出自己這一項在整體中的位置。 + +全景圖用 `graph TD`,填進 `{{工作包全景}}`,連同段落標題一起: + +``` +

工作包全景

+
graph TD + A[建立共用函式庫
09-25] --> B[建立抽取契約
09-27] +
+``` + +節點寫工作包標題與截止日,箭頭方向是「先決 → 後續」。節點一樣以 12 個為上限, +超過就只畫相依鏈最長路徑上的那幾顆,其餘在頁尾列成文字。 + +網址一樣寫回需求議題: + +``` +node scripts/issue-update.js --repo --index <需求議題編號> --overview-url <網址> +``` + +**重跑會就地更新同一行**,不會在議題上留下兩個連結。規劃階段產生的那一份會被這一份取代, +這是預期行為——同一顆需求議題只掛一個總覽網址。 + +### 12. 回報 列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆 ——那條路徑決定整體交期。 diff --git a/prompts/sdlc-plan.md b/prompts/sdlc-plan.md index e67f6fa..6beed8d 100644 --- a/prompts/sdlc-plan.md +++ b/prompts/sdlc-plan.md @@ -69,7 +69,34 @@ node scripts/issue-create.js --repo --title "<標題>" --body-file 同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆 並把 `created` 設為 `false`。 -### 6. 回報 +### 6. 產生圖解版總覽 + +套用 `templates/overview-artifact.html`,把議題的總覽、目標與流程圖填成一份可以直接投影的 +網頁。這一份是給**非技術的利害關係人**看的:他們不必讀完技術細節就知道這件事在做什麼。 + +模板的佔位對應如下,樣式不要動——版面與內容分開,改一邊不必碰另一邊: + +- `{{標題}}` 需求議題標題 +- `{{來源議題}}` 指回議題的連結 +- `{{總覽}}` 一句話總覽 +- `{{目標}}` 目標,逐條包成 `
  • ` +- `{{流程圖}}` 流程圖的 Mermaid 原始碼(**不含**圍欄,圍欄是議題 markdown 用的) +- `{{工作包全景}}` 規劃階段還沒有工作包,**填空字串**;這一段由分析階段補上 +- `{{頁尾}}` 產生時間與產生者 + +若執行環境能把 HTML 發佈成可分享的網址,就發佈;不能的話存成檔案,把路徑當成網址用。 + +拿到網址後寫回議題: + +``` +node scripts/issue-update.js --repo --index <編號> --overview-url <網址> +``` + +它把連結以固定前綴寫成總覽段落裡的一行,**重跑時就地更新同一行**,不會長出第二個連結; +議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。連結旁會自動附上 +「此連結預設為私有,組織外無法開啟」,因為讀到的人多半會想轉寄給組織外的人。 + +### 7. 回報 把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。 diff --git a/scripts/issue-update.js b/scripts/issue-update.js index 17ac0e0..4ca1ba1 100644 --- a/scripts/issue-update.js +++ b/scripts/issue-update.js @@ -9,10 +9,13 @@ * * Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 * + * 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新, + * 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。 + * * 用法: * node scripts/issue-update.js --repo owner/name --index 12 * [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N] - * [--host <網址>] [--dry-run] + * [--overview-url <網址>] [--host <網址>] [--dry-run] */ import { ScriptError, @@ -27,21 +30,25 @@ import { } from './lib.js'; import { upsertLineInSection } from './issue-body.js'; +/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */ +const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)'; + main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index'], - optional: ['milestone', 'due-date', 'estimate-days', 'host'], + optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const dueDate = parseDueDate(flags['due-date']); const days = parseDays(flags['estimate-days']); + const overviewUrl = parseOverviewUrl(flags['overview-url']); - if (flags.milestone === undefined && dueDate === null && days === null) { + if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) { throw new ScriptError( 'NOTHING_TO_UPDATE', - '至少要指定 --milestone、--due-date 或 --estimate-days 其中一個', + '至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個', ); } @@ -57,11 +64,16 @@ main(async () => { if (dueDate !== null) { payload.due_date = `${dueDate}T00:00:00Z`; } - if (days !== null) { + if (days !== null || overviewUrl !== null) { const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); - const updated = upsertLineInSection(issue.body ?? '', '關聯', `估算人天:${days}`); + let body = issue.body ?? ''; + + if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`); + if (overviewUrl !== null) { + body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`); + } // 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄 - if (updated !== issue.body) payload.body = updated; + if (body !== issue.body) payload.body = body; } if (flags['dry-run']) { @@ -86,6 +98,21 @@ function parseDueDate(value) { return value; } +function parseOverviewUrl(value) { + if (value === undefined) return null; + if (!/^https?:\/\/\S+$/.test(value)) { + throw new ScriptError('BAD_OVERVIEW_URL', `--overview-url 需為 http(s) 網址,收到的是 ${value}`); + } + // 連上一次寫回的整行一起複製貼上是很常見的手誤,放行的話那句提醒會在議題上出現兩次 + if (value.includes(PRIVACY_NOTE)) { + throw new ScriptError( + 'BAD_OVERVIEW_URL', + `--overview-url 夾帶了上一次寫回的提醒文字,請只給網址本身:${value}`, + ); + } + return value; +} + function parseDays(value) { if (value === undefined) return null; const days = Number(value); diff --git a/templates/overview-artifact.html b/templates/overview-artifact.html new file mode 100644 index 0000000..0b1f4ec --- /dev/null +++ b/templates/overview-artifact.html @@ -0,0 +1,112 @@ + + + + + +{{標題}} + + + +
    +
    +

    {{標題}}

    +

    {{來源議題}}

    +
    + +

    {{總覽}}

    + +
    +

    目標

    +
      {{目標}}
    +
    + +
    +

    流程

    +
    {{流程圖}}
    +
    + + {{工作包全景}} + +
    {{頁尾}}
    +
    + + + + diff --git a/test/helpers/prompt-doc.js b/test/helpers/prompt-doc.js index 8b405b3..f40e684 100644 --- a/test/helpers/prompt-doc.js +++ b/test/helpers/prompt-doc.js @@ -19,9 +19,14 @@ export function readReference(name) { return readFileSync(join(repoRoot, 'references', `${name}.md`), 'utf8'); } -/** 讀一份輸出模板 */ +/** 讀一份輸出模板(markdown) */ export function readTemplate(name) { - return readFileSync(join(repoRoot, 'templates', `${name}.md`), 'utf8'); + return readTemplateFile(`${name}.md`); +} + +/** 讀 templates/ 底下任一個檔案,含副檔名 */ +export function readTemplateFile(filename) { + return readFileSync(join(repoRoot, 'templates', filename), 'utf8'); } /** diff --git a/test/issue-update.test.js b/test/issue-update.test.js index bd4819d..0dc179d 100644 --- a/test/issue-update.test.js +++ b/test/issue-update.test.js @@ -249,3 +249,93 @@ test('估算插在段落內容結尾,不會掉到下一個段落裡', async (t '估算要留在關聯段落內', ); }); + +// ── 總覽網頁的連結 ───────────────────────────────────────────────── + +test('把總覽網頁的連結寫進總覽段落', async (t) => { + const stub = await withStub(t, {}, { + body: '## 總覽\n\n一句話總覽。\n\n## 關聯\n\n需求議題:#1\n', + }); + + await run(['--overview-url', 'https://example.com/artifact/abc'], stub); + + const updated = patchOf(stub).body.body; + const overview = updated.slice(0, updated.indexOf('## 關聯')); + assert.match(overview, /圖解版總覽:https:\/\/example\.com\/artifact\/abc/); +}); + +test('連結旁註明 artifact 預設私有', async (t) => { + const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話總覽。\n' }); + + await run(['--overview-url', 'https://example.com/a'], stub); + + assert.match(patchOf(stub).body.body, /私有/); + assert.match(patchOf(stub).body.body, /組織外/); +}); + +test('議題原本的 markdown 白話總覽保留不動', async (t) => { + const stub = await withStub(t, {}, { + body: '## 總覽\n\n這段白話總覽不該被網頁取代。\n\n## 背景\n\n背景說明。\n', + }); + + await run(['--overview-url', 'https://example.com/a'], stub); + + const updated = patchOf(stub).body.body; + assert.match(updated, /這段白話總覽不該被網頁取代。/); + assert.match(updated, /## 背景\n\n背景說明。/); +}); + +test('重跑時原地更新同一行,不會長出第二個連結', async (t) => { + const stub = await withStub(t, {}, { + body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/old(此連結預設為私有,組織外無法開啟)\n', + }); + + await run(['--overview-url', 'https://example.com/new'], stub); + + const updated = patchOf(stub).body.body; + assert.equal((updated.match(/圖解版總覽:/g) ?? []).length, 1); + assert.match(updated, /example\.com\/new/); + assert.equal(updated.includes('example.com/old'), false); +}); + +test('連結沒變時不重寫 body', async (t) => { + const stub = await withStub(t, {}, { + body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/a(此連結預設為私有,組織外無法開啟)\n', + }); + + await run(['--overview-url', 'https://example.com/a'], stub); + + assert.equal('body' in patchOf(stub).body, false); +}); + +test('不是網址時擋在打 Gitea 之前', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--overview-url', '不是網址'], stub); + + assert.equal(json.error.code, 'BAD_OVERVIEW_URL'); + assert.equal(stub.requests.length, 0); +}); + +test('總覽網頁與其他欄位可以一次送出', async (t) => { + const stub = await withStub(t); + + await run(['--overview-url', 'https://example.com/a', '--due-date', '2026-09-24'], stub); + + const patches = stub.requests.filter((r) => r.method === 'PATCH' && !r.path.endsWith('/0')); + assert.equal(patches.length, 1); + assert.match(patches[0].body.body, /圖解版總覽/); + assert.equal(patches[0].body.due_date, '2026-09-24T00:00:00Z'); +}); + +test('網址夾帶上一次寫回的提醒文字時擋下,免得那句話重複兩次', async (t) => { + const stub = await withStub(t); + + const { json } = await run( + ['--overview-url', 'https://example.com/a(此連結預設為私有,組織外無法開啟)'], + stub, + ); + + assert.equal(json.error.code, 'BAD_OVERVIEW_URL'); + assert.equal(stub.requests.length, 0); +}); diff --git a/test/overview-artifact.test.js b/test/overview-artifact.test.js new file mode 100644 index 0000000..846888f --- /dev/null +++ b/test/overview-artifact.test.js @@ -0,0 +1,126 @@ +/** + * 圖解版總覽網頁:模板本身,以及兩份正本裡產生它、把網址寫回議題的規則。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readPrompt, readTemplateFile } from './helpers/prompt-doc.js'; + +const template = readTemplateFile('overview-artifact.html'); +const planPrompt = readPrompt('sdlc-plan'); +const analyzePrompt = readPrompt('sdlc-analyze'); +/** 只看產生總覽那一步,避免拿整份正本的任何一處來充數 */ +const planStep = planPrompt.slice(planPrompt.indexOf('### 6.'), planPrompt.indexOf('### 7.')); + +/** 模板要填的欄位 */ +const PLACEHOLDERS = ['標題', '來源議題', '總覽', '目標', '流程圖', '工作包全景', '頁尾']; + +// ── 模板 ─────────────────────────────────────────────────────────── + +test('模板以 {{變數}} 佔位,欄位齊全', () => { + const found = new Set([...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1])); + for (const name of PLACEHOLDERS) { + assert.ok(found.has(name), `模板缺少佔位 {{${name}}}`); + } +}); + +test('樣式與內容分離:樣式集中在 style 區塊,內文不帶 style 屬性', () => { + const styleBlocks = template.match(/