diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e9ff44a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,4 @@ + + +## 概要 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69d045d..3f78962 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,12 +23,18 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create draft release with PR body + - name: Create draft release from PR overviews id: create-release uses: actions/github-script@v7 with: script: | const fs = require('fs'); + const path = require('path'); + const { buildReleaseNotes } = require(path.join( + process.env.GITHUB_WORKSPACE, + 'scripts', + 'release-notes.cjs' + )); const tagName = context.ref.startsWith('refs/tags/') ? context.ref.replace('refs/tags/', '') : context.payload.inputs.tag_name; @@ -54,18 +60,18 @@ jobs: core.warning(`pr-release-relation.yml の読み込みに失敗: ${e.message}`); } - // 各PRのbodyを取得して結合 - const bodies = []; + // ## 概要 だけを載せる。節が無ければ PR タイトル。 + const pulls = []; for (const prNumber of prNumbers) { const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, }); - if (pr.body) bodies.push(pr.body.trim()); + pulls.push({ title: pr.title, body: pr.body || '' }); } - const body = bodies.join('\n\n---\n\n') || `Release ${tagName}`; + const body = buildReleaseNotes(pulls) || `Release ${tagName}`; const { data: release } = await github.rest.repos.createRelease({ owner: context.repo.owner, diff --git a/scripts/release-notes.cjs b/scripts/release-notes.cjs new file mode 100644 index 0000000..c577ba1 --- /dev/null +++ b/scripts/release-notes.cjs @@ -0,0 +1,82 @@ +// リリースノートは各 PR の「## 概要」だけ。節が無ければタイトルの箇条書き。 + +function extractOverview(markdown) { + const lines = String(markdown ?? "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n"); + let fence = null; + let capturing = false; + const out = []; + + const atFence = (line) => { + const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (!m) return false; + const marker = m[2]; + const rest = m[3]; + if (!fence) { + fence = { char: marker[0], len: marker.length }; + return true; + } + if (marker[0] === fence.char && marker.length >= fence.len && rest.trim() === "") { + fence = null; + return true; + } + return false; + }; + + for (const line of lines) { + if (atFence(line) || fence) { + if (capturing) out.push(line); + continue; + } + if (/^##\s+概要\s*$/.test(line)) { + capturing = true; + continue; + } + if (capturing && /^#{1,2}\s+\S/.test(line)) break; + if (capturing) out.push(line); + } + + const text = out.join("\n").trim(); + return text || null; +} + +function buildReleaseNotes(pulls) { + const chunks = []; + const bullets = []; + const flush = () => { + if (bullets.length === 0) return; + chunks.push(bullets.join("\n")); + bullets.length = 0; + }; + + for (const pr of pulls || []) { + const overview = extractOverview(pr && pr.body); + if (overview) { + flush(); + chunks.push(`## 概要\n\n${overview}`); + continue; + } + const title = String((pr && pr.title) || "") + .replace(/\s+/g, " ") + .trim(); + if (title) bullets.push(`- ${title}`); + } + flush(); + return chunks.join("\n\n"); +} + +module.exports = { extractOverview, buildReleaseNotes }; + +if (require.main === module) { + const fs = require("fs"); + const file = process.argv[2]; + if (!file) { + process.stderr.write("usage: node scripts/release-notes.cjs \n"); + process.exit(2); + } + const notes = buildReleaseNotes(JSON.parse(fs.readFileSync(file, "utf8"))); + process.stdout.write(notes); + if (notes && !notes.endsWith("\n")) process.stdout.write("\n"); +} diff --git a/scripts/release-notes.test.cjs b/scripts/release-notes.test.cjs new file mode 100644 index 0000000..2576237 --- /dev/null +++ b/scripts/release-notes.test.cjs @@ -0,0 +1,96 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { extractOverview, buildReleaseNotes } = require("./release-notes.cjs"); + +const pr16 = `## 概要 + +next版の7項目を実装。 + +| コミット | 内容 | +|---|---| +| \`feat:\` ローカルAPI + MCP | HTTP API | + +### 補足 + +フォーカス移動を含む。 + +## 確認 + +- \`tsc\` 成功 + +## レビューしてほしい点 + +1. Google連携は未検証です。 + +## 既知の割り切り(\`ponytail:\` コメントで明記) + +- トークンファイルの 0600 は unix のみ(Windows未対応) +`; + +test("概要だけを抜き、確認・レビュー・割り切りは捨てる", () => { + const notes = buildReleaseNotes([{ title: "feat: next版", body: pr16 }]); + assert.match(notes, /next版の7項目を実装/); + assert.match(notes, /ローカルAPI \+ MCP/); + assert.match(notes, /### 補足/); + assert.match(notes, /フォーカス移動を含む/); + assert.doesNotMatch(notes, /## 確認/); + assert.doesNotMatch(notes, /tsc/); + assert.doesNotMatch(notes, /レビューしてほしい点/); + assert.doesNotMatch(notes, /未検証/); + assert.doesNotMatch(notes, /既知の割り切り/); + assert.doesNotMatch(notes, /Windows未対応/); +}); + +test("見出しが無い本文はタイトルにフォールバックする", () => { + const notes = buildReleaseNotes([ + { title: "fix: バグ修正", body: "- ウィンドウが閉じない\n- 日付が更新されない\n" }, + ]); + assert.equal(notes, "- fix: バグ修正"); +}); + +test("概要が空、または確認しか無いときは本文を載せない", () => { + const empty = buildReleaseNotes([ + { title: "feat: 空", body: "## 概要\n\n\n## 確認\n\n- cargo test\n" }, + ]); + assert.equal(empty, "- feat: 空"); + + const checklistOnly = buildReleaseNotes([ + { title: "chore: 確認だけ", body: "## 確認\n\n- 目視した\n\n## レビューしてほしい点\n\n- なし\n" }, + ]); + assert.equal(checklistOnly, "- chore: 確認だけ"); +}); + +test("コードフェンス内の見出しでは節を切らない", () => { + const body = [ + "## 概要", + "", + "手順:", + "", + "```", + "## 確認", + "レビューしてほしい点", + "```", + "", + "以上。", + "", + "## 確認", + "", + "- 実機", + ].join("\n"); + const section = extractOverview(body); + assert.match(section, /## 確認/); + assert.match(section, /以上。/); + assert.doesNotMatch(section, /実機/); +}); + +test("複数 PR は概要とタイトル箇条書きを順に結合する", () => { + const notes = buildReleaseNotes([ + { title: "chore: 先", body: "" }, + { title: "feat: 本体", body: "## 概要\r\n\r\nカレンダーを削除。\r\n" }, + { title: "chore: 後\nの続き", body: "本文は使わない" }, + ]); + assert.equal( + notes, + ["- chore: 先", "## 概要\n\nカレンダーを削除。", "- chore: 後 の続き"].join("\n\n"), + ); +}); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d566fda..8c380a7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,7 +28,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["app", "dmg"], "icon": [ "icons/icon_16x16.png", "icons/icon_32x32.png",