From ed2d08de9fbaca71c939fc66270fcfee98f0c511 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:22:27 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(=E7=B8=BD=E8=A6=BD=E6=A8=A1=E6=9D=BF):?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E5=9C=96=E8=A7=A3=E7=89=88=E7=B8=BD?= =?UTF-8?q?=E8=A6=BD=E7=9A=84=20HTML=20=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 給非技術的利害關係人在會議上直接投影用:一句話總覽、目標、流程圖,分析階段再多 一張工作包全景。 樣式全部集中在單一 style 區塊,內文只放佔位——改版面不必動內容,換內容不必碰樣式。 深色模式跟隨系統,手機寬度另有斷點,投影與傳連結兩種場合都看得清楚。 工作包全景是整段佔位、獨佔一行,規劃階段填空字串時整段會乾淨消失;若把它包在寫死 的 section 裡,規劃版就會留下一個空標題。 mermaid 由 CDN 載入並實際渲染,而不是把原始碼丟給讀者看;代價是離線開啟時圖不會出來。 Co-Authored-By: Claude Opus 5 (1M context) --- templates/overview-artifact.html | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 templates/overview-artifact.html 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 @@ + + + + + +{{標題}} + + + +
+
+

{{標題}}

+

{{來源議題}}

+
+ +

{{總覽}}

+ +
+

目標

+
    {{目標}}
+
+ +
+

流程

+
{{流程圖}}
+
+ + {{工作包全景}} + +
{{頁尾}}
+
+ + + + From 0e05dd2066bf599b92d0f72e54318347fdca1ea0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:22:28 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(issue-update):=20=E6=94=AF=E6=8F=B4=20?= =?UTF-8?q?--overview-url=EF=BC=8C=E6=8A=8A=E7=B8=BD=E8=A6=BD=E7=B6=B2?= =?UTF-8?q?=E5=9D=80=E5=AF=AB=E5=9B=9E=E8=AD=B0=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沿用既有的 upsertLineInSection:連結以固定前綴獨佔總覽段落裡的一行,重跑時就地 更新,不會長出第二個連結;議題原本的 markdown 白話總覽一字不動——網頁是補充, 不是取代。 連結旁自動附上「此連結預設為私有,組織外無法開啟」。讀到的人多半會想轉寄給組織外 的人,這句話寫在議題上比寫在文件裡有用。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-update.js | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/scripts/issue-update.js b/scripts/issue-update.js index 17ac0e0..6745f87 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, @@ -30,18 +33,19 @@ import { upsertLineInSection } from './issue-body.js'; 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 +61,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 +95,17 @@ function parseDueDate(value) { return value; } +/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */ +const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)'; + +function parseOverviewUrl(value) { + if (value === undefined) return null; + if (!/^https?:\/\/\S+$/.test(value)) { + throw new ScriptError('BAD_OVERVIEW_URL', `--overview-url 需為 http(s) 網址,收到的是 ${value}`); + } + return value; +} + function parseDays(value) { if (value === undefined) return null; const days = Number(value); From 6b30552ee26117090c1f4a2c984c9ddef9f1f8d8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:22:28 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat(=E6=B5=81=E7=A8=8B=E6=AD=A3=E6=9C=AC):?= =?UTF-8?q?=20=E5=85=A9=E4=BB=BD=E6=AD=A3=E6=9C=AC=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=94=A2=E7=94=9F=E7=B8=BD=E8=A6=BD=E8=88=87=E5=AF=AB=E5=9B=9E?= =?UTF-8?q?=E7=B6=B2=E5=9D=80=E7=9A=84=E6=AD=A5=E9=A9=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 規劃版填總覽、目標、流程圖,工作包全景填空字串;分析版沿用同一份模板,額外把 工作包的相依與截止日畫成 graph TD 的全景圖。節點一樣以 12 個為上限,超過就只畫 相依鏈最長路徑上的那幾顆。 兩份都寫回同一顆需求議題的同一行,所以分析版會取代規劃版——同一顆需求議題只掛 一個總覽網址,這是預期行為,正本裡寫明免得被當成 bug。 另外交代填模板時流程圖不帶圍欄:圍欄是議題 markdown 用的,填進 HTML 會多出一段 沒有意義的字。 Closes #10 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-analyze.md | 28 +++++++++++++++++++++++++++- prompts/sdlc-plan.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) 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. 回報 把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。 From 47529d33c2c9915eb0ab2305cc40a05f3e6f1af0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:22:29 +0000 Subject: [PATCH 4/8] =?UTF-8?q?test(issue-update):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E7=B8=BD=E8=A6=BD=E7=B6=B2=E5=9D=80=E7=9A=84=E5=AF=AB=E5=9B=9E?= =?UTF-8?q?=E8=88=87=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-update.test.js | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/issue-update.test.js b/test/issue-update.test.js index bd4819d..9de80bd 100644 --- a/test/issue-update.test.js +++ b/test/issue-update.test.js @@ -249,3 +249,81 @@ 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'); +}); From 0aa04390611fcd679e819ca5d05d755bf1740c2d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:22:29 +0000 Subject: [PATCH 5/8] =?UTF-8?q?test(=E7=B8=BD=E8=A6=BD=E7=B6=B2=E9=A0=81):?= =?UTF-8?q?=20=E9=87=98=E4=BD=8F=E6=A8=A1=E6=9D=BF=E7=B5=90=E6=A7=8B?= =?UTF-8?q?=E8=88=87=E5=85=A9=E4=BB=BD=E6=AD=A3=E6=9C=AC=E7=9A=84=E8=A6=8F?= =?UTF-8?q?=E5=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模板的檢查重點是「樣式與內容分離」與「全景段落能整段消失」,兩者壞掉時規劃版會 留下空標題或行內樣式散落各處,肉眼不容易發現。 順帶把 work-package 測試的 phase2 切法收斂到第三段之前——原本切到檔尾,第三段 新增的編號清單會混進段落順序的斷言裡。 Co-Authored-By: Claude Opus 5 (1M context) --- test/helpers/prompt-doc.js | 9 ++- test/overview-artifact.test.js | 115 +++++++++++++++++++++++++++++++ test/work-package-assets.test.js | 2 +- 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 test/overview-artifact.test.js 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/overview-artifact.test.js b/test/overview-artifact.test.js new file mode 100644 index 0000000..8f24b5d --- /dev/null +++ b/test/overview-artifact.test.js @@ -0,0 +1,115 @@ +/** + * 圖解版總覽網頁:模板本身,以及兩份正本裡產生它、把網址寫回議題的規則。 + */ +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 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(/