From f85aa5f5e93d2ab2ce29b1cab87159334be069a4 Mon Sep 17 00:00:00 2001 From: Serg Date: Mon, 29 Sep 2025 18:19:08 +0700 Subject: [PATCH 01/58] feat: release drafter config --- .github/release-drafter.yml | 24 ++++++++++++++++++++++++ .github/workflows/release-drafter.yml | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..ff4e9a9 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,24 @@ +name-template: "v$NEXT_PATCH_VERSION" +tag-template: "v$NEXT_PATCH_VERSION" + +categories: + - title: "✨ Features" + labels: ["feature", "enhancement", "feat"] + - title: "🐞 Bug fixes" + labels: ["bug", "fix"] + - title: "🧰 Maintenance" + labels: ["chore", "refactor", "docs"] + - title: "πŸ’₯ Breaking changes" + labels: ["breaking"] + +exclude-labels: + - "wip" + - "skip-release" + +change-template: "- $TITLE (#$PR) by @$AUTHOR" +template: | + ## Changes + $CHANGES + + ## Linked issues + $ISSUES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..337a06e --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,18 @@ +name: Release Drafter + +on: + workflow_call: + inputs: + target_branch: + required: true + type: string + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: .github/release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 83c7f16aa09b8aa9ec0c7fcccad0f350414421d6 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 3 Oct 2025 21:12:34 +0700 Subject: [PATCH 02/58] feat: custom release drafter --- .github/release-drafter.yml | 24 ----- .github/scripts/release-notes.js | 104 +++++++++++++++++++++ .github/workflows/custom-release-draft.yml | 31 ++++++ .github/workflows/release-drafter.yml | 18 ---- 4 files changed, 135 insertions(+), 42 deletions(-) delete mode 100644 .github/release-drafter.yml create mode 100644 .github/scripts/release-notes.js create mode 100644 .github/workflows/custom-release-draft.yml delete mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index ff4e9a9..0000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,24 +0,0 @@ -name-template: "v$NEXT_PATCH_VERSION" -tag-template: "v$NEXT_PATCH_VERSION" - -categories: - - title: "✨ Features" - labels: ["feature", "enhancement", "feat"] - - title: "🐞 Bug fixes" - labels: ["bug", "fix"] - - title: "🧰 Maintenance" - labels: ["chore", "refactor", "docs"] - - title: "πŸ’₯ Breaking changes" - labels: ["breaking"] - -exclude-labels: - - "wip" - - "skip-release" - -change-template: "- $TITLE (#$PR) by @$AUTHOR" -template: | - ## Changes - $CHANGES - - ## Linked issues - $ISSUES diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js new file mode 100644 index 0000000..619757f --- /dev/null +++ b/.github/scripts/release-notes.js @@ -0,0 +1,104 @@ +import { Octokit } from "@octokit/rest"; + +const token = process.env.GITHUB_TOKEN; +const repoFull = process.env.GITHUB_REPOSITORY; +const [owner, repo] = repoFull.split("/"); + +if (!token || !repoFull) { + console.error("GITHUB_TOKEN and GITHUB_REPOSITORY must be set"); + process.exit(1); +} + +const octokit = new Octokit({ auth: token }); + +const SECTIONS = { + "[Bug]": "🐞 Bug Fixes", + "[Feat]": "✨ New Features", + "[Enhancement]": "πŸ”§ Enhancements", + "[Refactor]": "♻️ Refactoring", + "[Docs]": "πŸ“š Documentation", + "[Test]": "πŸ§ͺ Tests", + "[Chore]": "🧹 Chores", +}; + +function stripPrefix(title) { + return title.replace(/^\[[^\]]+\]\s*/, ""); +} + +async function main() { + const { data: prs } = await octokit.pulls.list({ + owner, + repo, + state: "closed", + per_page: 50, + sort: "updated", + direction: "desc", + }); + + let body = "# πŸš€ Release Notes\n\n"; + const groupedPRs = new Set(); + + for (const [prefix, sectionTitle] of Object.entries(SECTIONS)) { + const items = prs + .filter(pr => pr.title.startsWith(prefix)) + .map(pr => { + groupedPRs.add(pr.number); + return `- ${stripPrefix(pr.title)} (#${pr.number})`; + }); + if (items.length) { + body += `## ${sectionTitle}\n${items.join("\n")}\n\n`; + } + } + + const otherPRs = prs + .filter(pr => !groupedPRs.has(pr.number)) + .map(pr => `- ${pr.title} (#${pr.number})`); + + if (otherPRs.length) { + body += `## Other PRs\n${otherPRs.join("\n")}\n\n`; + } + + const { data: releases } = await octokit.repos.listReleases({ owner, repo }); + let draft = releases.find(r => r.draft); + + let nextVersion = "v0.1.0"; + const latest = releases.find(r => !r.draft) || releases[0]; + if (latest) { + const match = latest.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); + if (match) { + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]) + 1; + nextVersion = `v${major}.${minor}.${patch}`; + } + } + + const releaseName = nextVersion; + const releaseTag = nextVersion; + + if (draft) { + console.log("Updating existing draft release:", draft.tag_name); + await octokit.repos.updateRelease({ + owner, + repo, + release_id: draft.id, + name: releaseName, + body, + }); + } else { + console.log("Creating new draft release"); + await octokit.repos.createRelease({ + owner, + repo, + tag_name: releaseTag, + name: releaseName, + body, + draft: true, + }); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml new file mode 100644 index 0000000..9b9eba5 --- /dev/null +++ b/.github/workflows/custom-release-draft.yml @@ -0,0 +1,31 @@ +name: Custom Release Draft + +on: + workflow_call: + inputs: + target_branch: + required: false + type: string + push: + branches: + - feat/release-drafter + +jobs: + release-draft: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install @octokit/rest + + - name: Run release notes script + run: node .github/scripts/release-notes.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 337a06e..0000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Release Drafter - -on: - workflow_call: - inputs: - target_branch: - required: true - type: string - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - with: - config-name: .github/release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 54027a97a3ac63fb1a9d7d537baac4de82af780a Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 3 Oct 2025 21:24:07 +0700 Subject: [PATCH 03/58] feat: add username --- .github/scripts/release-notes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 619757f..bda6dd6 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -43,7 +43,7 @@ async function main() { .filter(pr => pr.title.startsWith(prefix)) .map(pr => { groupedPRs.add(pr.number); - return `- ${stripPrefix(pr.title)} (#${pr.number})`; + return `- ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; }); if (items.length) { body += `## ${sectionTitle}\n${items.join("\n")}\n\n`; @@ -52,7 +52,7 @@ async function main() { const otherPRs = prs .filter(pr => !groupedPRs.has(pr.number)) - .map(pr => `- ${pr.title} (#${pr.number})`); + .map(pr => `- ${pr.title} (#${pr.number}) by @${pr.user.login}`); if (otherPRs.length) { body += `## Other PRs\n${otherPRs.join("\n")}\n\n`; From 67793787a9904903ef8b83b0ed1966fb1ff65e0d Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 9 Oct 2025 23:30:20 +0700 Subject: [PATCH 04/58] feat: improved release notes generator with prefix & issue parsing --- .github/scripts/release-notes.js | 117 +++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index bda6dd6..660a169 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -11,18 +11,57 @@ if (!token || !repoFull) { const octokit = new Octokit({ auth: token }); -const SECTIONS = { - "[Bug]": "🐞 Bug Fixes", - "[Feat]": "✨ New Features", - "[Enhancement]": "πŸ”§ Enhancements", - "[Refactor]": "♻️ Refactoring", - "[Docs]": "πŸ“š Documentation", - "[Test]": "πŸ§ͺ Tests", - "[Chore]": "🧹 Chores", -}; - -function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, ""); +const SECTIONS = [ + { title: "πŸš€ Tasks", keys: ["Task", "Composite"], labels: ["task", "composite"] }, + { title: "✨ New Features", keys: ["Feat", "Feature"], labels: ["feature", "new-feature"] }, + { title: "πŸ”§ Enhancements", keys: ["Enhancement", "UX/UI"], labels: ["enhancement", "ux/ui"] }, + { title: "🐞 Bug Fixes", keys: ["Bug", "Fix"], labels: ["bug"] }, + { title: "♻️ Refactoring", keys: ["Refactor"], labels: ["refactor"] }, + { title: "πŸ“š Documentation", keys: ["Docs"], labels: ["docs", "documentation"] }, + { title: "πŸ§ͺ Tests", keys: ["Test"], labels: ["test"] }, + { title: "🧹 Chores", keys: ["Chore"], labels: ["chore", "maintenance"] }, +]; + +function variantsOf(key) { + const k = key.toLowerCase(); + return [ + k, + `[${k}]`, + `${k}:`, + `[${k}:]`, + `[${k},`, + `${k},`, + ]; +} + +function parsePrefixes(title) { + const match = title.match(/(\[[^\]]+\]|^[a-z0-9 ,/:-]+)/gi); + if (!match) return []; + return match + .map(p => p.replace(/[\[\]:]/g, "").trim().toLowerCase().split(/[ ,/]+/)) + .flat() + .filter(Boolean); +} + +function findSection(title, labels = [], issueTitle = "", issueLabels = []) { + const prefixes = [ + ...parsePrefixes(title), + ...parsePrefixes(issueTitle), + ]; + const allLabels = [...(labels || []), ...(issueLabels || [])].map(l => l.toLowerCase()); + + for (const section of SECTIONS) { + const allKeys = section.keys.flatMap(variantsOf); + if (prefixes.some(p => allKeys.includes(p))) return section; + if (allLabels.some(l => section.labels.includes(l))) return section; + } + + return null; +} + +function extractLinkedIssues(body) { + const matches = [...body.matchAll(/(close[sd]?|fixe?[sd]?|resolve[sd]?)\s+#(\d+)/gi)]; + return matches.map(m => Number(m[2])); } async function main() { @@ -36,26 +75,46 @@ async function main() { }); let body = "# πŸš€ Release Notes\n\n"; - const groupedPRs = new Set(); - - for (const [prefix, sectionTitle] of Object.entries(SECTIONS)) { - const items = prs - .filter(pr => pr.title.startsWith(prefix)) - .map(pr => { - groupedPRs.add(pr.number); - return `- ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - }); - if (items.length) { - body += `## ${sectionTitle}\n${items.join("\n")}\n\n`; + const grouped = {}; + const allSections = SECTIONS.map(s => s.title); + allSections.push("Other PRs"); + allSections.forEach(s => (grouped[s] = [])); + + for (const pr of prs) { + const linkedIssueNums = extractLinkedIssues(pr.body || ""); + let linkedIssues = []; + for (const num of linkedIssueNums) { + try { + const { data: issue } = await octokit.issues.get({ owner, repo, issue_number: num }); + linkedIssues.push(issue); + } catch { + continue; + } } - } - const otherPRs = prs - .filter(pr => !groupedPRs.has(pr.number)) - .map(pr => `- ${pr.title} (#${pr.number}) by @${pr.user.login}`); + const section = findSection( + pr.title, + pr.labels.map(l => l.name), + linkedIssues[0]?.title, + linkedIssues[0]?.labels?.map(l => l.name) + ); + + const issueText = linkedIssues.length + ? linkedIssues + .map(i => `${i.title} (#${i.number})`) + .join(", ") + : ""; - if (otherPRs.length) { - body += `## Other PRs\n${otherPRs.join("\n")}\n\n`; + const line = `- ${pr.title} (#${pr.number})${issueText ? " closes " + issueText : ""} by @${pr.user.login}`; + if (section) grouped[section.title].push(line); + else grouped["Other PRs"].push(line); + } + + for (const section of allSections) { + const items = grouped[section]; + if (items.length) { + body += `## ${section}\n${items.join("\n")}\n\n`; + } } const { data: releases } = await octokit.repos.listReleases({ owner, repo }); From b718d7d00cea6b9daa963329dd600bb6359b7fed Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:14:42 +0700 Subject: [PATCH 05/58] feat: change closes prs view --- .github/scripts/release-notes.js | 158 ++++++++++++++----------------- 1 file changed, 70 insertions(+), 88 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 660a169..c16f428 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -11,57 +11,24 @@ if (!token || !repoFull) { const octokit = new Octokit({ auth: token }); -const SECTIONS = [ - { title: "πŸš€ Tasks", keys: ["Task", "Composite"], labels: ["task", "composite"] }, - { title: "✨ New Features", keys: ["Feat", "Feature"], labels: ["feature", "new-feature"] }, - { title: "πŸ”§ Enhancements", keys: ["Enhancement", "UX/UI"], labels: ["enhancement", "ux/ui"] }, - { title: "🐞 Bug Fixes", keys: ["Bug", "Fix"], labels: ["bug"] }, - { title: "♻️ Refactoring", keys: ["Refactor"], labels: ["refactor"] }, - { title: "πŸ“š Documentation", keys: ["Docs"], labels: ["docs", "documentation"] }, - { title: "πŸ§ͺ Tests", keys: ["Test"], labels: ["test"] }, - { title: "🧹 Chores", keys: ["Chore"], labels: ["chore", "maintenance"] }, -]; - -function variantsOf(key) { - const k = key.toLowerCase(); - return [ - k, - `[${k}]`, - `${k}:`, - `[${k}:]`, - `[${k},`, - `${k},`, - ]; +const SECTIONS = { + "[Task]": "πŸš€ Tasks", + "[Feat]": "✨ New Features", + "[Enhancement]": "πŸ”§ Enhancements", + "[Bug]": "🐞 Bug Fixes", + "[Refactor]": "♻️ Refactoring", + "[Docs]": "πŸ“š Documentation", + "[Test]": "πŸ§ͺ Tests", + "[Chore]": "🧹 Chores", +}; + +function stripPrefix(title) { + return title.replace(/^\[[^\]]+\]\s*/, ""); } -function parsePrefixes(title) { - const match = title.match(/(\[[^\]]+\]|^[a-z0-9 ,/:-]+)/gi); - if (!match) return []; - return match - .map(p => p.replace(/[\[\]:]/g, "").trim().toLowerCase().split(/[ ,/]+/)) - .flat() - .filter(Boolean); -} - -function findSection(title, labels = [], issueTitle = "", issueLabels = []) { - const prefixes = [ - ...parsePrefixes(title), - ...parsePrefixes(issueTitle), - ]; - const allLabels = [...(labels || []), ...(issueLabels || [])].map(l => l.toLowerCase()); - - for (const section of SECTIONS) { - const allKeys = section.keys.flatMap(variantsOf); - if (prefixes.some(p => allKeys.includes(p))) return section; - if (allLabels.some(l => section.labels.includes(l))) return section; - } - - return null; -} - -function extractLinkedIssues(body) { - const matches = [...body.matchAll(/(close[sd]?|fixe?[sd]?|resolve[sd]?)\s+#(\d+)/gi)]; - return matches.map(m => Number(m[2])); +function detectPrefix(title) { + const match = title.match(/^\[([^\]]+)\]/); + return match ? `[${match[1]}]` : null; } async function main() { @@ -69,61 +36,74 @@ async function main() { owner, repo, state: "closed", - per_page: 50, + per_page: 100, sort: "updated", direction: "desc", }); - let body = "# πŸš€ Release Notes\n\n"; - const grouped = {}; - const allSections = SECTIONS.map(s => s.title); - allSections.push("Other PRs"); - allSections.forEach(s => (grouped[s] = [])); + const { data: issues } = await octokit.issues.listForRepo({ + owner, + repo, + state: "closed", + per_page: 100, + }); + + const issueMap = new Map(); + for (const issue of issues) { + issueMap.set(issue.number, { ...issue, prs: [] }); + } for (const pr of prs) { - const linkedIssueNums = extractLinkedIssues(pr.body || ""); - let linkedIssues = []; - for (const num of linkedIssueNums) { - try { - const { data: issue } = await octokit.issues.get({ owner, repo, issue_number: num }); - linkedIssues.push(issue); - } catch { - continue; + if (!pr.merged_at) continue; + + const linkedIssues = (pr.body?.match(/#(\d+)/g) || []) + .map((s) => parseInt(s.replace("#", ""), 10)) + .filter((n) => issueMap.has(n)); + + if (linkedIssues.length) { + for (const id of linkedIssues) { + issueMap.get(id).prs.push(pr); } + } else { + issueMap.set(`pr-${pr.number}`, { title: pr.title, prs: [pr], isStandalone: true }); } - - const section = findSection( - pr.title, - pr.labels.map(l => l.name), - linkedIssues[0]?.title, - linkedIssues[0]?.labels?.map(l => l.name) - ); - - const issueText = linkedIssues.length - ? linkedIssues - .map(i => `${i.title} (#${i.number})`) - .join(", ") - : ""; - - const line = `- ${pr.title} (#${pr.number})${issueText ? " closes " + issueText : ""} by @${pr.user.login}`; - if (section) grouped[section.title].push(line); - else grouped["Other PRs"].push(line); } - for (const section of allSections) { - const items = grouped[section]; - if (items.length) { - body += `## ${section}\n${items.join("\n")}\n\n`; + let body = "# πŸš€ Release Notes\n\n"; + + const groupedBySection = {}; + + for (const issue of issueMap.values()) { + const title = issue.title || ""; + const prefix = detectPrefix(title) || detectPrefix(issue.prs?.[0]?.title || "") || "[Other]"; + const sectionTitle = SECTIONS[prefix] || "πŸ—‚οΈ Other"; + + groupedBySection[sectionTitle] ||= []; + + if (issue.isStandalone) { + const pr = issue.prs[0]; + groupedBySection[sectionTitle].push( + `- ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}` + ); + } else { + const issueNumber = issue.number; + const issueLine = `- ${prefix} ${stripPrefix(title)} (#${issueNumber})`; + const prRefs = issue.prs.map((pr) => `#${pr.number}`).join(", "); + groupedBySection[sectionTitle].push(`${issueLine}\n ↳ PRs: ${prRefs}`); } } + for (const [section, items] of Object.entries(groupedBySection)) { + body += `## ${section}\n${items.join("\n")}\n\n`; + } + const { data: releases } = await octokit.repos.listReleases({ owner, repo }); - let draft = releases.find(r => r.draft); + let draft = releases.find((r) => r.draft); let nextVersion = "v0.1.0"; - const latest = releases.find(r => !r.draft) || releases[0]; + const latest = releases.find((r) => !r.draft) || releases[0]; if (latest) { - const match = latest.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); + const match = latest.tag_name?.match(/v(\d+)\.(\d+)\.(\d+)/); if (match) { const major = Number(match[1]); const minor = Number(match[2]); @@ -155,9 +135,11 @@ async function main() { draft: true, }); } + + console.log("βœ… Draft release updated successfully"); } -main().catch(err => { +main().catch((err) => { console.error(err); process.exit(1); }); From e95a3ed7e8d762d6bc16924d48d97252e4010268 Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:26:48 +0700 Subject: [PATCH 06/58] feat: add pr author --- .github/scripts/release-notes.js | 63 +++++++++++++++----------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index c16f428..0a2c7bd 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -20,13 +20,14 @@ const SECTIONS = { "[Docs]": "πŸ“š Documentation", "[Test]": "πŸ§ͺ Tests", "[Chore]": "🧹 Chores", + Other: "πŸ“¦ Other PRs", }; function stripPrefix(title) { return title.replace(/^\[[^\]]+\]\s*/, ""); } -function detectPrefix(title) { +function getPrefix(title) { const match = title.match(/^\[([^\]]+)\]/); return match ? `[${match[1]}]` : null; } @@ -37,8 +38,6 @@ async function main() { repo, state: "closed", per_page: 100, - sort: "updated", - direction: "desc", }); const { data: issues } = await octokit.issues.listForRepo({ @@ -57,8 +56,8 @@ async function main() { if (!pr.merged_at) continue; const linkedIssues = (pr.body?.match(/#(\d+)/g) || []) - .map((s) => parseInt(s.replace("#", ""), 10)) - .filter((n) => issueMap.has(n)); + .map(s => parseInt(s.replace("#", ""), 10)) + .filter(n => issueMap.has(n)); if (linkedIssues.length) { for (const id of linkedIssues) { @@ -70,58 +69,56 @@ async function main() { } let body = "# πŸš€ Release Notes\n\n"; - - const groupedBySection = {}; + const sectionGroups = {}; + for (const key of Object.keys(SECTIONS)) sectionGroups[key] = []; for (const issue of issueMap.values()) { - const title = issue.title || ""; - const prefix = detectPrefix(title) || detectPrefix(issue.prs?.[0]?.title || "") || "[Other]"; - const sectionTitle = SECTIONS[prefix] || "πŸ—‚οΈ Other"; + if (!issue.prs.length && !issue.isStandalone) continue; // пропускаСм issue Π±Π΅Π· PR - groupedBySection[sectionTitle] ||= []; + const title = issue.title || ""; + const prefix = getPrefix(title) || getPrefix(issue.prs?.[0]?.title || "") || "Other"; + const section = SECTIONS[prefix] ? prefix : "Other"; if (issue.isStandalone) { const pr = issue.prs[0]; - groupedBySection[sectionTitle].push( - `- ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}` + sectionGroups[section].push( + `${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}` ); } else { - const issueNumber = issue.number; - const issueLine = `- ${prefix} ${stripPrefix(title)} (#${issueNumber})`; - const prRefs = issue.prs.map((pr) => `#${pr.number}`).join(", "); - groupedBySection[sectionTitle].push(`${issueLine}\n ↳ PRs: ${prRefs}`); + const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); + sectionGroups[section].push(`${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`); } } - for (const [section, items] of Object.entries(groupedBySection)) { - body += `## ${section}\n${items.join("\n")}\n\n`; + const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; + + for (const key of orderedSections) { + const items = sectionGroups[key]; + if (items?.length) { + body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; + } } const { data: releases } = await octokit.repos.listReleases({ owner, repo }); - let draft = releases.find((r) => r.draft); + let draft = releases.find(r => r.draft); let nextVersion = "v0.1.0"; - const latest = releases.find((r) => !r.draft) || releases[0]; + const latest = releases.find(r => !r.draft) || releases[0]; if (latest) { - const match = latest.tag_name?.match(/v(\d+)\.(\d+)\.(\d+)/); + const match = latest.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); if (match) { - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]) + 1; - nextVersion = `v${major}.${minor}.${patch}`; + const [_, major, minor, patch] = match.map(Number); + nextVersion = `v${major}.${minor}.${patch + 1}`; } } - const releaseName = nextVersion; - const releaseTag = nextVersion; - if (draft) { console.log("Updating existing draft release:", draft.tag_name); await octokit.repos.updateRelease({ owner, repo, release_id: draft.id, - name: releaseName, + name: nextVersion, body, }); } else { @@ -129,8 +126,8 @@ async function main() { await octokit.repos.createRelease({ owner, repo, - tag_name: releaseTag, - name: releaseName, + tag_name: nextVersion, + name: nextVersion, body, draft: true, }); @@ -139,7 +136,7 @@ async function main() { console.log("βœ… Draft release updated successfully"); } -main().catch((err) => { +main().catch(err => { console.error(err); process.exit(1); }); From 99fbe35b92b5aa91609c6842687b912f204f060f Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:28:13 +0700 Subject: [PATCH 07/58] feat: change section name --- .github/scripts/release-notes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 0a2c7bd..1a520cb 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -20,7 +20,7 @@ const SECTIONS = { "[Docs]": "πŸ“š Documentation", "[Test]": "πŸ§ͺ Tests", "[Chore]": "🧹 Chores", - Other: "πŸ“¦ Other PRs", + Other: "πŸ“¦ Other", }; function stripPrefix(title) { From e8b1af8866a3e4f6c6c57a54685a4b7ce0046eb2 Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:33:43 +0700 Subject: [PATCH 08/58] feat: change other section view --- .github/scripts/release-notes.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 1a520cb..aef6b51 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -81,12 +81,16 @@ async function main() { if (issue.isStandalone) { const pr = issue.prs[0]; - sectionGroups[section].push( - `${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}` - ); + const prLine = section === "Other" + ? `${pr.title} (#${pr.number}) by @${pr.user.login}` + : `${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; + sectionGroups[section].push(prLine); } else { const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); - sectionGroups[section].push(`${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`); + const issueLine = section === "Other" + ? `${title} (#${issue.number})\n ↳ PRs: ${prRefs}` + : `${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; + sectionGroups[section].push(issueLine); } } From 97b852a0df5963eb342b455e5c0194b6c3b22d79 Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:42:31 +0700 Subject: [PATCH 09/58] =?UTF-8?q?=D1=81hore:=20change=20getPrefix=20func?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-notes.js | 42 ++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index aef6b51..3c9f82a 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -20,16 +20,42 @@ const SECTIONS = { "[Docs]": "πŸ“š Documentation", "[Test]": "πŸ§ͺ Tests", "[Chore]": "🧹 Chores", + "[UX/UI]": "πŸ”§ UX/UI Improvements", + "[Composite]": "πŸ“¦ Composite", Other: "πŸ“¦ Other", }; +const PREFIXES = ["Task", "Feat", "Enhancement", "Bug", "Refactor", "Docs", "Test", "Chore", "UX/UI", "Composite"]; + +const PREFIX_ALIASES = {}; +PREFIXES.forEach(p => { + const norm = `[${p}]`; + const lower = p.toLowerCase(); + + PREFIX_ALIASES[`[${lower}]`] = norm; + PREFIX_ALIASES[lower] = norm; + PREFIX_ALIASES[`${lower}:`] = norm; + PREFIX_ALIASES[p] = norm; + PREFIX_ALIASES[`${p}:`] = norm; +}); + function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, ""); + return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z]+:\s*/i, "").trim(); } function getPrefix(title) { - const match = title.match(/^\[([^\]]+)\]/); - return match ? `[${match[1]}]` : null; + if (!title) return null; + const matchBracket = title.match(/^\[([^\]]+)\]/); + if (matchBracket) { + const norm = PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; + if (norm) return norm; + } + const matchWord = title.match(/^([a-z/]+):?/i); + if (matchWord) { + const norm = PREFIX_ALIASES[matchWord[1].toLowerCase()]; + if (norm) return norm; + } + return null; } async function main() { @@ -48,9 +74,7 @@ async function main() { }); const issueMap = new Map(); - for (const issue of issues) { - issueMap.set(issue.number, { ...issue, prs: [] }); - } + for (const issue of issues) issueMap.set(issue.number, { ...issue, prs: [] }); for (const pr of prs) { if (!pr.merged_at) continue; @@ -60,9 +84,7 @@ async function main() { .filter(n => issueMap.has(n)); if (linkedIssues.length) { - for (const id of linkedIssues) { - issueMap.get(id).prs.push(pr); - } + for (const id of linkedIssues) issueMap.get(id).prs.push(pr); } else { issueMap.set(`pr-${pr.number}`, { title: pr.title, prs: [pr], isStandalone: true }); } @@ -73,7 +95,7 @@ async function main() { for (const key of Object.keys(SECTIONS)) sectionGroups[key] = []; for (const issue of issueMap.values()) { - if (!issue.prs.length && !issue.isStandalone) continue; // пропускаСм issue Π±Π΅Π· PR + if (!issue.prs.length && !issue.isStandalone) continue; const title = issue.title || ""; const prefix = getPrefix(title) || getPrefix(issue.prs?.[0]?.title || "") || "Other"; From c61b99cedb9c1e14a964560905b48f1d948bf016 Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:45:17 +0700 Subject: [PATCH 10/58] =?UTF-8?q?=D1=81hore:=20Tasks=20and=20Enhancements?= =?UTF-8?q?=20sections=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-notes.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 3c9f82a..e8d8ef5 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -13,19 +13,19 @@ const octokit = new Octokit({ auth: token }); const SECTIONS = { "[Task]": "πŸš€ Tasks", + "[Composite]": "πŸš€ Tasks", "[Feat]": "✨ New Features", "[Enhancement]": "πŸ”§ Enhancements", + "[UX/UI]": "πŸ”§ Enhancements", "[Bug]": "🐞 Bug Fixes", "[Refactor]": "♻️ Refactoring", "[Docs]": "πŸ“š Documentation", "[Test]": "πŸ§ͺ Tests", "[Chore]": "🧹 Chores", - "[UX/UI]": "πŸ”§ UX/UI Improvements", - "[Composite]": "πŸ“¦ Composite", - Other: "πŸ“¦ Other", + Other: "πŸ“¦ Other PRs", }; -const PREFIXES = ["Task", "Feat", "Enhancement", "Bug", "Refactor", "Docs", "Test", "Chore", "UX/UI", "Composite"]; +const PREFIXES = ["Task", "Composite", "Feat", "Enhancement", "UX/UI", "Bug", "Refactor", "Docs", "Test", "Chore"]; const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { From 863c25edf5dc8421c1ec7ba7a41ab3fadf7ef008 Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 18 Oct 2025 12:48:49 +0700 Subject: [PATCH 11/58] feat: add marker for title --- .github/scripts/release-notes.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index e8d8ef5..99ff089 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -22,7 +22,7 @@ const SECTIONS = { "[Docs]": "πŸ“š Documentation", "[Test]": "πŸ§ͺ Tests", "[Chore]": "🧹 Chores", - Other: "πŸ“¦ Other PRs", + Other: "πŸ“¦ Other", }; const PREFIXES = ["Task", "Composite", "Feat", "Enhancement", "UX/UI", "Bug", "Refactor", "Docs", "Test", "Chore"]; @@ -104,14 +104,14 @@ async function main() { if (issue.isStandalone) { const pr = issue.prs[0]; const prLine = section === "Other" - ? `${pr.title} (#${pr.number}) by @${pr.user.login}` - : `${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; + ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` + : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; sectionGroups[section].push(prLine); } else { const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); const issueLine = section === "Other" - ? `${title} (#${issue.number})\n ↳ PRs: ${prRefs}` - : `${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; + ? `β€’ ${title} (#${issue.number})\n ↳ PRs: ${prRefs}` + : `β€’ ${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; sectionGroups[section].push(issueLine); } } From a01c05cabff2faa95bef91155165133010e62009 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 22 Oct 2025 20:01:09 +0700 Subject: [PATCH 12/58] feat: change workflow trigger --- .github/workflows/custom-release-draft.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml index 9b9eba5..705d819 100644 --- a/.github/workflows/custom-release-draft.yml +++ b/.github/workflows/custom-release-draft.yml @@ -1,14 +1,9 @@ name: Custom Release Draft on: - workflow_call: - inputs: - target_branch: - required: false - type: string push: branches: - - feat/release-drafter + - master jobs: release-draft: From 2500f55753caf9001e6c8adb590d3b47adb0dc4b Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 22 Oct 2025 20:43:48 +0700 Subject: [PATCH 13/58] feat: add reusable for workflow --- .github/workflows/custom-release-draft.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml index 705d819..e1fbcac 100644 --- a/.github/workflows/custom-release-draft.yml +++ b/.github/workflows/custom-release-draft.yml @@ -1,6 +1,10 @@ name: Custom Release Draft on: + workflow_call: + secrets: + GITHUB_TOKEN: + required: true push: branches: - master From e002831a4b653772d1d419e31245c534bf36d09b Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 22 Oct 2025 20:54:40 +0700 Subject: [PATCH 14/58] feat: add reusable for workflow --- .github/workflows/custom-release-draft.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml index e1fbcac..5a93056 100644 --- a/.github/workflows/custom-release-draft.yml +++ b/.github/workflows/custom-release-draft.yml @@ -2,9 +2,10 @@ name: Custom Release Draft on: workflow_call: - secrets: - GITHUB_TOKEN: - required: true + inputs: + target_branch: + required: false + type: string push: branches: - master From 123f5568d80c2cb6d9015f67278215d14a8c87b1 Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 23 Oct 2025 20:48:40 +0700 Subject: [PATCH 15/58] feat: from dev to master prs --- .github/scripts/release-notes.js | 76 ++++++++------------------------ 1 file changed, 18 insertions(+), 58 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 99ff089..f28531c 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -25,13 +25,11 @@ const SECTIONS = { Other: "πŸ“¦ Other", }; -const PREFIXES = ["Task", "Composite", "Feat", "Enhancement", "UX/UI", "Bug", "Refactor", "Docs", "Test", "Chore"]; - +const PREFIXES = ["Task","Composite","Feat","Enhancement","UX/UI","Bug","Refactor","Docs","Test","Chore","Fix"]; const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { const norm = `[${p}]`; const lower = p.toLowerCase(); - PREFIX_ALIASES[`[${lower}]`] = norm; PREFIX_ALIASES[lower] = norm; PREFIX_ALIASES[`${lower}:`] = norm; @@ -40,21 +38,24 @@ PREFIXES.forEach(p => { }); function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z]+:\s*/i, "").trim(); + return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); } function getPrefix(title) { if (!title) return null; + const matchBracket = title.match(/^\[([^\]]+)\]/); if (matchBracket) { const norm = PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; if (norm) return norm; } - const matchWord = title.match(/^([a-z/]+):?/i); + + const matchWord = title.match(/^([a-z/]+):/i); if (matchWord) { const norm = PREFIX_ALIASES[matchWord[1].toLowerCase()]; if (norm) return norm; } + return null; } @@ -62,67 +63,26 @@ async function main() { const { data: prs } = await octokit.pulls.list({ owner, repo, - state: "closed", - per_page: 100, - }); - - const { data: issues } = await octokit.issues.listForRepo({ - owner, - repo, - state: "closed", + state: "open", + base: "master", + head: "dev", per_page: 100, }); - const issueMap = new Map(); - for (const issue of issues) issueMap.set(issue.number, { ...issue, prs: [] }); - - for (const pr of prs) { - if (!pr.merged_at) continue; - - const linkedIssues = (pr.body?.match(/#(\d+)/g) || []) - .map(s => parseInt(s.replace("#", ""), 10)) - .filter(n => issueMap.has(n)); - - if (linkedIssues.length) { - for (const id of linkedIssues) issueMap.get(id).prs.push(pr); - } else { - issueMap.set(`pr-${pr.number}`, { title: pr.title, prs: [pr], isStandalone: true }); - } + if (!prs.length) { + console.log("No open PRs from dev to master found."); + return; } let body = "# πŸš€ Release Notes\n\n"; - const sectionGroups = {}; - for (const key of Object.keys(SECTIONS)) sectionGroups[key] = []; - - for (const issue of issueMap.values()) { - if (!issue.prs.length && !issue.isStandalone) continue; - - const title = issue.title || ""; - const prefix = getPrefix(title) || getPrefix(issue.prs?.[0]?.title || "") || "Other"; + for (const pr of prs) { + const prefix = getPrefix(pr.title) || "Other"; const section = SECTIONS[prefix] ? prefix : "Other"; + const prLine = section === "Other" + ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` + : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - if (issue.isStandalone) { - const pr = issue.prs[0]; - const prLine = section === "Other" - ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` - : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - sectionGroups[section].push(prLine); - } else { - const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); - const issueLine = section === "Other" - ? `β€’ ${title} (#${issue.number})\n ↳ PRs: ${prRefs}` - : `β€’ ${prefix} ${stripPrefix(title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; - sectionGroups[section].push(issueLine); - } - } - - const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; - - for (const key of orderedSections) { - const items = sectionGroups[key]; - if (items?.length) { - body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; - } + body += `## ${SECTIONS[section]}\n${prLine}\n\n`; } const { data: releases } = await octokit.repos.listReleases({ owner, repo }); From 232b62751a06b494b73db29a15e0a4456c29e0ed Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 23 Oct 2025 23:19:30 +0700 Subject: [PATCH 16/58] fix: from dev to master prs --- .github/scripts/release-notes.js | 59 +++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index f28531c..ac98ccb 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -25,7 +25,11 @@ const SECTIONS = { Other: "πŸ“¦ Other", }; -const PREFIXES = ["Task","Composite","Feat","Enhancement","UX/UI","Bug","Refactor","Docs","Test","Chore","Fix"]; +const PREFIXES = [ + "Task", "Composite", "Feat", "Enhancement", "UX/UI", + "Bug", "Refactor", "Docs", "Test", "Chore", "Fix" +]; + const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { const norm = `[${p}]`; @@ -43,46 +47,75 @@ function stripPrefix(title) { function getPrefix(title) { if (!title) return null; - const matchBracket = title.match(/^\[([^\]]+)\]/); if (matchBracket) { const norm = PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; if (norm) return norm; } - const matchWord = title.match(/^([a-z/]+):/i); if (matchWord) { const norm = PREFIX_ALIASES[matchWord[1].toLowerCase()]; if (norm) return norm; } - return null; } async function main() { - const { data: prs } = await octokit.pulls.list({ + const { data: masterCommits } = await octokit.repos.listCommits({ + owner, + repo, + sha: "master", + per_page: 100, + }); + const masterShaSet = new Set(masterCommits.map(c => c.sha)); + + const { data: devPRs } = await octokit.pulls.list({ owner, repo, - state: "open", - base: "master", - head: "dev", + state: "closed", + base: "dev", per_page: 100, }); - if (!prs.length) { - console.log("No open PRs from dev to master found."); + const pendingPRs = []; + for (const pr of devPRs) { + const { data: prCommits } = await octokit.pulls.listCommits({ + owner, + repo, + pull_number: pr.number, + }); + if (prCommits.some(c => !masterShaSet.has(c.sha))) { + pendingPRs.push(pr); + } + } + + if (!pendingPRs.length) { + console.log("No PRs in dev that are not yet in master."); return; } - let body = "# πŸš€ Release Notes\n\n"; - for (const pr of prs) { + let sectionGroups = {}; + Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); + + for (const pr of pendingPRs) { const prefix = getPrefix(pr.title) || "Other"; const section = SECTIONS[prefix] ? prefix : "Other"; + const prLine = section === "Other" ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - body += `## ${SECTIONS[section]}\n${prLine}\n\n`; + sectionGroups[section].push(prLine); + } + + const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; + + let body = "# πŸš€ Release Notes\n\n"; + for (const key of orderedSections) { + const items = sectionGroups[key]; + if (items?.length) { + body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; + } } const { data: releases } = await octokit.repos.listReleases({ owner, repo }); From 7bb5cf47d1bdaf78a81f607e72597520b60a8903 Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 23 Oct 2025 23:32:43 +0700 Subject: [PATCH 17/58] fix: compare dev and master branches --- .github/scripts/release-notes.js | 44 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index ac98ccb..a6adc09 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -60,31 +60,45 @@ function getPrefix(title) { return null; } +async function fetchAllPRs() { + const prs = []; + let page = 1; + while (true) { + const { data } = await octokit.pulls.list({ + owner, + repo, + state: "closed", + per_page: 100, + page, + }); + if (!data.length) break; + prs.push(...data); + page++; + } + return prs; +} + async function main() { - const { data: masterCommits } = await octokit.repos.listCommits({ + const { data: compare } = await octokit.repos.compareCommits({ owner, repo, - sha: "master", - per_page: 100, + base: "master", + head: "dev", }); - const masterShaSet = new Set(masterCommits.map(c => c.sha)); - const { data: devPRs } = await octokit.pulls.list({ - owner, - repo, - state: "closed", - base: "dev", - per_page: 100, - }); + const devShas = new Set(compare.commits.map(c => c.sha)); + + const allPRs = await fetchAllPRs(); const pendingPRs = []; - for (const pr of devPRs) { + for (const pr of allPRs) { const { data: prCommits } = await octokit.pulls.listCommits({ owner, repo, - pull_number: pr.number, + pull_number: pr.number }); - if (prCommits.some(c => !masterShaSet.has(c.sha))) { + + if (prCommits.some(c => devShas.has(c.sha))) { pendingPRs.push(pr); } } @@ -94,7 +108,7 @@ async function main() { return; } - let sectionGroups = {}; + const sectionGroups = {}; Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); for (const pr of pendingPRs) { From ffc5beb4de57145c7160745781bc77460e71bd94 Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 23 Oct 2025 23:53:57 +0700 Subject: [PATCH 18/58] fix: compare dev and last tag --- .github/scripts/release-notes.js | 65 ++++++++++++++------------------ 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index a6adc09..c88033d 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -11,6 +11,7 @@ if (!token || !repoFull) { const octokit = new Octokit({ auth: token }); +// Section definitions and prefixes const SECTIONS = { "[Task]": "πŸš€ Tasks", "[Composite]": "πŸš€ Tasks", @@ -41,10 +42,12 @@ PREFIXES.forEach(p => { PREFIX_ALIASES[`${p}:`] = norm; }); +// Strip prefix from title function stripPrefix(title) { return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); } +// Extract normalized prefix from title function getPrefix(title) { if (!title) return null; const matchBracket = title.match(/^\[([^\]]+)\]/); @@ -60,54 +63,43 @@ function getPrefix(title) { return null; } -async function fetchAllPRs() { - const prs = []; - let page = 1; - while (true) { - const { data } = await octokit.pulls.list({ - owner, - repo, - state: "closed", - per_page: 100, - page, - }); - if (!data.length) break; - prs.push(...data); - page++; - } - return prs; -} - async function main() { + // 1. Get latest release (tag) on master + const { data: releases } = await octokit.repos.listReleases({ owner, repo }); + const latestRelease = releases.find(r => !r.draft); + const latestTag = latestRelease?.tag_name; + + // 2. Compare dev vs last release tag to get all commits after last release const { data: compare } = await octokit.repos.compareCommits({ owner, repo, - base: "master", + base: latestTag || "master", head: "dev", }); - const devShas = new Set(compare.commits.map(c => c.sha)); - - const allPRs = await fetchAllPRs(); + const devShas = compare.commits.map(c => c.sha); + // 3. Find PRs associated with these commits const pendingPRs = []; - for (const pr of allPRs) { - const { data: prCommits } = await octokit.pulls.listCommits({ + for (const sha of devShas) { + const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ owner, repo, - pull_number: pr.number + commit_sha: sha, + }); + prs.forEach(pr => { + if (pr.merged_at && !pendingPRs.some(p => p.number === pr.number)) { + pendingPRs.push(pr); + } }); - - if (prCommits.some(c => devShas.has(c.sha))) { - pendingPRs.push(pr); - } } if (!pendingPRs.length) { - console.log("No PRs in dev that are not yet in master."); + console.log("No merged PRs in dev after last release found."); return; } + // 4. Group PRs by section const sectionGroups = {}; Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); @@ -122,6 +114,7 @@ async function main() { sectionGroups[section].push(prLine); } + // 5. Order sections: Tasks first, Other last const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; let body = "# πŸš€ Release Notes\n\n"; @@ -132,19 +125,19 @@ async function main() { } } - const { data: releases } = await octokit.repos.listReleases({ owner, repo }); - let draft = releases.find(r => r.draft); - + // 6. Determine next patch version let nextVersion = "v0.1.0"; - const latest = releases.find(r => !r.draft) || releases[0]; - if (latest) { - const match = latest.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); + const latestNonDraft = releases.find(r => !r.draft) || releases[0]; + if (latestNonDraft) { + const match = latestNonDraft.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); if (match) { const [_, major, minor, patch] = match.map(Number); nextVersion = `v${major}.${minor}.${patch + 1}`; } } + // 7. Create or update draft release + const draft = releases.find(r => r.draft); if (draft) { console.log("Updating existing draft release:", draft.tag_name); await octokit.repos.updateRelease({ From d8c12db81a96123ab4b100f9dc992071ab0e33dd Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:05:29 +0700 Subject: [PATCH 19/58] fix: fix prefixes --- .github/scripts/release-notes.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index c88033d..b0d21c7 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -35,13 +35,21 @@ const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { const norm = `[${p}]`; const lower = p.toLowerCase(); + + // Bracket variants PREFIX_ALIASES[`[${lower}]`] = norm; + PREFIX_ALIASES[`[${p}]`] = norm; + + // Plain word variants PREFIX_ALIASES[lower] = norm; - PREFIX_ALIASES[`${lower}:`] = norm; PREFIX_ALIASES[p] = norm; + + // Colon variants + PREFIX_ALIASES[`${lower}:`] = norm; PREFIX_ALIASES[`${p}:`] = norm; }); + // Strip prefix from title function stripPrefix(title) { return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); From 2e05a19c92838412a334a0272867ce0fef4cb091 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:12:18 +0700 Subject: [PATCH 20/58] fix: link issues --- .github/scripts/release-notes.js | 67 +++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index b0d21c7..cc95551 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -35,27 +35,18 @@ const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { const norm = `[${p}]`; const lower = p.toLowerCase(); - - // Bracket variants PREFIX_ALIASES[`[${lower}]`] = norm; PREFIX_ALIASES[`[${p}]`] = norm; - - // Plain word variants PREFIX_ALIASES[lower] = norm; PREFIX_ALIASES[p] = norm; - - // Colon variants PREFIX_ALIASES[`${lower}:`] = norm; PREFIX_ALIASES[`${p}:`] = norm; }); - -// Strip prefix from title function stripPrefix(title) { return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); } -// Extract normalized prefix from title function getPrefix(title) { if (!title) return null; const matchBracket = title.match(/^\[([^\]]+)\]/); @@ -75,19 +66,19 @@ async function main() { // 1. Get latest release (tag) on master const { data: releases } = await octokit.repos.listReleases({ owner, repo }); const latestRelease = releases.find(r => !r.draft); - const latestTag = latestRelease?.tag_name; + const latestTag = latestRelease?.tag_name || "master"; // 2. Compare dev vs last release tag to get all commits after last release const { data: compare } = await octokit.repos.compareCommits({ owner, repo, - base: latestTag || "master", + base: latestTag, head: "dev", }); const devShas = compare.commits.map(c => c.sha); - // 3. Find PRs associated with these commits + // 3. Collect all merged PRs in dev after last release const pendingPRs = []; for (const sha of devShas) { const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ @@ -107,33 +98,63 @@ async function main() { return; } - // 4. Group PRs by section + // 4. Map issues -> PRs + const issueMap = new Map(); // key: issue number, value: { issue, prs[] } + const standalonePRs = []; + + for (const pr of pendingPRs) { + // Get linked issues via GitHub API + const { data: linkedIssues } = await octokit.pulls.listIssuesAssociatedWithPullRequest({ + owner, + repo, + pull_number: pr.number, + }); + + if (linkedIssues.length) { + linkedIssues.forEach(issue => { + if (!issueMap.has(issue.number)) issueMap.set(issue.number, { issue, prs: [] }); + issueMap.get(issue.number).prs.push(pr); + }); + } else { + standalonePRs.push(pr); + } + } + + // 5. Group by section const sectionGroups = {}; Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); - for (const pr of pendingPRs) { + // 5a. Issues with PRs + for (const { issue, prs } of issueMap.values()) { + const prefix = getPrefix(issue.title) || getPrefix(prs[0].title) || "Other"; + const section = SECTIONS[prefix] ? prefix : "Other"; + + const prRefs = prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); + const line = `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; + sectionGroups[section].push(line); + } + + // 5b. Standalone PRs + for (const pr of standalonePRs) { const prefix = getPrefix(pr.title) || "Other"; const section = SECTIONS[prefix] ? prefix : "Other"; - const prLine = section === "Other" + const line = section === "Other" ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - sectionGroups[section].push(prLine); + sectionGroups[section].push(line); } - // 5. Order sections: Tasks first, Other last + // 6. Assemble release notes const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; - let body = "# πŸš€ Release Notes\n\n"; for (const key of orderedSections) { const items = sectionGroups[key]; - if (items?.length) { - body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; - } + if (items?.length) body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; } - // 6. Determine next patch version + // 7. Determine next patch version let nextVersion = "v0.1.0"; const latestNonDraft = releases.find(r => !r.draft) || releases[0]; if (latestNonDraft) { @@ -144,7 +165,7 @@ async function main() { } } - // 7. Create or update draft release + // 8. Create or update draft release const draft = releases.find(r => r.draft); if (draft) { console.log("Updating existing draft release:", draft.tag_name); From 14aae5e18ed0794a12015786c435e33699d5b994 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:19:54 +0700 Subject: [PATCH 21/58] fix: fix get issues --- .github/scripts/release-notes.js | 63 ++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index cc95551..64f3759 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -35,18 +35,26 @@ const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { const norm = `[${p}]`; const lower = p.toLowerCase(); + + // Bracket variants PREFIX_ALIASES[`[${lower}]`] = norm; PREFIX_ALIASES[`[${p}]`] = norm; + + // Plain word variants PREFIX_ALIASES[lower] = norm; PREFIX_ALIASES[p] = norm; + + // Colon variants PREFIX_ALIASES[`${lower}:`] = norm; PREFIX_ALIASES[`${p}:`] = norm; }); +// Strip prefix from title function stripPrefix(title) { return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); } +// Extract normalized prefix from title function getPrefix(title) { if (!title) return null; const matchBracket = title.match(/^\[([^\]]+)\]/); @@ -62,6 +70,12 @@ function getPrefix(title) { return null; } +// Extract issue numbers from PR body +function extractIssueNumbers(prBody) { + if (!prBody) return []; + return Array.from(prBody.matchAll(/#(\d+)/g), m => parseInt(m[1], 10)); +} + async function main() { // 1. Get latest release (tag) on master const { data: releases } = await octokit.repos.listReleases({ owner, repo }); @@ -78,7 +92,7 @@ async function main() { const devShas = compare.commits.map(c => c.sha); - // 3. Collect all merged PRs in dev after last release + // 3. Find PRs associated with these commits const pendingPRs = []; for (const sha of devShas) { const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ @@ -98,60 +112,61 @@ async function main() { return; } - // 4. Map issues -> PRs - const issueMap = new Map(); // key: issue number, value: { issue, prs[] } + // 4. Map issues to PRs + const issueMap = new Map(); const standalonePRs = []; for (const pr of pendingPRs) { - // Get linked issues via GitHub API - const { data: linkedIssues } = await octokit.pulls.listIssuesAssociatedWithPullRequest({ - owner, - repo, - pull_number: pr.number, - }); - - if (linkedIssues.length) { - linkedIssues.forEach(issue => { + const issueNumbers = extractIssueNumbers(pr.body); + if (issueNumbers.length) { + for (const num of issueNumbers) { + const { data: issue } = await octokit.issues.get({ owner, repo, issue_number: num }); if (!issueMap.has(issue.number)) issueMap.set(issue.number, { issue, prs: [] }); issueMap.get(issue.number).prs.push(pr); - }); + } } else { standalonePRs.push(pr); } } - // 5. Group by section + // 5. Group by sections const sectionGroups = {}; Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); - // 5a. Issues with PRs - for (const { issue, prs } of issueMap.values()) { - const prefix = getPrefix(issue.title) || getPrefix(prs[0].title) || "Other"; + for (const [issueNum, obj] of issueMap.entries()) { + const issue = obj.issue; + const prs = obj.prs; + const prefix = getPrefix(issue.title) || getPrefix(prs[0]?.title) || "Other"; const section = SECTIONS[prefix] ? prefix : "Other"; + const prAuthors = prs.map(p => `@${p.user.login}`).join(", "); + const prRefs = prs.map(p => `#${p.number}`).join(", "); + + const line = prs.length > 0 + ? `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs} by ${prAuthors}` + : `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})`; - const prRefs = prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); - const line = `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; sectionGroups[section].push(line); } - // 5b. Standalone PRs + // Special case: PRs without issues for (const pr of standalonePRs) { const prefix = getPrefix(pr.title) || "Other"; const section = SECTIONS[prefix] ? prefix : "Other"; - const line = section === "Other" ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - sectionGroups[section].push(line); } - // 6. Assemble release notes + // 6. Order sections: Tasks first, Other last const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; + let body = "# πŸš€ Release Notes\n\n"; for (const key of orderedSections) { const items = sectionGroups[key]; - if (items?.length) body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; + if (items?.length) { + body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; + } } // 7. Determine next patch version From 333e114a642e82a6ec8e9955630a0a8660811aba Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:25:09 +0700 Subject: [PATCH 22/58] fix: try to fix prefixes --- .github/scripts/release-notes.js | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 64f3759..f18d72e 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -36,17 +36,12 @@ PREFIXES.forEach(p => { const norm = `[${p}]`; const lower = p.toLowerCase(); - // Bracket variants - PREFIX_ALIASES[`[${lower}]`] = norm; - PREFIX_ALIASES[`[${p}]`] = norm; - - // Plain word variants PREFIX_ALIASES[lower] = norm; PREFIX_ALIASES[p] = norm; - - // Colon variants PREFIX_ALIASES[`${lower}:`] = norm; PREFIX_ALIASES[`${p}:`] = norm; + PREFIX_ALIASES[`[${lower}]`] = norm; + PREFIX_ALIASES[`[${p}]`] = norm; }); // Strip prefix from title @@ -57,19 +52,16 @@ function stripPrefix(title) { // Extract normalized prefix from title function getPrefix(title) { if (!title) return null; - const matchBracket = title.match(/^\[([^\]]+)\]/); - if (matchBracket) { - const norm = PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; - if (norm) return norm; - } - const matchWord = title.match(/^([a-z/]+):/i); - if (matchWord) { - const norm = PREFIX_ALIASES[matchWord[1].toLowerCase()]; - if (norm) return norm; + const cleanTitle = title.trim(); + const match = cleanTitle.match(/^(\[[^\]]+\]|[a-z\/]+):?/i); + if (match) { + const candidate = match[1].replace(/[\[\]]/g, "").toLowerCase(); + return PREFIX_ALIASES[candidate] || PREFIX_ALIASES[match[1]] || null; } return null; } + // Extract issue numbers from PR body function extractIssueNumbers(prBody) { if (!prBody) return []; From 330e53a716cedce263488b8c71c64fa1eeac4781 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:44:47 +0700 Subject: [PATCH 23/58] fix: return test branch --- .github/workflows/custom-release-draft.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml index 5a93056..fc05a16 100644 --- a/.github/workflows/custom-release-draft.yml +++ b/.github/workflows/custom-release-draft.yml @@ -9,6 +9,7 @@ on: push: branches: - master + - feat/release-drafter jobs: release-draft: From 41bd46991930a6c81312a14e5b8e70c8e0fd2612 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 00:58:20 +0700 Subject: [PATCH 24/58] fix: map issues --- .github/scripts/release-notes.js | 112 ++++++++++++++----------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index f18d72e..3f77e78 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -26,55 +26,41 @@ const SECTIONS = { Other: "πŸ“¦ Other", }; -const PREFIXES = [ - "Task", "Composite", "Feat", "Enhancement", "UX/UI", - "Bug", "Refactor", "Docs", "Test", "Chore", "Fix" -]; +const PREFIXES = Object.keys(SECTIONS).filter(p => p !== "Other"); +// Build prefix aliases (lowercase, colon, brackets) const PREFIX_ALIASES = {}; PREFIXES.forEach(p => { - const norm = `[${p}]`; - const lower = p.toLowerCase(); - - PREFIX_ALIASES[lower] = norm; - PREFIX_ALIASES[p] = norm; - PREFIX_ALIASES[`${lower}:`] = norm; - PREFIX_ALIASES[`${p}:`] = norm; - PREFIX_ALIASES[`[${lower}]`] = norm; - PREFIX_ALIASES[`[${p}]`] = norm; + const clean = p.replace(/[\[\]]/g, ""); + PREFIX_ALIASES[clean.toLowerCase()] = p; + PREFIX_ALIASES[`${clean.toLowerCase()}:`] = p; + PREFIX_ALIASES[clean] = p; + PREFIX_ALIASES[`${clean}:`] = p; }); // Strip prefix from title function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); + return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z🎨\/]+:\s*/i, "").trim(); } // Extract normalized prefix from title function getPrefix(title) { if (!title) return null; - const cleanTitle = title.trim(); - const match = cleanTitle.match(/^(\[[^\]]+\]|[a-z\/]+):?/i); + const match = title.match(/^(\[[^\]]+\]|[a-z🎨\/]+):?/i); if (match) { const candidate = match[1].replace(/[\[\]]/g, "").toLowerCase(); - return PREFIX_ALIASES[candidate] || PREFIX_ALIASES[match[1]] || null; + return PREFIX_ALIASES[candidate] || "Other"; } - return null; -} - - -// Extract issue numbers from PR body -function extractIssueNumbers(prBody) { - if (!prBody) return []; - return Array.from(prBody.matchAll(/#(\d+)/g), m => parseInt(m[1], 10)); + return "Other"; } async function main() { - // 1. Get latest release (tag) on master + // 1. Get latest release on master const { data: releases } = await octokit.repos.listReleases({ owner, repo }); const latestRelease = releases.find(r => !r.draft); const latestTag = latestRelease?.tag_name || "master"; - // 2. Compare dev vs last release tag to get all commits after last release + // 2. Compare dev vs latest release to get commits const { data: compare } = await octokit.repos.compareCommits({ owner, repo, @@ -84,7 +70,7 @@ async function main() { const devShas = compare.commits.map(c => c.sha); - // 3. Find PRs associated with these commits + // 3. Find all merged PRs in dev not in master const pendingPRs = []; for (const sha of devShas) { const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ @@ -104,56 +90,58 @@ async function main() { return; } - // 4. Map issues to PRs - const issueMap = new Map(); - const standalonePRs = []; + // 4. Map issues β†’ PRs + const issueMap = new Map(); // issueNumber -> { issue, prs[] } for (const pr of pendingPRs) { - const issueNumbers = extractIssueNumbers(pr.body); - if (issueNumbers.length) { - for (const num of issueNumbers) { - const { data: issue } = await octokit.issues.get({ owner, repo, issue_number: num }); - if (!issueMap.has(issue.number)) issueMap.set(issue.number, { issue, prs: [] }); + // Fetch issues linked to this PR + const { data: linkedIssues } = await octokit.pulls.listIssuesAssociatedWithPullRequest({ + owner, + repo, + pull_number: pr.number, + }); + + if (linkedIssues.length) { + linkedIssues.forEach(issue => { + if (!issueMap.has(issue.number)) { + issueMap.set(issue.number, { issue, prs: [] }); + } issueMap.get(issue.number).prs.push(pr); - } + }); } else { - standalonePRs.push(pr); + // PR without linked issue β†’ special case + issueMap.set(`pr-${pr.number}`, { prs: [pr], isStandalone: true }); } } - // 5. Group by sections + // 5. Group PRs/issues by section const sectionGroups = {}; Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); - for (const [issueNum, obj] of issueMap.entries()) { - const issue = obj.issue; - const prs = obj.prs; - const prefix = getPrefix(issue.title) || getPrefix(prs[0]?.title) || "Other"; - const section = SECTIONS[prefix] ? prefix : "Other"; - const prAuthors = prs.map(p => `@${p.user.login}`).join(", "); - const prRefs = prs.map(p => `#${p.number}`).join(", "); - - const line = prs.length > 0 - ? `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs} by ${prAuthors}` - : `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})`; + for (const [key, entry] of issueMap.entries()) { + if (entry.isStandalone) { + const pr = entry.prs[0]; + const prefix = getPrefix(pr.title); + const section = SECTIONS[prefix] ? prefix : "Other"; + sectionGroups[section].push(`β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`); + } else { + const issue = entry.issue; + const prefix = getPrefix(issue.title) || getPrefix(entry.prs[0]?.title) || "Other"; + const section = SECTIONS[prefix] ? prefix : "Other"; - sectionGroups[section].push(line); - } + const prRefs = entry.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); + const issueLine = entry.prs.length > 0 + ? `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}` + : `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})`; - // Special case: PRs without issues - for (const pr of standalonePRs) { - const prefix = getPrefix(pr.title) || "Other"; - const section = SECTIONS[prefix] ? prefix : "Other"; - const line = section === "Other" - ? `β€’ ${pr.title} (#${pr.number}) by @${pr.user.login}` - : `β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - sectionGroups[section].push(line); + sectionGroups[section].push(issueLine); + } } - // 6. Order sections: Tasks first, Other last + // 6. Build release notes const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; - let body = "# πŸš€ Release Notes\n\n"; + for (const key of orderedSections) { const items = sectionGroups[key]; if (items?.length) { From 63b1a6240714e853251fae2c9da62a0fc15e5a0f Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 24 Oct 2025 01:17:20 +0700 Subject: [PATCH 25/58] fix: get issues --- .github/scripts/release-notes.js | 141 +++++++++++++++---------------- 1 file changed, 67 insertions(+), 74 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 3f77e78..1868e4b 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -11,56 +11,57 @@ if (!token || !repoFull) { const octokit = new Octokit({ auth: token }); -// Section definitions and prefixes +// Sections definitions const SECTIONS = { - "[Task]": "πŸš€ Tasks", - "[Composite]": "πŸš€ Tasks", - "[Feat]": "✨ New Features", - "[Enhancement]": "πŸ”§ Enhancements", - "[UX/UI]": "πŸ”§ Enhancements", - "[Bug]": "🐞 Bug Fixes", - "[Refactor]": "♻️ Refactoring", - "[Docs]": "πŸ“š Documentation", - "[Test]": "πŸ§ͺ Tests", - "[Chore]": "🧹 Chores", - Other: "πŸ“¦ Other", + "πŸš€ Tasks": ["Task", "Composite"], + "✨ New Features": ["Feat"], + "πŸ”§ Enhancements": ["Enhancement", "UX/UI"], + "🐞 Bug Fixes": ["Bug"], + "♻️ Refactoring": ["Refactor"], + "πŸ“š Documentation": ["Docs"], + "πŸ§ͺ Tests": ["Test"], + "🧹 Chores": ["Chore"], + "πŸ“¦ Other": ["Other"], }; -const PREFIXES = Object.keys(SECTIONS).filter(p => p !== "Other"); - -// Build prefix aliases (lowercase, colon, brackets) +// Normalize prefixes for PR titles const PREFIX_ALIASES = {}; -PREFIXES.forEach(p => { - const clean = p.replace(/[\[\]]/g, ""); - PREFIX_ALIASES[clean.toLowerCase()] = p; - PREFIX_ALIASES[`${clean.toLowerCase()}:`] = p; - PREFIX_ALIASES[clean] = p; - PREFIX_ALIASES[`${clean}:`] = p; +Object.keys(SECTIONS).forEach(section => { + SECTIONS[section].forEach(p => { + const lower = p.toLowerCase(); + PREFIX_ALIASES[p] = section; + PREFIX_ALIASES[lower] = section; + PREFIX_ALIASES[`${p}:`] = section; + PREFIX_ALIASES[`${lower}:`] = section; + PREFIX_ALIASES[`[${p}]`] = section; + PREFIX_ALIASES[`[${lower}]`] = section; + }); }); -// Strip prefix from title function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z🎨\/]+:\s*/i, "").trim(); + return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); } -// Extract normalized prefix from title -function getPrefix(title) { - if (!title) return null; - const match = title.match(/^(\[[^\]]+\]|[a-z🎨\/]+):?/i); - if (match) { - const candidate = match[1].replace(/[\[\]]/g, "").toLowerCase(); - return PREFIX_ALIASES[candidate] || "Other"; +function getSectionFromPRTitle(title) { + if (!title) return "πŸ“¦ Other"; + const matchBracket = title.match(/^\[([^\]]+)\]/); + if (matchBracket && PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]) { + return PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; + } + const matchWord = title.match(/^([a-z/]+):?/i); + if (matchWord && PREFIX_ALIASES[matchWord[1].toLowerCase()]) { + return PREFIX_ALIASES[matchWord[1].toLowerCase()]; } - return "Other"; + return "πŸ“¦ Other"; } async function main() { - // 1. Get latest release on master + // 1. Get latest release tag on master const { data: releases } = await octokit.repos.listReleases({ owner, repo }); const latestRelease = releases.find(r => !r.draft); const latestTag = latestRelease?.tag_name || "master"; - // 2. Compare dev vs latest release to get commits + // 2. Compare dev vs last release tag const { data: compare } = await octokit.repos.compareCommits({ owner, repo, @@ -70,7 +71,7 @@ async function main() { const devShas = compare.commits.map(c => c.sha); - // 3. Find all merged PRs in dev not in master + // 3. Find merged PRs associated with these commits const pendingPRs = []; for (const sha of devShas) { const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ @@ -90,77 +91,69 @@ async function main() { return; } - // 4. Map issues β†’ PRs - const issueMap = new Map(); // issueNumber -> { issue, prs[] } + // 4. Build issue map from linked issues + const issueMap = new Map(); for (const pr of pendingPRs) { - // Fetch issues linked to this PR - const { data: linkedIssues } = await octokit.pulls.listIssuesAssociatedWithPullRequest({ + // Get issues linked to PR + const { data: linkedIssues } = await octokit.rest.pulls.listIssuesAssociatedWithPullRequest({ owner, repo, pull_number: pr.number, }); if (linkedIssues.length) { - linkedIssues.forEach(issue => { - if (!issueMap.has(issue.number)) { - issueMap.set(issue.number, { issue, prs: [] }); - } + for (const issue of linkedIssues) { + if (!issueMap.has(issue.number)) issueMap.set(issue.number, { ...issue, prs: [] }); issueMap.get(issue.number).prs.push(pr); - }); + } } else { - // PR without linked issue β†’ special case - issueMap.set(`pr-${pr.number}`, { prs: [pr], isStandalone: true }); + // PR without issue + const key = `pr-${pr.number}`; + issueMap.set(key, { title: pr.title, prs: [pr], isStandalone: true }); } } - // 5. Group PRs/issues by section + // 5. Group by sections const sectionGroups = {}; - Object.keys(SECTIONS).forEach(k => sectionGroups[k] = []); - - for (const [key, entry] of issueMap.entries()) { - if (entry.isStandalone) { - const pr = entry.prs[0]; - const prefix = getPrefix(pr.title); - const section = SECTIONS[prefix] ? prefix : "Other"; - sectionGroups[section].push(`β€’ ${prefix} ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`); + Object.values(SECTIONS).forEach(sec => sectionGroups[sec[0]] = []); + sectionGroups["πŸ“¦ Other"] = []; + + for (const issue of issueMap.values()) { + if (issue.isStandalone) { + const pr = issue.prs[0]; + const section = getSectionFromPRTitle(pr.title); + const line = `β€’ ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; + sectionGroups[section].push(line); } else { - const issue = entry.issue; - const prefix = getPrefix(issue.title) || getPrefix(entry.prs[0]?.title) || "Other"; - const section = SECTIONS[prefix] ? prefix : "Other"; - - const prRefs = entry.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); - const issueLine = entry.prs.length > 0 - ? `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}` - : `β€’ ${prefix} ${stripPrefix(issue.title)} (#${issue.number})`; - - sectionGroups[section].push(issueLine); + // Issue with PRs + const issuePrefix = getSectionFromPRTitle(issue.title); + const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); + const line = `β€’ ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; + sectionGroups[issuePrefix].push(line); } } - // 6. Build release notes - const orderedSections = ["[Task]", ...Object.keys(SECTIONS).filter(k => k !== "[Task]" && k !== "Other"), "Other"]; - let body = "# πŸš€ Release Notes\n\n"; + // 6. Order sections: Tasks first, Other last + const orderedSections = ["πŸš€ Tasks", "✨ New Features", "πŸ”§ Enhancements", "🐞 Bug Fixes", "♻️ Refactoring", "πŸ“š Documentation", "πŸ§ͺ Tests", "🧹 Chores", "πŸ“¦ Other"]; + let body = "# πŸš€ Release Notes\n\n"; for (const key of orderedSections) { const items = sectionGroups[key]; - if (items?.length) { - body += `## ${SECTIONS[key]}\n${items.join("\n")}\n\n`; - } + if (items?.length) body += `## ${key}\n${items.join("\n")}\n\n`; } // 7. Determine next patch version let nextVersion = "v0.1.0"; - const latestNonDraft = releases.find(r => !r.draft) || releases[0]; - if (latestNonDraft) { - const match = latestNonDraft.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); + if (latestRelease) { + const match = latestRelease.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); if (match) { const [_, major, minor, patch] = match.map(Number); nextVersion = `v${major}.${minor}.${patch + 1}`; } } - // 8. Create or update draft release + // 8. Create/update draft release const draft = releases.find(r => r.draft); if (draft) { console.log("Updating existing draft release:", draft.tag_name); From 8691f8bc2bc14ea463fe3d61cee40a5704add859 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 15:53:21 +0700 Subject: [PATCH 26/58] fix: get prs logic --- .github/scripts/release-notes.js | 245 +++++++++++-------------------- 1 file changed, 83 insertions(+), 162 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 1868e4b..de4a307 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,185 +1,106 @@ import { Octokit } from "@octokit/rest"; +import dotenv from "dotenv"; +import { execSync } from "child_process"; -const token = process.env.GITHUB_TOKEN; -const repoFull = process.env.GITHUB_REPOSITORY; -const [owner, repo] = repoFull.split("/"); +dotenv.config(); -if (!token || !repoFull) { - console.error("GITHUB_TOKEN and GITHUB_REPOSITORY must be set"); - process.exit(1); -} - -const octokit = new Octokit({ auth: token }); - -// Sections definitions -const SECTIONS = { - "πŸš€ Tasks": ["Task", "Composite"], - "✨ New Features": ["Feat"], - "πŸ”§ Enhancements": ["Enhancement", "UX/UI"], - "🐞 Bug Fixes": ["Bug"], - "♻️ Refactoring": ["Refactor"], - "πŸ“š Documentation": ["Docs"], - "πŸ§ͺ Tests": ["Test"], - "🧹 Chores": ["Chore"], - "πŸ“¦ Other": ["Other"], -}; - -// Normalize prefixes for PR titles -const PREFIX_ALIASES = {}; -Object.keys(SECTIONS).forEach(section => { - SECTIONS[section].forEach(p => { - const lower = p.toLowerCase(); - PREFIX_ALIASES[p] = section; - PREFIX_ALIASES[lower] = section; - PREFIX_ALIASES[`${p}:`] = section; - PREFIX_ALIASES[`${lower}:`] = section; - PREFIX_ALIASES[`[${p}]`] = section; - PREFIX_ALIASES[`[${lower}]`] = section; - }); -}); - -function stripPrefix(title) { - return title.replace(/^\[[^\]]+\]\s*/, "").replace(/^[a-z/]+:\s*/i, "").trim(); -} - -function getSectionFromPRTitle(title) { - if (!title) return "πŸ“¦ Other"; - const matchBracket = title.match(/^\[([^\]]+)\]/); - if (matchBracket && PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]) { - return PREFIX_ALIASES[`[${matchBracket[1].toLowerCase()}]`]; +// === Detect repository info automatically === +function detectRepo() { + // 1️⃣ If running inside GitHub Actions + if (process.env.GITHUB_REPOSITORY) { + const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + return { owner, repo }; } - const matchWord = title.match(/^([a-z/]+):?/i); - if (matchWord && PREFIX_ALIASES[matchWord[1].toLowerCase()]) { - return PREFIX_ALIASES[matchWord[1].toLowerCase()]; + + // 2️⃣ Otherwise, try reading from local git config + try { + const remoteUrl = execSync("git config --get remote.origin.url").toString().trim(); + // Handles both SSH (git@github.com:org/repo.git) and HTTPS (https://github.com/org/repo.git) + const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+)(?:\.git)?$/); + if (match) { + return { owner: match[1], repo: match[2] }; + } + } catch { + console.warn("⚠️ Could not detect repository from git remote."); } - return "πŸ“¦ Other"; + + throw new Error("❌ Repository could not be detected. Run inside a git repo or set GITHUB_REPOSITORY."); } -async function main() { - // 1. Get latest release tag on master - const { data: releases } = await octokit.repos.listReleases({ owner, repo }); - const latestRelease = releases.find(r => !r.draft); - const latestTag = latestRelease?.tag_name || "master"; - - // 2. Compare dev vs last release tag - const { data: compare } = await octokit.repos.compareCommits({ - owner, - repo, - base: latestTag, - head: "dev", - }); +// === Config === +const DEV_BRANCH = "dev"; +const MASTER_BRANCH = "master"; +const { owner: OWNER, repo: REPO } = detectRepo(); - const devShas = compare.commits.map(c => c.sha); +// === Init GitHub API client === +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - // 3. Find merged PRs associated with these commits - const pendingPRs = []; - for (const sha of devShas) { - const { data: prs } = await octokit.repos.listPullRequestsAssociatedWithCommit({ - owner, - repo, - commit_sha: sha, - }); - prs.forEach(pr => { - if (pr.merged_at && !pendingPRs.some(p => p.number === pr.number)) { - pendingPRs.push(pr); - } +async function main() { + // 1️⃣ Get latest release + let lastRelease = null; + try { + const { data } = await octokit.repos.listReleases({ + owner: OWNER, + repo: REPO, + per_page: 1, }); + lastRelease = data[0] || null; + } catch { + console.log("⚠️ Could not fetch releases β€” maybe there are none yet."); } - if (!pendingPRs.length) { - console.log("No merged PRs in dev after last release found."); - return; - } - - // 4. Build issue map from linked issues - const issueMap = new Map(); - - for (const pr of pendingPRs) { - // Get issues linked to PR - const { data: linkedIssues } = await octokit.rest.pulls.listIssuesAssociatedWithPullRequest({ - owner, - repo, - pull_number: pr.number, - }); - - if (linkedIssues.length) { - for (const issue of linkedIssues) { - if (!issueMap.has(issue.number)) issueMap.set(issue.number, { ...issue, prs: [] }); - issueMap.get(issue.number).prs.push(pr); + const since = lastRelease ? new Date(lastRelease.created_at) : null; + + // 2️⃣ Determine which branch to compare + const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); + const branchNames = branches.data.map((b) => b.name); + + let targetBranch = MASTER_BRANCH; + + // If `dev` exists and has commits after the last release β†’ use it + if (branchNames.includes(DEV_BRANCH) && lastRelease) { + try { + const compare = await octokit.repos.compareCommits({ + owner: OWNER, + repo: REPO, + base: lastRelease.tag_name, + head: DEV_BRANCH, + }); + if (compare.data.commits.length > 0) { + targetBranch = DEV_BRANCH; } - } else { - // PR without issue - const key = `pr-${pr.number}`; - issueMap.set(key, { title: pr.title, prs: [pr], isStandalone: true }); + } catch { + console.warn("⚠️ Failed to compare commits, falling back to master."); } } - // 5. Group by sections - const sectionGroups = {}; - Object.values(SECTIONS).forEach(sec => sectionGroups[sec[0]] = []); - sectionGroups["πŸ“¦ Other"] = []; - - for (const issue of issueMap.values()) { - if (issue.isStandalone) { - const pr = issue.prs[0]; - const section = getSectionFromPRTitle(pr.title); - const line = `β€’ ${stripPrefix(pr.title)} (#${pr.number}) by @${pr.user.login}`; - sectionGroups[section].push(line); - } else { - // Issue with PRs - const issuePrefix = getSectionFromPRTitle(issue.title); - const prRefs = issue.prs.map(pr => `#${pr.number} by @${pr.user.login}`).join(", "); - const line = `β€’ ${stripPrefix(issue.title)} (#${issue.number})\n ↳ PRs: ${prRefs}`; - sectionGroups[issuePrefix].push(line); - } - } - - // 6. Order sections: Tasks first, Other last - const orderedSections = ["πŸš€ Tasks", "✨ New Features", "πŸ”§ Enhancements", "🐞 Bug Fixes", "♻️ Refactoring", "πŸ“š Documentation", "πŸ§ͺ Tests", "🧹 Chores", "πŸ“¦ Other"]; - - let body = "# πŸš€ Release Notes\n\n"; - for (const key of orderedSections) { - const items = sectionGroups[key]; - if (items?.length) body += `## ${key}\n${items.join("\n")}\n\n`; - } + // 3️⃣ Fetch closed PRs for the selected branch + const { data: prs } = await octokit.pulls.list({ + owner: OWNER, + repo: REPO, + state: "closed", + base: targetBranch, + per_page: 100, + }); - // 7. Determine next patch version - let nextVersion = "v0.1.0"; - if (latestRelease) { - const match = latestRelease.tag_name.match(/v(\d+)\.(\d+)\.(\d+)/); - if (match) { - const [_, major, minor, patch] = match.map(Number); - nextVersion = `v${major}.${minor}.${patch + 1}`; - } - } + // 4️⃣ Filter PRs that are merged and newer than the last release + const mergedPRs = prs.filter( + (pr) => pr.merged_at && (!since || new Date(pr.merged_at) > since) + ); - // 8. Create/update draft release - const draft = releases.find(r => r.draft); - if (draft) { - console.log("Updating existing draft release:", draft.tag_name); - await octokit.repos.updateRelease({ - owner, - repo, - release_id: draft.id, - name: nextVersion, - body, - }); - } else { - console.log("Creating new draft release"); - await octokit.repos.createRelease({ - owner, - repo, - tag_name: nextVersion, - name: nextVersion, - body, - draft: true, - }); - } + // 5️⃣ Output + console.log(`πŸ“¦ Repository: ${OWNER}/${REPO}`); + console.log(`πŸ“ Target branch: ${targetBranch}`); + console.log(`πŸ•“ Last release: ${lastRelease ? lastRelease.tag_name : "none"}`); + console.log(`βœ… Found ${mergedPRs.length} merged PRs:\n`); - console.log("βœ… Draft release updated successfully"); + mergedPRs.forEach((pr) => { + console.log(`#${pr.number} ${pr.title} (${pr.user.login}) β€” ${pr.merged_at}`); + console.log(`β†’ ${pr.html_url}\n`); + }); } -main().catch(err => { - console.error(err); +main().catch((err) => { + console.error("Error:", err); process.exit(1); }); From 8c2fc4add1f063dd5dd24493b801deedc5c50465 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 15:56:26 +0700 Subject: [PATCH 27/58] fix: get prs logic --- .github/scripts/release-notes.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index de4a307..e50c544 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,21 +1,17 @@ import { Octokit } from "@octokit/rest"; -import dotenv from "dotenv"; import { execSync } from "child_process"; -dotenv.config(); - // === Detect repository info automatically === function detectRepo() { - // 1️⃣ If running inside GitHub Actions + // 1️⃣ Inside GitHub Actions β†’ use GITHUB_REPOSITORY if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); return { owner, repo }; } - // 2️⃣ Otherwise, try reading from local git config + // 2️⃣ Local fallback β†’ detect from git remote try { const remoteUrl = execSync("git config --get remote.origin.url").toString().trim(); - // Handles both SSH (git@github.com:org/repo.git) and HTTPS (https://github.com/org/repo.git) const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+)(?:\.git)?$/); if (match) { return { owner: match[1], repo: match[2] }; @@ -24,7 +20,7 @@ function detectRepo() { console.warn("⚠️ Could not detect repository from git remote."); } - throw new Error("❌ Repository could not be detected. Run inside a git repo or set GITHUB_REPOSITORY."); + throw new Error("❌ Repository could not be detected."); } // === Config === @@ -46,18 +42,17 @@ async function main() { }); lastRelease = data[0] || null; } catch { - console.log("⚠️ Could not fetch releases β€” maybe there are none yet."); + console.log("⚠️ Could not fetch releases β€” maybe none exist yet."); } const since = lastRelease ? new Date(lastRelease.created_at) : null; - // 2️⃣ Determine which branch to compare + // 2️⃣ Determine branch to use const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map((b) => b.name); let targetBranch = MASTER_BRANCH; - // If `dev` exists and has commits after the last release β†’ use it if (branchNames.includes(DEV_BRANCH) && lastRelease) { try { const compare = await octokit.repos.compareCommits({ @@ -70,11 +65,11 @@ async function main() { targetBranch = DEV_BRANCH; } } catch { - console.warn("⚠️ Failed to compare commits, falling back to master."); + console.warn("⚠️ Could not compare commits, falling back to master."); } } - // 3️⃣ Fetch closed PRs for the selected branch + // 3️⃣ Get closed PRs for target branch const { data: prs } = await octokit.pulls.list({ owner: OWNER, repo: REPO, @@ -83,7 +78,7 @@ async function main() { per_page: 100, }); - // 4️⃣ Filter PRs that are merged and newer than the last release + // 4️⃣ Filter merged PRs after the last release const mergedPRs = prs.filter( (pr) => pr.merged_at && (!since || new Date(pr.merged_at) > since) ); From 2b01188e5bb1db9bcf27d9db6585ec658d715fec Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:11:39 +0700 Subject: [PATCH 28/58] fix: get issues logic --- .github/scripts/release-notes.js | 110 ++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 22 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index e50c544..bb69462 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,15 +1,13 @@ import { Octokit } from "@octokit/rest"; import { execSync } from "child_process"; +import { request } from "@octokit/graphql"; -// === Detect repository info automatically === function detectRepo() { - // 1️⃣ Inside GitHub Actions β†’ use GITHUB_REPOSITORY if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); return { owner, repo }; } - // 2️⃣ Local fallback β†’ detect from git remote try { const remoteUrl = execSync("git config --get remote.origin.url").toString().trim(); const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+)(?:\.git)?$/); @@ -23,13 +21,68 @@ function detectRepo() { throw new Error("❌ Repository could not be detected."); } -// === Config === const DEV_BRANCH = "dev"; const MASTER_BRANCH = "master"; const { owner: OWNER, repo: REPO } = detectRepo(); -// === Init GitHub API client === const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); +const graphqlWithAuth = request.defaults({ + headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, +}); + +// === Helper: get all PRs with pagination === +async function getAllPulls({ owner, repo, base }) { + const perPage = 100; + let page = 1; + let all = []; + + while (true) { + const { data } = await octokit.pulls.list({ + owner, + repo, + state: "closed", + base, + per_page: perPage, + page, + }); + + if (!data.length) break; + all = all.concat(data); + if (data.length < perPage) break; + page++; + } + + return all; +} + +// === Helper: get issues linked to PR via GraphQL === +async function getLinkedIssues(owner, repo, prNumber) { + const query = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 10) { + nodes { + number + title + state + url + } + } + } + } + } + `; + + try { + const response = await graphqlWithAuth(query, { owner, repo, number: prNumber }); + const issues = response.repository.pullRequest.closingIssuesReferences.nodes || []; + return issues; + } catch (err) { + console.warn(`⚠️ Failed to get linked issues for PR #${prNumber}:`, err.message); + return []; + } +} async function main() { // 1️⃣ Get latest release @@ -47,10 +100,9 @@ async function main() { const since = lastRelease ? new Date(lastRelease.created_at) : null; - // 2️⃣ Determine branch to use + // 2️⃣ Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map((b) => b.name); - let targetBranch = MASTER_BRANCH; if (branchNames.includes(DEV_BRANCH) && lastRelease) { @@ -69,30 +121,44 @@ async function main() { } } - // 3️⃣ Get closed PRs for target branch - const { data: prs } = await octokit.pulls.list({ - owner: OWNER, - repo: REPO, - state: "closed", - base: targetBranch, - per_page: 100, - }); - - // 4️⃣ Filter merged PRs after the last release + // 3️⃣ Fetch merged PRs + const prs = await getAllPulls({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter( (pr) => pr.merged_at && (!since || new Date(pr.merged_at) > since) ); + // 4️⃣ Fetch linked issues for each PR + const result = []; + for (const pr of mergedPRs) { + const issues = await getLinkedIssues(OWNER, REPO, pr.number); + + result.push({ + number: pr.number, + title: pr.title, + user: pr.user.login, + merged_at: pr.merged_at, + url: pr.html_url, + issues, + }); + } + // 5️⃣ Output console.log(`πŸ“¦ Repository: ${OWNER}/${REPO}`); console.log(`πŸ“ Target branch: ${targetBranch}`); console.log(`πŸ•“ Last release: ${lastRelease ? lastRelease.tag_name : "none"}`); - console.log(`βœ… Found ${mergedPRs.length} merged PRs:\n`); + console.log(`βœ… Found ${result.length} merged PRs:\n`); - mergedPRs.forEach((pr) => { - console.log(`#${pr.number} ${pr.title} (${pr.user.login}) β€” ${pr.merged_at}`); - console.log(`β†’ ${pr.html_url}\n`); - }); + for (const pr of result) { + console.log(`#${pr.number} ${pr.title} (${pr.user}) β€” ${pr.merged_at}`); + if (pr.issues.length) { + pr.issues.forEach((i) => + console.log(` ↳ #${i.number} ${i.title} (${i.state}) β†’ ${i.url}`) + ); + } else { + console.log(" ↳ no linked issues"); + } + console.log(`β†’ ${pr.url}\n`); + } } main().catch((err) => { From cd32b16565eadbdbac357a1ead32ac3d6d9f1b04 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:17:12 +0700 Subject: [PATCH 29/58] fix: install octokit/graphql --- .github/workflows/custom-release-draft.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/custom-release-draft.yml b/.github/workflows/custom-release-draft.yml index fc05a16..efd1c0e 100644 --- a/.github/workflows/custom-release-draft.yml +++ b/.github/workflows/custom-release-draft.yml @@ -23,7 +23,7 @@ jobs: node-version: 20 - name: Install dependencies - run: npm install @octokit/rest + run: npm install @octokit/rest @octokit/graphql - name: Run release notes script run: node .github/scripts/release-notes.js From ff20d126d4bf37796a1f8365f6e4f3073875ba5f Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:21:24 +0700 Subject: [PATCH 30/58] fix: install octokit/graphql --- .github/scripts/release-notes.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index bb69462..03fe929 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,7 +1,8 @@ import { Octokit } from "@octokit/rest"; +import graphql from "@octokit/graphql"; import { execSync } from "child_process"; -import { request } from "@octokit/graphql"; +// === Detect repository automatically === function detectRepo() { if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); @@ -21,16 +22,18 @@ function detectRepo() { throw new Error("❌ Repository could not be detected."); } +// === Config === const DEV_BRANCH = "dev"; const MASTER_BRANCH = "master"; const { owner: OWNER, repo: REPO } = detectRepo(); +// === Octokit clients === const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); -const graphqlWithAuth = request.defaults({ +const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); -// === Helper: get all PRs with pagination === +// === Fetch all closed PRs with pagination === async function getAllPulls({ owner, repo, base }) { const perPage = 100; let page = 1; @@ -55,7 +58,7 @@ async function getAllPulls({ owner, repo, base }) { return all; } -// === Helper: get issues linked to PR via GraphQL === +// === Fetch linked issues for a PR via GraphQL === async function getLinkedIssues(owner, repo, prNumber) { const query = ` query ($owner: String!, $repo: String!, $number: Int!) { @@ -76,14 +79,14 @@ async function getLinkedIssues(owner, repo, prNumber) { try { const response = await graphqlWithAuth(query, { owner, repo, number: prNumber }); - const issues = response.repository.pullRequest.closingIssuesReferences.nodes || []; - return issues; + return response.repository.pullRequest.closingIssuesReferences.nodes || []; } catch (err) { - console.warn(`⚠️ Failed to get linked issues for PR #${prNumber}:`, err.message); + console.warn(`⚠️ Failed to fetch linked issues for PR #${prNumber}: ${err.message}`); return []; } } +// === Main script === async function main() { // 1️⃣ Get latest release let lastRelease = null; @@ -161,6 +164,7 @@ async function main() { } } +// === Run script === main().catch((err) => { console.error("Error:", err); process.exit(1); From c5e03152997eb3d3e044e25596e14bcaadca35d6 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:26:15 +0700 Subject: [PATCH 31/58] fix: import octokit/graphql --- .github/scripts/release-notes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 03fe929..125aa0d 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,5 +1,5 @@ import { Octokit } from "@octokit/rest"; -import graphql from "@octokit/graphql"; +import { graphql } from "@octokit/graphql"; // <-- Π²ΠΎΡ‚ Ρ‚Π°ΠΊ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΠΎ import { execSync } from "child_process"; // === Detect repository automatically === From 122f82266b46501341ea78ce9657198a2caa1c18 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:34:17 +0700 Subject: [PATCH 32/58] fix: link issues and prs --- .github/scripts/release-notes.js | 101 ++++++++++++------------------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 125aa0d..1af0232 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -1,8 +1,7 @@ import { Octokit } from "@octokit/rest"; -import { graphql } from "@octokit/graphql"; // <-- Π²ΠΎΡ‚ Ρ‚Π°ΠΊ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΠΎ +import { graphql } from "@octokit/graphql"; import { execSync } from "child_process"; -// === Detect repository automatically === function detectRepo() { if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); @@ -22,19 +21,16 @@ function detectRepo() { throw new Error("❌ Repository could not be detected."); } -// === Config === const DEV_BRANCH = "dev"; const MASTER_BRANCH = "master"; const { owner: OWNER, repo: REPO } = detectRepo(); -// === Octokit clients === const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); -// === Fetch all closed PRs with pagination === -async function getAllPulls({ owner, repo, base }) { +async function getAllPRs({ owner, repo, base }) { const perPage = 100; let page = 1; let all = []; @@ -58,8 +54,8 @@ async function getAllPulls({ owner, repo, base }) { return all; } -// === Fetch linked issues for a PR via GraphQL === -async function getLinkedIssues(owner, repo, prNumber) { +// Get linked issues for a PR via GraphQL +async function getLinkedIssues(prNumber) { const query = ` query ($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -67,45 +63,33 @@ async function getLinkedIssues(owner, repo, prNumber) { closingIssuesReferences(first: 10) { nodes { number - title - state - url } } } } } `; - try { - const response = await graphqlWithAuth(query, { owner, repo, number: prNumber }); - return response.repository.pullRequest.closingIssuesReferences.nodes || []; - } catch (err) { - console.warn(`⚠️ Failed to fetch linked issues for PR #${prNumber}: ${err.message}`); + const response = await graphqlWithAuth(query, { owner: OWNER, repo: REPO, number: prNumber }); + return response.repository.pullRequest.closingIssuesReferences.nodes.map(i => i.number); + } catch { return []; } } -// === Main script === async function main() { // 1️⃣ Get latest release let lastRelease = null; try { - const { data } = await octokit.repos.listReleases({ - owner: OWNER, - repo: REPO, - per_page: 1, - }); + const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 1 }); lastRelease = data[0] || null; - } catch { - console.log("⚠️ Could not fetch releases β€” maybe none exist yet."); - } + } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; - // 2️⃣ Determine target branch + // 2️⃣ Determine branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); - const branchNames = branches.data.map((b) => b.name); + const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; if (branchNames.includes(DEV_BRANCH) && lastRelease) { @@ -116,56 +100,49 @@ async function main() { base: lastRelease.tag_name, head: DEV_BRANCH, }); - if (compare.data.commits.length > 0) { - targetBranch = DEV_BRANCH; - } - } catch { - console.warn("⚠️ Could not compare commits, falling back to master."); - } + if (compare.data.commits.length > 0) targetBranch = DEV_BRANCH; + } catch {} } // 3️⃣ Fetch merged PRs - const prs = await getAllPulls({ owner: OWNER, repo: REPO, base: targetBranch }); - const mergedPRs = prs.filter( - (pr) => pr.merged_at && (!since || new Date(pr.merged_at) > since) - ); + const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); + const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); + + // 4️⃣ Build issue β†’ PR map + const issueToPRs = {}; + const prsWithoutIssue = []; - // 4️⃣ Fetch linked issues for each PR - const result = []; for (const pr of mergedPRs) { - const issues = await getLinkedIssues(OWNER, REPO, pr.number); - - result.push({ - number: pr.number, - title: pr.title, - user: pr.user.login, - merged_at: pr.merged_at, - url: pr.html_url, - issues, - }); + const linkedIssues = await getLinkedIssues(pr.number); + if (linkedIssues.length) { + for (const issueNum of linkedIssues) { + if (!issueToPRs[issueNum]) issueToPRs[issueNum] = []; + issueToPRs[issueNum].push(pr.number); + } + } else { + prsWithoutIssue.push(pr); + } } // 5️⃣ Output console.log(`πŸ“¦ Repository: ${OWNER}/${REPO}`); console.log(`πŸ“ Target branch: ${targetBranch}`); console.log(`πŸ•“ Last release: ${lastRelease ? lastRelease.tag_name : "none"}`); - console.log(`βœ… Found ${result.length} merged PRs:\n`); - - for (const pr of result) { - console.log(`#${pr.number} ${pr.title} (${pr.user}) β€” ${pr.merged_at}`); - if (pr.issues.length) { - pr.issues.forEach((i) => - console.log(` ↳ #${i.number} ${i.title} (${i.state}) β†’ ${i.url}`) - ); - } else { - console.log(" ↳ no linked issues"); + console.log(`\nβœ… Issues with linked PRs:\n`); + + for (const [issueNum, prNumbers] of Object.entries(issueToPRs)) { + console.log(`#${issueNum} ↳ PRs: ${prNumbers.map(n => `#${n}`).join(", ")}`); + } + + if (prsWithoutIssue.length) { + console.log(`\nβœ… PRs without linked issues:\n`); + for (const pr of prsWithoutIssue) { + console.log(`#${pr.number} ${pr.title}`); } - console.log(`β†’ ${pr.url}\n`); } } -// === Run script === -main().catch((err) => { +main().catch(err => { console.error("Error:", err); process.exit(1); }); From c9859a63f256926550323b0dd8262c8fba593cbf Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 16:38:45 +0700 Subject: [PATCH 33/58] fix: get issue title --- .github/scripts/release-notes.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 1af0232..5267d2d 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -11,12 +11,8 @@ function detectRepo() { try { const remoteUrl = execSync("git config --get remote.origin.url").toString().trim(); const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+)(?:\.git)?$/); - if (match) { - return { owner: match[1], repo: match[2] }; - } - } catch { - console.warn("⚠️ Could not detect repository from git remote."); - } + if (match) return { owner: match[1], repo: match[2] }; + } catch {} throw new Error("❌ Repository could not be detected."); } @@ -63,6 +59,7 @@ async function getLinkedIssues(prNumber) { closingIssuesReferences(first: 10) { nodes { number + title } } } @@ -71,7 +68,10 @@ async function getLinkedIssues(prNumber) { `; try { const response = await graphqlWithAuth(query, { owner: OWNER, repo: REPO, number: prNumber }); - return response.repository.pullRequest.closingIssuesReferences.nodes.map(i => i.number); + return response.repository.pullRequest.closingIssuesReferences.nodes.map(i => ({ + number: i.number, + title: i.title, + })); } catch { return []; } @@ -109,15 +109,15 @@ async function main() { const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); // 4️⃣ Build issue β†’ PR map - const issueToPRs = {}; + const issueMap = {}; // key: issue number, value: { title, PR numbers } const prsWithoutIssue = []; for (const pr of mergedPRs) { const linkedIssues = await getLinkedIssues(pr.number); if (linkedIssues.length) { - for (const issueNum of linkedIssues) { - if (!issueToPRs[issueNum]) issueToPRs[issueNum] = []; - issueToPRs[issueNum].push(pr.number); + for (const issue of linkedIssues) { + if (!issueMap[issue.number]) issueMap[issue.number] = { title: issue.title, prs: [] }; + issueMap[issue.number].prs.push(pr.number); } } else { prsWithoutIssue.push(pr); @@ -130,8 +130,8 @@ async function main() { console.log(`πŸ•“ Last release: ${lastRelease ? lastRelease.tag_name : "none"}`); console.log(`\nβœ… Issues with linked PRs:\n`); - for (const [issueNum, prNumbers] of Object.entries(issueToPRs)) { - console.log(`#${issueNum} ↳ PRs: ${prNumbers.map(n => `#${n}`).join(", ")}`); + for (const [issueNum, info] of Object.entries(issueMap)) { + console.log(`#${issueNum} ${info.title} ↳ PRs: ${info.prs.map(n => `#${n}`).join(", ")}`); } if (prsWithoutIssue.length) { From 1e86e425973632c2bb625936145c103d7254bad7 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 17:04:28 +0700 Subject: [PATCH 34/58] fix: create sections --- .github/scripts/release-notes.js | 85 +++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 5267d2d..48b5550 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -2,21 +2,21 @@ import { Octokit } from "@octokit/rest"; import { graphql } from "@octokit/graphql"; import { execSync } from "child_process"; +// --- Detect repository --- function detectRepo() { if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); return { owner, repo }; } - try { const remoteUrl = execSync("git config --get remote.origin.url").toString().trim(); const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+)(?:\.git)?$/); if (match) return { owner: match[1], repo: match[2] }; } catch {} - throw new Error("❌ Repository could not be detected."); } +// --- Config --- const DEV_BRANCH = "dev"; const MASTER_BRANCH = "master"; const { owner: OWNER, repo: REPO } = detectRepo(); @@ -26,11 +26,11 @@ const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); +// --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; let page = 1; let all = []; - while (true) { const { data } = await octokit.pulls.list({ owner, @@ -40,17 +40,15 @@ async function getAllPRs({ owner, repo, base }) { per_page: perPage, page, }); - if (!data.length) break; all = all.concat(data); if (data.length < perPage) break; page++; } - return all; } -// Get linked issues for a PR via GraphQL +// --- Get linked issues via GraphQL --- async function getLinkedIssues(prNumber) { const query = ` query ($owner: String!, $repo: String!, $number: Int!) { @@ -77,8 +75,35 @@ async function getLinkedIssues(prNumber) { } } +// --- Classify title by flexible prefix --- +function classifyTitle(title) { + const match = title.match(/^\[?([a-zA-Z\/]+)\]?:?/); + const prefix = match ? match[1].toLowerCase() : null; + + if (!prefix) return "Other"; + + const map = { + "task": "πŸš€ Tasks", + "composite": "πŸš€ Tasks", + "ux/ui": "πŸ”§ Enhancements", + "enhancement": "πŸ”§ Enhancements", + "bug": "🐞 Bug Fixes", + "feat": "✨ New Features", + "refactor": "πŸ›  Refactoring", + "docs": "πŸ“š Documentation", + "test": "βœ… Tests", + "chore": "βš™οΈ Chores", + "proposal": "πŸ’‘ Ideas & Proposals", + "idea": "πŸ’‘ Ideas & Proposals", + "discussion": "πŸ’‘ Ideas & Proposals", + }; + + return map[prefix] || "Other"; +} + +// --- Main function --- async function main() { - // 1️⃣ Get latest release + // 1️⃣ Latest release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 1 }); @@ -87,11 +112,10 @@ async function main() { const since = lastRelease ? new Date(lastRelease.created_at) : null; - // 2️⃣ Determine branch + // 2️⃣ Target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; - if (branchNames.includes(DEV_BRANCH) && lastRelease) { try { const compare = await octokit.repos.compareCommits({ @@ -109,7 +133,7 @@ async function main() { const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); // 4️⃣ Build issue β†’ PR map - const issueMap = {}; // key: issue number, value: { title, PR numbers } + const issueMap = {}; const prsWithoutIssue = []; for (const pr of mergedPRs) { @@ -124,24 +148,41 @@ async function main() { } } - // 5️⃣ Output - console.log(`πŸ“¦ Repository: ${OWNER}/${REPO}`); - console.log(`πŸ“ Target branch: ${targetBranch}`); - console.log(`πŸ•“ Last release: ${lastRelease ? lastRelease.tag_name : "none"}`); - console.log(`\nβœ… Issues with linked PRs:\n`); + // 5️⃣ Classify + const sections = { + "πŸš€ Tasks": [], + "πŸ”§ Enhancements": [], + "🐞 Bug Fixes": [], + "✨ New Features": [], + "πŸ›  Refactoring": [], + "πŸ“š Documentation": [], + "βœ… Tests": [], + "βš™οΈ Chores": [], + "πŸ’‘ Ideas & Proposals": [], + Other: [], + }; + + for (const [num, info] of Object.entries(issueMap)) { + const section = classifyTitle(info.title); + sections[section].push(`#${num} ${info.title} ↳ PRs: ${info.prs.map(n => `#${n}`).join(", ")}`); + } - for (const [issueNum, info] of Object.entries(issueMap)) { - console.log(`#${issueNum} ${info.title} ↳ PRs: ${info.prs.map(n => `#${n}`).join(", ")}`); + for (const pr of prsWithoutIssue) { + const section = classifyTitle(pr.title); + sections[section].push(`#${pr.number} ${pr.title}`); } - if (prsWithoutIssue.length) { - console.log(`\nβœ… PRs without linked issues:\n`); - for (const pr of prsWithoutIssue) { - console.log(`#${pr.number} ${pr.title}`); - } + // 6️⃣ Print release notes + console.log(`## Draft Release Notes\n`); + for (const [sectionName, items] of Object.entries(sections)) { + if (!items.length) continue; + console.log(`### ${sectionName}\n`); + items.forEach(i => console.log(`- ${i}`)); + console.log(""); // blank line } } +// --- Run --- main().catch(err => { console.error("Error:", err); process.exit(1); From 1afeabb02e04440e5249e4bdcec0f357da41847c Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 17:55:52 +0700 Subject: [PATCH 35/58] fix: get prefix with Emoji --- .github/scripts/release-notes.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 48b5550..bcf4661 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,7 +77,8 @@ async function getLinkedIssues(prNumber) { // --- Classify title by flexible prefix --- function classifyTitle(title) { - const match = title.match(/^\[?([a-zA-Z\/]+)\]?:?/); + const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Emoji}\p{Extended_Pictographic}]+/u, ""); + const match = cleaned.match(/^([a-zA-Z\/]+)/); const prefix = match ? match[1].toLowerCase() : null; if (!prefix) return "Other"; From b0f190cdc7e6d2dd6e0eeb7f0e7d1bbfb360f7f2 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 17:58:30 +0700 Subject: [PATCH 36/58] fix: get prefix --- .github/scripts/release-notes.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index bcf4661..1b1a7fb 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,11 +77,11 @@ async function getLinkedIssues(prNumber) { // --- Classify title by flexible prefix --- function classifyTitle(title) { - const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Emoji}\p{Extended_Pictographic}]+/u, ""); - const match = cleaned.match(/^([a-zA-Z\/]+)/); - const prefix = match ? match[1].toLowerCase() : null; + const match = title.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); + const rawPrefix = match ? (match[1] || match[2]) : null; + if (!rawPrefix) return "Other"; - if (!prefix) return "Other"; + const prefix = rawPrefix.toLowerCase(); const map = { "task": "πŸš€ Tasks", @@ -102,6 +102,7 @@ function classifyTitle(title) { return map[prefix] || "Other"; } + // --- Main function --- async function main() { // 1️⃣ Latest release From dd670883a2fb4cb39d883c65fe26ad49b47e2901 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 17:59:55 +0700 Subject: [PATCH 37/58] fix: get prefix --- .github/scripts/release-notes.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 1b1a7fb..2034f07 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,7 +77,9 @@ async function getLinkedIssues(prNumber) { // --- Classify title by flexible prefix --- function classifyTitle(title) { - const match = title.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); + const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); + + const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); const rawPrefix = match ? (match[1] || match[2]) : null; if (!rawPrefix) return "Other"; @@ -103,6 +105,7 @@ function classifyTitle(title) { } + // --- Main function --- async function main() { // 1️⃣ Latest release From 7bd4dd0bc483f493c6a2bf88f981bcb4c48a0ef9 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 18:02:02 +0700 Subject: [PATCH 38/58] fix: prs sort --- .github/scripts/release-notes.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 2034f07..7861568 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -169,7 +169,12 @@ async function main() { for (const [num, info] of Object.entries(issueMap)) { const section = classifyTitle(info.title); - sections[section].push(`#${num} ${info.title} ↳ PRs: ${info.prs.map(n => `#${n}`).join(", ")}`); + sections[section].push( + `#${num} ${info.title} ↳ PRs: ${info.prs + .sort((a, b) => a - b) // сортировка ΠΏΠΎ Π²ΠΎΠ·Ρ€Π°ΡΡ‚Π°Π½ΠΈΡŽ + .map(n => `#${n}`) + .join(", ")}` + ); } for (const pr of prsWithoutIssue) { From 6e156a8dc45a0bc4d111f4f783e3bf601692362d Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 18:11:31 +0700 Subject: [PATCH 39/58] feat: create GitHub release --- .github/scripts/release-notes.js | 40 ++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 7861568..9993715 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,14 +77,13 @@ async function getLinkedIssues(prNumber) { // --- Classify title by flexible prefix --- function classifyTitle(title) { + // remove leading emoji and spaces const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); - const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); const rawPrefix = match ? (match[1] || match[2]) : null; if (!rawPrefix) return "Other"; const prefix = rawPrefix.toLowerCase(); - const map = { "task": "πŸš€ Tasks", "composite": "πŸš€ Tasks", @@ -104,7 +103,16 @@ function classifyTitle(title) { return map[prefix] || "Other"; } +// --- Semantic versioning --- +function nextVersion(lastTag) { + if (!lastTag) return "v0.1.0"; + const match = lastTag.match(/^v(\d+)\.(\d+)\.(\d+)/); + if (!match) return "v0.1.0"; + let [_, major, minor, patch] = match.map(Number); + patch += 1; + return `v${major}.${minor}.${patch}`; +} // --- Main function --- async function main() { @@ -116,6 +124,8 @@ async function main() { } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; + const lastTag = lastRelease?.tag_name || null; + const newTag = nextVersion(lastTag); // 2️⃣ Target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); @@ -182,14 +192,30 @@ async function main() { sections[section].push(`#${pr.number} ${pr.title}`); } - // 6️⃣ Print release notes - console.log(`## Draft Release Notes\n`); + // 6️⃣ Build release notes text + let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; - console.log(`### ${sectionName}\n`); - items.forEach(i => console.log(`- ${i}`)); - console.log(""); // blank line + items.sort((a, b) => parseInt(a.match(/#(\d+)/)[1]) - parseInt(b.match(/#(\d+)/)[1])); + releaseNotesText += `### ${sectionName}\n`; + items.forEach(i => releaseNotesText += `- ${i}\n`); + releaseNotesText += `\n`; } + + console.log(releaseNotesText); + + // 7️⃣ Create GitHub release + await octokit.repos.createRelease({ + owner: OWNER, + repo: REPO, + tag_name: newTag, + name: `Release ${newTag}`, + body: releaseNotesText, + draft: false, + prerelease: false, + }); + + console.log(`βœ… Release created: ${newTag}`); } // --- Run --- From 36b8802a6f614e8ca08231163ea22b24150339f0 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 18:14:05 +0700 Subject: [PATCH 40/58] fix: draft release --- .github/scripts/release-notes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 9993715..60a11f4 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -204,14 +204,14 @@ async function main() { console.log(releaseNotesText); - // 7️⃣ Create GitHub release + // 7️⃣ Create GitHub release (as draft) await octokit.repos.createRelease({ owner: OWNER, repo: REPO, tag_name: newTag, name: `Release ${newTag}`, body: releaseNotesText, - draft: false, + draft: true, prerelease: false, }); From 883d5599c76b77563677accf8055952168b2ed03 Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 29 Oct 2025 18:22:52 +0700 Subject: [PATCH 41/58] fix: update last draft --- .github/scripts/release-notes.js | 39 ++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 60a11f4..eeccc63 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -203,17 +203,36 @@ async function main() { } console.log(releaseNotesText); + // --- Find or create draft release --- + let draftRelease = null; + try { + const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); + draftRelease = releases.find(r => r.draft); + } catch {} - // 7️⃣ Create GitHub release (as draft) - await octokit.repos.createRelease({ - owner: OWNER, - repo: REPO, - tag_name: newTag, - name: `Release ${newTag}`, - body: releaseNotesText, - draft: true, - prerelease: false, - }); + if (draftRelease) { + // Update existing draft + await octokit.repos.updateRelease({ + owner: OWNER, + repo: REPO, + release_id: draftRelease.id, + body: releaseNotesText, + name: `Release ${draftRelease.tag_name}`, + }); + console.log(`βœ… Draft release updated: ${draftRelease.tag_name}`); + } else { + // Create new draft + await octokit.repos.createRelease({ + owner: OWNER, + repo: REPO, + tag_name: newTag, + name: `Release ${newTag}`, + body: releaseNotesText, + draft: true, + prerelease: false, + }); + console.log(`βœ… Draft release created: ${newTag}`); + } console.log(`βœ… Release created: ${newTag}`); } From a1cd70b39e05f8c381a3fc7c0126444d91ad8fad Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 11:33:17 +0700 Subject: [PATCH 42/58] fix: normalizeTitlePrefixes --- .github/scripts/release-notes.js | 75 ++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index eeccc63..e4df506 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,39 +77,59 @@ async function getLinkedIssues(prNumber) { // --- Classify title by flexible prefix --- function classifyTitle(title) { - // remove leading emoji and spaces - const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); + // Remove emojis and spaces at start + const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ""); + + // Match [Feat], Feat:, Feat - etc. const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); - const rawPrefix = match ? (match[1] || match[2]) : null; + if (!match) return "Other"; + + // If multiple prefixes inside [Feat, Enhancement] β€” split and take first + let rawPrefix = (match[1] || match[2])?.split(",")[0].trim() || null; if (!rawPrefix) return "Other"; const prefix = rawPrefix.toLowerCase(); const map = { - "task": "πŸš€ Tasks", - "composite": "πŸš€ Tasks", + task: "πŸš€ Tasks", + composite: "πŸš€ Tasks", "ux/ui": "πŸ”§ Enhancements", - "enhancement": "πŸ”§ Enhancements", - "bug": "🐞 Bug Fixes", - "feat": "✨ New Features", - "refactor": "πŸ›  Refactoring", - "docs": "πŸ“š Documentation", - "test": "βœ… Tests", - "chore": "βš™οΈ Chores", - "proposal": "πŸ’‘ Ideas & Proposals", - "idea": "πŸ’‘ Ideas & Proposals", - "discussion": "πŸ’‘ Ideas & Proposals", + enhancement: "πŸ”§ Enhancements", + bug: "🐞 Bug Fixes", + feat: "✨ New Features", + feature: "✨ New Features", + refactor: "πŸ›  Refactoring", + docs: "πŸ“š Documentation", + doc: "πŸ“š Documentation", + test: "βœ… Tests", + chore: "βš™οΈ Chores", + proposal: "πŸ’‘ Ideas & Proposals", + idea: "πŸ’‘ Ideas & Proposals", + discussion: "πŸ’‘ Ideas & Proposals", }; return map[prefix] || "Other"; } +// --- Normalize prefix style in titles --- +function normalizeTitlePrefixes(title) { + return title.replace(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i, (match, p1, p2) => { + const prefixText = p1 || p2; + if (!prefixText) return match; + // Preserve multiple prefixes, capitalize each, wrap in [ ] + const formatted = prefixText + .split(",") + .map(p => `[${p.trim().charAt(0).toUpperCase() + p.trim().slice(1).toLowerCase()}]`) + .join(" "); + return `${formatted} `; + }); +} + // --- Semantic versioning --- function nextVersion(lastTag) { if (!lastTag) return "v0.1.0"; const match = lastTag.match(/^v(\d+)\.(\d+)\.(\d+)/); if (!match) return "v0.1.0"; - - let [_, major, minor, patch] = match.map(Number); + let [, major, minor, patch] = match.map(Number); patch += 1; return `v${major}.${minor}.${patch}`; } @@ -163,7 +183,7 @@ async function main() { } } - // 5️⃣ Classify + // 5️⃣ Classify and build sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -179,17 +199,15 @@ async function main() { for (const [num, info] of Object.entries(issueMap)) { const section = classifyTitle(info.title); - sections[section].push( - `#${num} ${info.title} ↳ PRs: ${info.prs - .sort((a, b) => a - b) // сортировка ΠΏΠΎ Π²ΠΎΠ·Ρ€Π°ΡΡ‚Π°Π½ΠΈΡŽ - .map(n => `#${n}`) - .join(", ")}` - ); + const title = normalizeTitlePrefixes(info.title); + const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); + sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } for (const pr of prsWithoutIssue) { const section = classifyTitle(pr.title); - sections[section].push(`#${pr.number} ${pr.title}`); + const title = normalizeTitlePrefixes(pr.title); + sections[section].push(`#${pr.number} ${title}`); } // 6️⃣ Build release notes text @@ -203,7 +221,8 @@ async function main() { } console.log(releaseNotesText); - // --- Find or create draft release --- + + // 7️⃣ Find or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); @@ -211,7 +230,6 @@ async function main() { } catch {} if (draftRelease) { - // Update existing draft await octokit.repos.updateRelease({ owner: OWNER, repo: REPO, @@ -221,7 +239,6 @@ async function main() { }); console.log(`βœ… Draft release updated: ${draftRelease.tag_name}`); } else { - // Create new draft await octokit.repos.createRelease({ owner: OWNER, repo: REPO, @@ -234,7 +251,7 @@ async function main() { console.log(`βœ… Draft release created: ${newTag}`); } - console.log(`βœ… Release created: ${newTag}`); + console.log(`βœ… Release processing completed`); } // --- Run --- From 69aa4ca4d7eed62f6415780f32a7abd96a5cf079 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 11:42:32 +0700 Subject: [PATCH 43/58] fix: get last published releases --- .github/scripts/release-notes.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index e4df506..fa0a294 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -139,8 +139,15 @@ async function main() { // 1️⃣ Latest release let lastRelease = null; try { - const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 1 }); - lastRelease = data[0] || null; + // Get last non-draft release + let releasesData = []; + try { + const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); + releasesData = data; + } catch {} + + const publishedReleases = releasesData.filter(r => !r.draft); + lastRelease = publishedReleases.length ? publishedReleases[0] : null; } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; From 33c995ef9486ede10692ac8d11ebfb29a81ee1b9 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 11:55:19 +0700 Subject: [PATCH 44/58] fix: classifyTitle --- .github/scripts/release-notes.js | 96 ++++++++++++++------------------ 1 file changed, 43 insertions(+), 53 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index fa0a294..20076f2 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -75,53 +75,50 @@ async function getLinkedIssues(prNumber) { } } -// --- Classify title by flexible prefix --- +// --- Determine section from prefix --- function classifyTitle(title) { - // Remove emojis and spaces at start - const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ""); - - // Match [Feat], Feat:, Feat - etc. - const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); + const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, "").trim(); + const match = cleaned.match(/^\s*\[([^\]]+)\]|^\s*([^\s:]+)\s*:?\s*/i); if (!match) return "Other"; - // If multiple prefixes inside [Feat, Enhancement] β€” split and take first - let rawPrefix = (match[1] || match[2])?.split(",")[0].trim() || null; - if (!rawPrefix) return "Other"; + const rawPrefix = (match[1] || match[2] || "").split(",")[0].trim().toLowerCase(); - const prefix = rawPrefix.toLowerCase(); const map = { - task: "πŸš€ Tasks", - composite: "πŸš€ Tasks", + "task": "πŸš€ Tasks", + "composite": "πŸš€ Tasks", "ux/ui": "πŸ”§ Enhancements", - enhancement: "πŸ”§ Enhancements", - bug: "🐞 Bug Fixes", - feat: "✨ New Features", - feature: "✨ New Features", - refactor: "πŸ›  Refactoring", - docs: "πŸ“š Documentation", - doc: "πŸ“š Documentation", - test: "βœ… Tests", - chore: "βš™οΈ Chores", - proposal: "πŸ’‘ Ideas & Proposals", - idea: "πŸ’‘ Ideas & Proposals", - discussion: "πŸ’‘ Ideas & Proposals", + "enhancement": "πŸ”§ Enhancements", + "bug": "🐞 Bug Fixes", + "feat": "✨ New Features", + "refactor": "πŸ›  Refactoring", + "docs": "πŸ“š Documentation", + "test": "βœ… Tests", + "chore": "βš™οΈ Chores", + "proposal": "πŸ’‘ Ideas & Proposals", + "idea": "πŸ’‘ Ideas & Proposals", + "discussion": "πŸ’‘ Ideas & Proposals", }; - return map[prefix] || "Other"; + return map[rawPrefix] || "Other"; } -// --- Normalize prefix style in titles --- +// --- Normalize title, preserving multiple prefixes --- function normalizeTitlePrefixes(title) { - return title.replace(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i, (match, p1, p2) => { - const prefixText = p1 || p2; - if (!prefixText) return match; - // Preserve multiple prefixes, capitalize each, wrap in [ ] - const formatted = prefixText + let cleaned = title.trim(); + + // Extract prefix part if exists + const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); + if (match) { + let prefixText = match[1] || match[2] || ""; + // Keep multiple prefixes intact (e.g. "Feat, UX/UI") + const formatted = `[${prefixText .split(",") - .map(p => `[${p.trim().charAt(0).toUpperCase() + p.trim().slice(1).toLowerCase()}]`) - .join(" "); - return `${formatted} `; - }); + .map(p => p.trim().replace(/^[\[\]]+/g, "").replace(/^([a-z])/, (_, c) => c.toUpperCase())) + .join(", ")}]`; + cleaned = cleaned.replace(match[0], `${formatted} `); + } + + return cleaned; } // --- Semantic versioning --- @@ -134,20 +131,14 @@ function nextVersion(lastTag) { return `v${major}.${minor}.${patch}`; } -// --- Main function --- +// --- Main --- async function main() { - // 1️⃣ Latest release + // 1️⃣ Get last release let lastRelease = null; try { - // Get last non-draft release - let releasesData = []; - try { - const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); - releasesData = data; - } catch {} - - const publishedReleases = releasesData.filter(r => !r.draft); - lastRelease = publishedReleases.length ? publishedReleases[0] : null; + const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); + const published = data.filter(r => !r.draft); + lastRelease = published.length ? published[0] : null; } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; @@ -170,7 +161,7 @@ async function main() { } catch {} } - // 3️⃣ Fetch merged PRs + // 3️⃣ Merged PRs since last release const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); @@ -190,7 +181,7 @@ async function main() { } } - // 5️⃣ Classify and build sections + // 5️⃣ Group by section const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -217,19 +208,19 @@ async function main() { sections[section].push(`#${pr.number} ${title}`); } - // 6️⃣ Build release notes text + // 6️⃣ Build release notes let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; items.sort((a, b) => parseInt(a.match(/#(\d+)/)[1]) - parseInt(b.match(/#(\d+)/)[1])); releaseNotesText += `### ${sectionName}\n`; - items.forEach(i => releaseNotesText += `- ${i}\n`); + items.forEach(i => (releaseNotesText += `- ${i}\n`)); releaseNotesText += `\n`; } console.log(releaseNotesText); - // 7️⃣ Find or create draft release + // 7️⃣ Update or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); @@ -258,10 +249,9 @@ async function main() { console.log(`βœ… Draft release created: ${newTag}`); } - console.log(`βœ… Release processing completed`); + console.log("βœ… Release processing completed"); } -// --- Run --- main().catch(err => { console.error("Error:", err); process.exit(1); From 2c74bb7cb8337697f57fb4c9efc499924e15f98b Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:02:39 +0700 Subject: [PATCH 45/58] fix: extractAndNormalizePrefixes fix: get prefixes --- .github/scripts/release-notes.js | 131 +++++++++++++++++-------------- 1 file changed, 74 insertions(+), 57 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 20076f2..ccc20b1 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -26,6 +26,72 @@ const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); +// --- Known prefixes map --- +const PREFIX_MAP = { + bug: "🐞 Bug Fixes", + feat: "✨ New Features", + enhancement: "πŸ”§ Enhancements", + refactor: "πŸ›  Refactoring", + docs: "πŸ“š Documentation", + test: "βœ… Tests", + chore: "βš™οΈ Chores", + task: "πŸš€ Tasks", + composite: "πŸš€ Tasks", + "ux/ui": "πŸ”§ Enhancements", + proposal: "πŸ’‘ Ideas & Proposals", + idea: "πŸ’‘ Ideas & Proposals", + discussion: "πŸ’‘ Ideas & Proposals", +}; + +// --- Helper to capitalize prefix cleanly --- +function capitalizePrefix(prefix) { + return prefix + .toLowerCase() + .split("/") + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join("/"); +} + +// --- Extract and normalize all prefixes in title (merged into one [..] if multiple) --- +function extractAndNormalizePrefixes(title) { + const matches = [...title.matchAll(/\[([^\]]+)\]/g)]; + if (matches.length) { + const combined = matches + .map(m => m[1].split(',').map(p => p.trim()).filter(Boolean).map(capitalizePrefix)) + .flat(); + return { + prefix: `[${combined.join(', ')}]`, + cleanTitle: title.replace(/^(\s*\[[^\]]+\]\s*)+/, '').trim(), + }; + } + + const singleMatch = title.match( + /^([^\w]*)(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\-\s]/i + ); + if (singleMatch) { + const normalized = capitalizePrefix(singleMatch[2]); + return { prefix: `[${normalized}]`, cleanTitle: title.replace(singleMatch[0], '').trim() }; + } + + return { prefix: '', cleanTitle: title }; +} + +// --- Normalize title --- +function normalizeTitlePrefixes(title) { + const { prefix, cleanTitle } = extractAndNormalizePrefixes(title); + return prefix ? `${prefix} ${cleanTitle}` : cleanTitle; +} + +// --- Classify title --- +function classifyTitle(title) { + const { prefix } = extractAndNormalizePrefixes(title); + if (!prefix) return "Other"; + + // Π‘Π΅Ρ€Ρ‘ΠΌ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ прСфикс для классификации + const firstPrefix = prefix.split(',')[0].replace(/[\[\]]/g, '').toLowerCase(); + return PREFIX_MAP[firstPrefix] || "Other"; +} + // --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; @@ -75,52 +141,6 @@ async function getLinkedIssues(prNumber) { } } -// --- Determine section from prefix --- -function classifyTitle(title) { - const cleaned = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, "").trim(); - const match = cleaned.match(/^\s*\[([^\]]+)\]|^\s*([^\s:]+)\s*:?\s*/i); - if (!match) return "Other"; - - const rawPrefix = (match[1] || match[2] || "").split(",")[0].trim().toLowerCase(); - - const map = { - "task": "πŸš€ Tasks", - "composite": "πŸš€ Tasks", - "ux/ui": "πŸ”§ Enhancements", - "enhancement": "πŸ”§ Enhancements", - "bug": "🐞 Bug Fixes", - "feat": "✨ New Features", - "refactor": "πŸ›  Refactoring", - "docs": "πŸ“š Documentation", - "test": "βœ… Tests", - "chore": "βš™οΈ Chores", - "proposal": "πŸ’‘ Ideas & Proposals", - "idea": "πŸ’‘ Ideas & Proposals", - "discussion": "πŸ’‘ Ideas & Proposals", - }; - - return map[rawPrefix] || "Other"; -} - -// --- Normalize title, preserving multiple prefixes --- -function normalizeTitlePrefixes(title) { - let cleaned = title.trim(); - - // Extract prefix part if exists - const match = cleaned.match(/^\s*(?:\[([^\]]+)\]|([^\s:]+))\s*:?\s*/i); - if (match) { - let prefixText = match[1] || match[2] || ""; - // Keep multiple prefixes intact (e.g. "Feat, UX/UI") - const formatted = `[${prefixText - .split(",") - .map(p => p.trim().replace(/^[\[\]]+/g, "").replace(/^([a-z])/, (_, c) => c.toUpperCase())) - .join(", ")}]`; - cleaned = cleaned.replace(match[0], `${formatted} `); - } - - return cleaned; -} - // --- Semantic versioning --- function nextVersion(lastTag) { if (!lastTag) return "v0.1.0"; @@ -131,14 +151,13 @@ function nextVersion(lastTag) { return `v${major}.${minor}.${patch}`; } -// --- Main --- +// --- Main function --- async function main() { - // 1️⃣ Get last release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); - const published = data.filter(r => !r.draft); - lastRelease = published.length ? published[0] : null; + const publishedReleases = data.filter(r => !r.draft); + lastRelease = publishedReleases.length ? publishedReleases[0] : null; } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; @@ -161,11 +180,10 @@ async function main() { } catch {} } - // 3️⃣ Merged PRs since last release const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); - // 4️⃣ Build issue β†’ PR map + // Build issue β†’ PR map const issueMap = {}; const prsWithoutIssue = []; @@ -181,7 +199,7 @@ async function main() { } } - // 5️⃣ Group by section + // Sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -208,19 +226,17 @@ async function main() { sections[section].push(`#${pr.number} ${title}`); } - // 6️⃣ Build release notes let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; items.sort((a, b) => parseInt(a.match(/#(\d+)/)[1]) - parseInt(b.match(/#(\d+)/)[1])); releaseNotesText += `### ${sectionName}\n`; - items.forEach(i => (releaseNotesText += `- ${i}\n`)); + items.forEach(i => releaseNotesText += `- ${i}\n`); releaseNotesText += `\n`; } console.log(releaseNotesText); - // 7️⃣ Update or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); @@ -249,9 +265,10 @@ async function main() { console.log(`βœ… Draft release created: ${newTag}`); } - console.log("βœ… Release processing completed"); + console.log(`βœ… Release processing completed`); } +// --- Run --- main().catch(err => { console.error("Error:", err); process.exit(1); From 2fc951331095ded7522f47574b5394cca8d74d0b Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:14:14 +0700 Subject: [PATCH 46/58] fix: normalizePrefixesToOfficial --- .github/scripts/release-notes.js | 39 +++++++++++++++++--------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index ccc20b1..386c934 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -43,21 +43,22 @@ const PREFIX_MAP = { discussion: "πŸ’‘ Ideas & Proposals", }; -// --- Helper to capitalize prefix cleanly --- -function capitalizePrefix(prefix) { - return prefix - .toLowerCase() - .split("/") - .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) - .join("/"); -} - -// --- Extract and normalize all prefixes in title (merged into one [..] if multiple) --- -function extractAndNormalizePrefixes(title) { +// --- Normalize multiple prefixes to official casing --- +function normalizePrefixesToOfficial(title) { const matches = [...title.matchAll(/\[([^\]]+)\]/g)]; if (matches.length) { const combined = matches - .map(m => m[1].split(',').map(p => p.trim()).filter(Boolean).map(capitalizePrefix)) + .map(m => m[1].split(',').map(p => p.trim().toLowerCase()).filter(Boolean) + .map(p => { + if (p === "ux/ui") return "UX/UI"; + if (PREFIX_MAP[p]) { + // extract just prefix text without emoji + const text = PREFIX_MAP[p].replace(/^[^\s]+\s/, ""); + return text.split(" ")[0]; + } + return p.charAt(0).toUpperCase() + p.slice(1); + }) + ) .flat(); return { prefix: `[${combined.join(', ')}]`, @@ -65,11 +66,13 @@ function extractAndNormalizePrefixes(title) { }; } + // Handle single-word prefix like "chore:" or "feat:" const singleMatch = title.match( /^([^\w]*)(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\-\s]/i ); if (singleMatch) { - const normalized = capitalizePrefix(singleMatch[2]); + const p = singleMatch[2].toLowerCase(); + const normalized = p === "ux/ui" ? "UX/UI" : p.charAt(0).toUpperCase() + p.slice(1); return { prefix: `[${normalized}]`, cleanTitle: title.replace(singleMatch[0], '').trim() }; } @@ -78,16 +81,14 @@ function extractAndNormalizePrefixes(title) { // --- Normalize title --- function normalizeTitlePrefixes(title) { - const { prefix, cleanTitle } = extractAndNormalizePrefixes(title); + const { prefix, cleanTitle } = normalizePrefixesToOfficial(title); return prefix ? `${prefix} ${cleanTitle}` : cleanTitle; } // --- Classify title --- function classifyTitle(title) { - const { prefix } = extractAndNormalizePrefixes(title); + const { prefix } = normalizePrefixesToOfficial(title); if (!prefix) return "Other"; - - // Π‘Π΅Ρ€Ρ‘ΠΌ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ прСфикс для классификации const firstPrefix = prefix.split(',')[0].replace(/[\[\]]/g, '').toLowerCase(); return PREFIX_MAP[firstPrefix] || "Other"; } @@ -153,6 +154,7 @@ function nextVersion(lastTag) { // --- Main function --- async function main() { + // Get last non-draft release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); @@ -164,7 +166,7 @@ async function main() { const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // 2️⃣ Target branch + // Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -237,6 +239,7 @@ async function main() { console.log(releaseNotesText); + // Update or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From 96bea37362c3485fc1c8af4fc74375ac0aefdfaa Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:26:23 +0700 Subject: [PATCH 47/58] fix: normalizeTitleForNotes --- .github/scripts/release-notes.js | 75 +++++++++++--------------------- 1 file changed, 26 insertions(+), 49 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 386c934..423304e 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -26,7 +26,7 @@ const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); -// --- Known prefixes map --- +// --- Known prefixes map for classification --- const PREFIX_MAP = { bug: "🐞 Bug Fixes", feat: "✨ New Features", @@ -43,53 +43,28 @@ const PREFIX_MAP = { discussion: "πŸ’‘ Ideas & Proposals", }; -// --- Normalize multiple prefixes to official casing --- -function normalizePrefixesToOfficial(title) { - const matches = [...title.matchAll(/\[([^\]]+)\]/g)]; - if (matches.length) { - const combined = matches - .map(m => m[1].split(',').map(p => p.trim().toLowerCase()).filter(Boolean) - .map(p => { - if (p === "ux/ui") return "UX/UI"; - if (PREFIX_MAP[p]) { - // extract just prefix text without emoji - const text = PREFIX_MAP[p].replace(/^[^\s]+\s/, ""); - return text.split(" ")[0]; - } - return p.charAt(0).toUpperCase() + p.slice(1); - }) - ) - .flat(); - return { - prefix: `[${combined.join(', ')}]`, - cleanTitle: title.replace(/^(\s*\[[^\]]+\]\s*)+/, '').trim(), - }; - } +// --- Normalize title for release notes (keep multi-prefix intact) --- +function normalizeTitleForNotes(title) { + let t = title.trim(); - // Handle single-word prefix like "chore:" or "feat:" - const singleMatch = title.match( - /^([^\w]*)(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\-\s]/i + // Convert single-word prefixes like chore:, feat:, 🎨 chore: β†’ [Chore] + t = t.replace( + /^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i, + (m, p1) => { + const normalized = p1.toLowerCase() === "ux/ui" ? "UX/UI" : p1.charAt(0).toUpperCase() + p1.slice(1).toLowerCase(); + return `[${normalized}] `; + } ); - if (singleMatch) { - const p = singleMatch[2].toLowerCase(); - const normalized = p === "ux/ui" ? "UX/UI" : p.charAt(0).toUpperCase() + p.slice(1); - return { prefix: `[${normalized}]`, cleanTitle: title.replace(singleMatch[0], '').trim() }; - } - - return { prefix: '', cleanTitle: title }; -} -// --- Normalize title --- -function normalizeTitlePrefixes(title) { - const { prefix, cleanTitle } = normalizePrefixesToOfficial(title); - return prefix ? `${prefix} ${cleanTitle}` : cleanTitle; + // Leave multi-prefix brackets intact + return t; } -// --- Classify title --- +// --- Classify title for section (by first prefix only) --- function classifyTitle(title) { - const { prefix } = normalizePrefixesToOfficial(title); - if (!prefix) return "Other"; - const firstPrefix = prefix.split(',')[0].replace(/[\[\]]/g, '').toLowerCase(); + const match = title.match(/\[([^\]]+)\]/); + if (!match) return "Other"; + const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); return PREFIX_MAP[firstPrefix] || "Other"; } @@ -154,7 +129,7 @@ function nextVersion(lastTag) { // --- Main function --- async function main() { - // Get last non-draft release + // 1️⃣ Latest non-draft release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); @@ -166,7 +141,7 @@ async function main() { const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // Determine target branch + // 2️⃣ Target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -182,10 +157,11 @@ async function main() { } catch {} } + // 3️⃣ Fetch merged PRs const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); - // Build issue β†’ PR map + // 4️⃣ Build issue β†’ PR map const issueMap = {}; const prsWithoutIssue = []; @@ -201,7 +177,7 @@ async function main() { } } - // Sections + // 5️⃣ Classify and organize sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -217,17 +193,18 @@ async function main() { for (const [num, info] of Object.entries(issueMap)) { const section = classifyTitle(info.title); - const title = normalizeTitlePrefixes(info.title); + const title = normalizeTitleForNotes(info.title); const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } for (const pr of prsWithoutIssue) { const section = classifyTitle(pr.title); - const title = normalizeTitlePrefixes(pr.title); + const title = normalizeTitleForNotes(pr.title); sections[section].push(`#${pr.number} ${title}`); } + // 6️⃣ Build release notes text let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; @@ -239,7 +216,7 @@ async function main() { console.log(releaseNotesText); - // Update or create draft release + // 7️⃣ Update or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From b07df7602834e6364518ff290d31e4127166d160 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:35:51 +0700 Subject: [PATCH 48/58] fix: classify prs --- .github/scripts/release-notes.js | 103 +++++++++++++++++-------------- 1 file changed, 58 insertions(+), 45 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 423304e..1d80fce 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -26,48 +26,23 @@ const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); -// --- Known prefixes map for classification --- +// --- Prefix map for classification --- const PREFIX_MAP = { - bug: "🐞 Bug Fixes", - feat: "✨ New Features", - enhancement: "πŸ”§ Enhancements", - refactor: "πŸ›  Refactoring", - docs: "πŸ“š Documentation", - test: "βœ… Tests", - chore: "βš™οΈ Chores", - task: "πŸš€ Tasks", - composite: "πŸš€ Tasks", + "task": "πŸš€ Tasks", + "composite": "πŸš€ Tasks", "ux/ui": "πŸ”§ Enhancements", - proposal: "πŸ’‘ Ideas & Proposals", - idea: "πŸ’‘ Ideas & Proposals", - discussion: "πŸ’‘ Ideas & Proposals", + "enhancement": "πŸ”§ Enhancements", + "bug": "🐞 Bug Fixes", + "feat": "✨ New Features", + "refactor": "πŸ›  Refactoring", + "docs": "πŸ“š Documentation", + "test": "βœ… Tests", + "chore": "βš™οΈ Chores", + "proposal": "πŸ’‘ Ideas & Proposals", + "idea": "πŸ’‘ Ideas & Proposals", + "discussion": "πŸ’‘ Ideas & Proposals", }; -// --- Normalize title for release notes (keep multi-prefix intact) --- -function normalizeTitleForNotes(title) { - let t = title.trim(); - - // Convert single-word prefixes like chore:, feat:, 🎨 chore: β†’ [Chore] - t = t.replace( - /^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i, - (m, p1) => { - const normalized = p1.toLowerCase() === "ux/ui" ? "UX/UI" : p1.charAt(0).toUpperCase() + p1.slice(1).toLowerCase(); - return `[${normalized}] `; - } - ); - - // Leave multi-prefix brackets intact - return t; -} - -// --- Classify title for section (by first prefix only) --- -function classifyTitle(title) { - const match = title.match(/\[([^\]]+)\]/); - if (!match) return "Other"; - const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); - return PREFIX_MAP[firstPrefix] || "Other"; -} - // --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; @@ -117,6 +92,42 @@ async function getLinkedIssues(prNumber) { } } +// --- Classify title by first prefix --- +function classifyTitle(title) { + const t = title.trim(); + + // 1️⃣ Bracket prefix [Feat], [Feat, UX/UI], etc. + let match = t.match(/\[([^\]]+)\]/); + if (match) { + const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); + return PREFIX_MAP[firstPrefix] || "Other"; + } + + // 2️⃣ Single-word prefix like chore:, feat:, 🎨 chore: + match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + if (match) { + const prefix = match[1].toLowerCase(); + return PREFIX_MAP[prefix] || "Other"; + } + + // 3️⃣ Fallback + return "Other"; +} + +// --- Normalize title prefixes (for display) --- +function normalizeTitleForNotes(title) { + let t = title.trim(); + + // Convert single-word prefixes to [Title] style, keep bracketed titles as-is + const match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + if (match) { + const prefix = match[1]; + t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}] `); + } + + return t; +} + // --- Semantic versioning --- function nextVersion(lastTag) { if (!lastTag) return "v0.1.0"; @@ -133,15 +144,15 @@ async function main() { let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); - const publishedReleases = data.filter(r => !r.draft); - lastRelease = publishedReleases.length ? publishedReleases[0] : null; + const published = data.filter(r => !r.draft); + lastRelease = published.length ? published[0] : null; } catch {} const since = lastRelease ? new Date(lastRelease.created_at) : null; const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // 2️⃣ Target branch + // 2️⃣ Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -177,7 +188,7 @@ async function main() { } } - // 5️⃣ Classify and organize sections + // 5️⃣ Build sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -191,16 +202,18 @@ async function main() { Other: [], }; + // PRs linked to issues for (const [num, info] of Object.entries(issueMap)) { const section = classifyTitle(info.title); - const title = normalizeTitleForNotes(info.title); + const title = info.title; // keep original title with all prefixes const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } + // PRs without issues for (const pr of prsWithoutIssue) { - const section = classifyTitle(pr.title); const title = normalizeTitleForNotes(pr.title); + const section = classifyTitle(title); sections[section].push(`#${pr.number} ${title}`); } @@ -216,7 +229,7 @@ async function main() { console.log(releaseNotesText); - // 7️⃣ Update or create draft release + // 7️⃣ Find or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From 8bf9313a3f804035e32aea73f9e27449abfc4752 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:40:34 +0700 Subject: [PATCH 49/58] fix: classifyTitle --- .github/scripts/release-notes.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 1d80fce..9564349 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -94,7 +94,10 @@ async function getLinkedIssues(prNumber) { // --- Classify title by first prefix --- function classifyTitle(title) { - const t = title.trim(); + let t = title.trim(); + + // Remove leading emojis and spaces + t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, '').trim(); // 1️⃣ Bracket prefix [Feat], [Feat, UX/UI], etc. let match = t.match(/\[([^\]]+)\]/); @@ -103,17 +106,17 @@ function classifyTitle(title) { return PREFIX_MAP[firstPrefix] || "Other"; } - // 2️⃣ Single-word prefix like chore:, feat:, 🎨 chore: - match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + // 2️⃣ Single-word prefix like chore:, feat: + match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); if (match) { const prefix = match[1].toLowerCase(); return PREFIX_MAP[prefix] || "Other"; } - // 3️⃣ Fallback return "Other"; } + // --- Normalize title prefixes (for display) --- function normalizeTitleForNotes(title) { let t = title.trim(); From cfcb20d2a3765556967a01d83f95e694e799e39b Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:49:31 +0700 Subject: [PATCH 50/58] fix: classifyTitle for emodji --- .github/scripts/release-notes.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 9564349..d19cf60 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -94,19 +94,17 @@ async function getLinkedIssues(prNumber) { // --- Classify title by first prefix --- function classifyTitle(title) { - let t = title.trim(); - - // Remove leading emojis and spaces - t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, '').trim(); + // 1️⃣ Remove leading emojis and spaces + let t = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, '').trim(); - // 1️⃣ Bracket prefix [Feat], [Feat, UX/UI], etc. - let match = t.match(/\[([^\]]+)\]/); + // 2️⃣ Bracket prefix [Feat], [Feat, UX/UI], etc. + let match = t.match(/^\[([^\]]+)\]/); if (match) { const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); return PREFIX_MAP[firstPrefix] || "Other"; } - // 2️⃣ Single-word prefix like chore:, feat: + // 3️⃣ Single-word prefix like chore:, feat: match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); if (match) { const prefix = match[1].toLowerCase(); @@ -116,12 +114,11 @@ function classifyTitle(title) { return "Other"; } - -// --- Normalize title prefixes (for display) --- +// --- Normalize title prefixes for display --- function normalizeTitleForNotes(title) { let t = title.trim(); - // Convert single-word prefixes to [Title] style, keep bracketed titles as-is + // Convert single-word prefixes to [Title] style if no brackets, keep bracketed titles as-is const match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); if (match) { const prefix = match[1]; From 267dda92034bfd902dfbf33a5006b98c162dd44c Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 12:58:07 +0700 Subject: [PATCH 51/58] fix: remove emojis --- .github/scripts/release-notes.js | 96 +++++++++++++++++--------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index d19cf60..97daa3d 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -43,6 +43,48 @@ const PREFIX_MAP = { "discussion": "πŸ’‘ Ideas & Proposals", }; +// --- Remove leading emojis (Unicode + :emoji:) --- +function stripLeadingEmoji(title) { + let t = title.trim(); + // Remove :emoji: at start + t = t.replace(/^(:[a-zA-Z0-9_+-]+:)+\s*/g, ''); + // Remove actual Unicode emojis + t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); + return t.trim(); +} + +// --- Classify title by first prefix --- +function classifyTitle(title) { + const t = stripLeadingEmoji(title); + + // Bracket prefix [Feat], [Feat, UX/UI], etc. + let match = t.match(/^\[([^\]]+)\]/); + if (match) { + const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); + return PREFIX_MAP[firstPrefix] || "Other"; + } + + // Single-word prefix like chore:, feat: + match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + if (match) { + const prefix = match[1].toLowerCase(); + return PREFIX_MAP[prefix] || "Other"; + } + + return "Other"; +} + +// --- Normalize title for display (keep all prefixes in brackets as-is) --- +function normalizeTitleForNotes(title) { + let t = title.trim(); + const match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + if (match) { + const prefix = match[1]; + t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}] `); + } + return t; +} + // --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; @@ -92,42 +134,6 @@ async function getLinkedIssues(prNumber) { } } -// --- Classify title by first prefix --- -function classifyTitle(title) { - // 1️⃣ Remove leading emojis and spaces - let t = title.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, '').trim(); - - // 2️⃣ Bracket prefix [Feat], [Feat, UX/UI], etc. - let match = t.match(/^\[([^\]]+)\]/); - if (match) { - const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); - return PREFIX_MAP[firstPrefix] || "Other"; - } - - // 3️⃣ Single-word prefix like chore:, feat: - match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); - if (match) { - const prefix = match[1].toLowerCase(); - return PREFIX_MAP[prefix] || "Other"; - } - - return "Other"; -} - -// --- Normalize title prefixes for display --- -function normalizeTitleForNotes(title) { - let t = title.trim(); - - // Convert single-word prefixes to [Title] style if no brackets, keep bracketed titles as-is - const match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); - if (match) { - const prefix = match[1]; - t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}] `); - } - - return t; -} - // --- Semantic versioning --- function nextVersion(lastTag) { if (!lastTag) return "v0.1.0"; @@ -138,9 +144,9 @@ function nextVersion(lastTag) { return `v${major}.${minor}.${patch}`; } -// --- Main function --- +// --- Main --- async function main() { - // 1️⃣ Latest non-draft release + // Latest non-draft release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); @@ -152,7 +158,7 @@ async function main() { const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // 2️⃣ Determine target branch + // Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -168,11 +174,11 @@ async function main() { } catch {} } - // 3️⃣ Fetch merged PRs + // Fetch merged PRs const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); - // 4️⃣ Build issue β†’ PR map + // Build issue β†’ PR map const issueMap = {}; const prsWithoutIssue = []; @@ -188,7 +194,7 @@ async function main() { } } - // 5️⃣ Build sections + // Build sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -205,7 +211,7 @@ async function main() { // PRs linked to issues for (const [num, info] of Object.entries(issueMap)) { const section = classifyTitle(info.title); - const title = info.title; // keep original title with all prefixes + const title = info.title; // keep original with all prefixes const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } @@ -217,7 +223,7 @@ async function main() { sections[section].push(`#${pr.number} ${title}`); } - // 6️⃣ Build release notes text + // Build release notes let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; @@ -229,7 +235,7 @@ async function main() { console.log(releaseNotesText); - // 7️⃣ Find or create draft release + // Find or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From 356313844f38390a8f2dbaeb5693f1095c10386b Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 13:02:10 +0700 Subject: [PATCH 52/58] fix: normalizeTitleForNotes --- .github/scripts/release-notes.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 97daa3d..df2a905 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -77,11 +77,18 @@ function classifyTitle(title) { // --- Normalize title for display (keep all prefixes in brackets as-is) --- function normalizeTitleForNotes(title) { let t = title.trim(); - const match = t.match(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]*\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + + // Match leading emoji + optional :emoji: + optional text prefix like chore:, feat:, etc. + const match = t.match(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (match) { - const prefix = match[1]; - t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}] `); + const prefix = match[3].toLowerCase(); + const sectionName = PREFIX_MAP[prefix]; + if (sectionName) { + // Replace everything matched with [Prefix] (first letter capitalized) + t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); + } } + return t; } From a566003ad8ae59fe473e971aa69c5d4cb7519fd4 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 13:07:13 +0700 Subject: [PATCH 53/58] fix: normalize title for release notes --- .github/scripts/release-notes.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index df2a905..71f9cda 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -65,7 +65,7 @@ function classifyTitle(title) { } // Single-word prefix like chore:, feat: - match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui|proposal|idea|discussion)[:\s-]+/i); + match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (match) { const prefix = match[1].toLowerCase(); return PREFIX_MAP[prefix] || "Other"; @@ -74,19 +74,19 @@ function classifyTitle(title) { return "Other"; } -// --- Normalize title for display (keep all prefixes in brackets as-is) --- +// --- Normalize title for release notes --- +// Convert emoji + chore: β†’ [Chore], feat: β†’ [Feat], etc. function normalizeTitleForNotes(title) { let t = title.trim(); - // Match leading emoji + optional :emoji: + optional text prefix like chore:, feat:, etc. - const match = t.match(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + // Remove leading emoji or :emoji: (one or more) + t = t.replace(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*/u, ''); + + // Match leading word prefix like chore:, feat:, etc. + const match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (match) { - const prefix = match[3].toLowerCase(); - const sectionName = PREFIX_MAP[prefix]; - if (sectionName) { - // Replace everything matched with [Prefix] (first letter capitalized) - t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); - } + const prefix = match[1].toLowerCase(); + t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); } return t; @@ -274,7 +274,7 @@ async function main() { console.log(`βœ… Release processing completed`); } -// --- Run --- +// Run main().catch(err => { console.error("Error:", err); process.exit(1); From 6b281370e2bface1ef36d297e65f2b3a76ee5f2a Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 13:12:57 +0700 Subject: [PATCH 54/58] fix: only one prefix when multiple --- .github/scripts/release-notes.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 71f9cda..dc9582f 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -82,16 +82,28 @@ function normalizeTitleForNotes(title) { // Remove leading emoji or :emoji: (one or more) t = t.replace(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*/u, ''); - // Match leading word prefix like chore:, feat:, etc. - const match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + // Match bracket prefix like [Feat, Enhancement] + const match = t.match(/^\[([^\]]+)\]/); if (match) { - const prefix = match[1].toLowerCase(); - t = t.replace(match[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); + // Take only the first prefix + const firstPrefix = match[1].split(',')[0].trim(); + // Keep the rest of the title unchanged + t = `[${firstPrefix}]` + t.slice(match[0].length); + return t; + } + + // Match single-word prefix like chore:, feat:, etc. + const match2 = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + if (match2) { + const prefix = match2[1].toLowerCase(); + t = t.replace(match2[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); } return t; } + + // --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; From 1487ab938e64e8671732324fa0e1f87bcf576769 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 13:20:41 +0700 Subject: [PATCH 55/58] fix: normalizeTitleForNotes --- .github/scripts/release-notes.js | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index dc9582f..7293cdd 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -79,31 +79,25 @@ function classifyTitle(title) { function normalizeTitleForNotes(title) { let t = title.trim(); - // Remove leading emoji or :emoji: (one or more) t = t.replace(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*/u, ''); - // Match bracket prefix like [Feat, Enhancement] - const match = t.match(/^\[([^\]]+)\]/); - if (match) { - // Take only the first prefix - const firstPrefix = match[1].split(',')[0].trim(); - // Keep the rest of the title unchanged - t = `[${firstPrefix}]` + t.slice(match[0].length); + const bracketMatch = t.match(/^\[([^\]]+)\]/); + if (bracketMatch) { + const firstPrefix = bracketMatch[1].split(',')[0].trim(); + t = `[${firstPrefix}]` + t.slice(bracketMatch[0].length); return t; } - // Match single-word prefix like chore:, feat:, etc. - const match2 = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); - if (match2) { - const prefix = match2[1].toLowerCase(); - t = t.replace(match2[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); + const simpleMatch = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + if (simpleMatch) { + const prefix = simpleMatch[1].toLowerCase(); + t = t.replace(simpleMatch[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); + return t; } return t; } - - // --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; From f486e3aa050930816275faab8c9dfd46ab50d047 Mon Sep 17 00:00:00 2001 From: Serg Date: Fri, 31 Oct 2025 13:27:59 +0700 Subject: [PATCH 56/58] fix: normalizeTitleForNotes --- .github/scripts/release-notes.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index 7293cdd..b9e753e 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -43,12 +43,12 @@ const PREFIX_MAP = { "discussion": "πŸ’‘ Ideas & Proposals", }; -// --- Remove leading emojis (Unicode + :emoji:) --- +// --- Strip leading emojis --- function stripLeadingEmoji(title) { let t = title.trim(); // Remove :emoji: at start t = t.replace(/^(:[a-zA-Z0-9_+-]+:)+\s*/g, ''); - // Remove actual Unicode emojis + // Remove Unicode emojis t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); return t.trim(); } @@ -75,19 +75,21 @@ function classifyTitle(title) { } // --- Normalize title for release notes --- -// Convert emoji + chore: β†’ [Chore], feat: β†’ [Feat], etc. function normalizeTitleForNotes(title) { let t = title.trim(); + // Remove leading emoji or :emoji: t = t.replace(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*/u, ''); + // Bracket prefix [Feat, Enhancement] β†’ [Feat] const bracketMatch = t.match(/^\[([^\]]+)\]/); if (bracketMatch) { const firstPrefix = bracketMatch[1].split(',')[0].trim(); - t = `[${firstPrefix}]` + t.slice(bracketMatch[0].length); + t = `[${firstPrefix}]` + t.slice(bracketMatch[0].length).trimStart(); return t; } + // Single-word prefix like chore:, feat:, etc. const simpleMatch = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (simpleMatch) { const prefix = simpleMatch[1].toLowerCase(); @@ -159,7 +161,7 @@ function nextVersion(lastTag) { // --- Main --- async function main() { - // Latest non-draft release + // 1️⃣ Latest non-draft release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); @@ -171,7 +173,7 @@ async function main() { const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // Determine target branch + // 2️⃣ Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -187,11 +189,11 @@ async function main() { } catch {} } - // Fetch merged PRs + // 3️⃣ Fetch merged PRs const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); - // Build issue β†’ PR map + // 4️⃣ Build issue β†’ PR map const issueMap = {}; const prsWithoutIssue = []; @@ -207,7 +209,7 @@ async function main() { } } - // Build sections + // 5️⃣ Build sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -223,8 +225,8 @@ async function main() { // PRs linked to issues for (const [num, info] of Object.entries(issueMap)) { - const section = classifyTitle(info.title); - const title = info.title; // keep original with all prefixes + const title = normalizeTitleForNotes(info.title); + const section = classifyTitle(title); const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } @@ -236,7 +238,7 @@ async function main() { sections[section].push(`#${pr.number} ${title}`); } - // Build release notes + // 6️⃣ Build release notes text let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; @@ -248,7 +250,7 @@ async function main() { console.log(releaseNotesText); - // Find or create draft release + // 7️⃣ Find or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From 8666f89e704cf9e02093697028de90b8f46f806f Mon Sep 17 00:00:00 2001 From: Serg Date: Sat, 1 Nov 2025 13:22:30 +0700 Subject: [PATCH 57/58] feat: fix prefix to bug fixes section, add pr author --- .github/scripts/release-notes.js | 35 +++++++++++++------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index b9e753e..ea6d9c6 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -33,6 +33,7 @@ const PREFIX_MAP = { "ux/ui": "πŸ”§ Enhancements", "enhancement": "πŸ”§ Enhancements", "bug": "🐞 Bug Fixes", + "fix": "🐞 Bug Fixes", "feat": "✨ New Features", "refactor": "πŸ›  Refactoring", "docs": "πŸ“š Documentation", @@ -46,10 +47,8 @@ const PREFIX_MAP = { // --- Strip leading emojis --- function stripLeadingEmoji(title) { let t = title.trim(); - // Remove :emoji: at start - t = t.replace(/^(:[a-zA-Z0-9_+-]+:)+\s*/g, ''); - // Remove Unicode emojis - t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); + t = t.replace(/^(:[a-zA-Z0-9_+-]+:)+\s*/g, ''); // :emoji: + t = t.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u, ''); // Unicode emoji return t.trim(); } @@ -57,15 +56,13 @@ function stripLeadingEmoji(title) { function classifyTitle(title) { const t = stripLeadingEmoji(title); - // Bracket prefix [Feat], [Feat, UX/UI], etc. let match = t.match(/^\[([^\]]+)\]/); if (match) { const firstPrefix = match[1].split(',')[0].trim().toLowerCase(); return PREFIX_MAP[firstPrefix] || "Other"; } - // Single-word prefix like chore:, feat: - match = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + match = t.match(/^(bug|fix|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (match) { const prefix = match[1].toLowerCase(); return PREFIX_MAP[prefix] || "Other"; @@ -85,12 +82,12 @@ function normalizeTitleForNotes(title) { const bracketMatch = t.match(/^\[([^\]]+)\]/); if (bracketMatch) { const firstPrefix = bracketMatch[1].split(',')[0].trim(); - t = `[${firstPrefix}]` + t.slice(bracketMatch[0].length).trimStart(); + t = `[${firstPrefix}] ` + t.slice(bracketMatch[0].length).trimStart(); return t; } - // Single-word prefix like chore:, feat:, etc. - const simpleMatch = t.match(/^(bug|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); + // Single-word prefix like chore:, feat:, fix:, etc. + const simpleMatch = t.match(/^(bug|fix|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (simpleMatch) { const prefix = simpleMatch[1].toLowerCase(); t = t.replace(simpleMatch[0], `[${prefix.charAt(0).toUpperCase() + prefix.slice(1)}] `); @@ -161,7 +158,6 @@ function nextVersion(lastTag) { // --- Main --- async function main() { - // 1️⃣ Latest non-draft release let lastRelease = null; try { const { data } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 20 }); @@ -173,7 +169,6 @@ async function main() { const lastTag = lastRelease?.tag_name || null; const newTag = nextVersion(lastTag); - // 2️⃣ Determine target branch const branches = await octokit.repos.listBranches({ owner: OWNER, repo: REPO }); const branchNames = branches.data.map(b => b.name); let targetBranch = MASTER_BRANCH; @@ -189,11 +184,9 @@ async function main() { } catch {} } - // 3️⃣ Fetch merged PRs const prs = await getAllPRs({ owner: OWNER, repo: REPO, base: targetBranch }); const mergedPRs = prs.filter(pr => pr.merged_at && (!since || new Date(pr.merged_at) > since)); - // 4️⃣ Build issue β†’ PR map const issueMap = {}; const prsWithoutIssue = []; @@ -202,14 +195,13 @@ async function main() { if (linkedIssues.length) { for (const issue of linkedIssues) { if (!issueMap[issue.number]) issueMap[issue.number] = { title: issue.title, prs: [] }; - issueMap[issue.number].prs.push(pr.number); + issueMap[issue.number].prs.push({ number: pr.number, user: pr.user?.login }); } } else { prsWithoutIssue.push(pr); } } - // 5️⃣ Build sections const sections = { "πŸš€ Tasks": [], "πŸ”§ Enhancements": [], @@ -223,11 +215,14 @@ async function main() { Other: [], }; - // PRs linked to issues + // Issues for (const [num, info] of Object.entries(issueMap)) { const title = normalizeTitleForNotes(info.title); const section = classifyTitle(title); - const prsText = info.prs.sort((a, b) => a - b).map(n => `#${n}`).join(", "); + const prsText = info.prs + .sort((a, b) => a.number - b.number) + .map(p => `#${p.number} by @${p.user}`) + .join(", "); sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } @@ -235,10 +230,9 @@ async function main() { for (const pr of prsWithoutIssue) { const title = normalizeTitleForNotes(pr.title); const section = classifyTitle(title); - sections[section].push(`#${pr.number} ${title}`); + sections[section].push(`#${pr.number} ${title} by @${pr.user?.login}`); } - // 6️⃣ Build release notes text let releaseNotesText = `## Draft Release Notes\n\n`; for (const [sectionName, items] of Object.entries(sections)) { if (!items.length) continue; @@ -250,7 +244,6 @@ async function main() { console.log(releaseNotesText); - // 7️⃣ Find or create draft release let draftRelease = null; try { const { data: releases } = await octokit.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 10 }); From 923b9ba83e23e02f31554293c8341ec797ee596c Mon Sep 17 00:00:00 2001 From: Serg Date: Wed, 12 Nov 2025 18:06:28 +0700 Subject: [PATCH 58/58] fix: new features & enhancements section --- .github/scripts/release-notes.js | 48 +++++++++++++++----------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js index ea6d9c6..9742286 100644 --- a/.github/scripts/release-notes.js +++ b/.github/scripts/release-notes.js @@ -2,7 +2,6 @@ import { Octokit } from "@octokit/rest"; import { graphql } from "@octokit/graphql"; import { execSync } from "child_process"; -// --- Detect repository --- function detectRepo() { if (process.env.GITHUB_REPOSITORY) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); @@ -16,7 +15,6 @@ function detectRepo() { throw new Error("❌ Repository could not be detected."); } -// --- Config --- const DEV_BRANCH = "dev"; const MASTER_BRANCH = "master"; const { owner: OWNER, repo: REPO } = detectRepo(); @@ -26,15 +24,11 @@ const graphqlWithAuth = graphql.defaults({ headers: { authorization: `token ${process.env.GITHUB_TOKEN}` }, }); -// --- Prefix map for classification --- const PREFIX_MAP = { "task": "πŸš€ Tasks", "composite": "πŸš€ Tasks", - "ux/ui": "πŸ”§ Enhancements", - "enhancement": "πŸ”§ Enhancements", "bug": "🐞 Bug Fixes", "fix": "🐞 Bug Fixes", - "feat": "✨ New Features", "refactor": "πŸ›  Refactoring", "docs": "πŸ“š Documentation", "test": "βœ… Tests", @@ -42,9 +36,11 @@ const PREFIX_MAP = { "proposal": "πŸ’‘ Ideas & Proposals", "idea": "πŸ’‘ Ideas & Proposals", "discussion": "πŸ’‘ Ideas & Proposals", + "feat": "✨ New features & Enhancements", + "enhancement": "✨ New features & Enhancements", + "ux/ui": "✨ New features & Enhancements", }; -// --- Strip leading emojis --- function stripLeadingEmoji(title) { let t = title.trim(); t = t.replace(/^(:[a-zA-Z0-9_+-]+:)+\s*/g, ''); // :emoji: @@ -52,7 +48,6 @@ function stripLeadingEmoji(title) { return t.trim(); } -// --- Classify title by first prefix --- function classifyTitle(title) { const t = stripLeadingEmoji(title); @@ -71,14 +66,11 @@ function classifyTitle(title) { return "Other"; } -// --- Normalize title for release notes --- function normalizeTitleForNotes(title) { let t = title.trim(); - // Remove leading emoji or :emoji: t = t.replace(/^([\s\p{Emoji_Presentation}\p{Extended_Pictographic}]+|(:[a-zA-Z0-9_+-]+:)+)\s*/u, ''); - // Bracket prefix [Feat, Enhancement] β†’ [Feat] const bracketMatch = t.match(/^\[([^\]]+)\]/); if (bracketMatch) { const firstPrefix = bracketMatch[1].split(',')[0].trim(); @@ -86,7 +78,6 @@ function normalizeTitleForNotes(title) { return t; } - // Single-word prefix like chore:, feat:, fix:, etc. const simpleMatch = t.match(/^(bug|fix|feat|enhancement|refactor|docs|test|chore|task|composite|ux\/ui)[:\s-]+/i); if (simpleMatch) { const prefix = simpleMatch[1].toLowerCase(); @@ -97,7 +88,6 @@ function normalizeTitleForNotes(title) { return t; } -// --- Fetch all closed PRs --- async function getAllPRs({ owner, repo, base }) { const perPage = 100; let page = 1; @@ -119,7 +109,6 @@ async function getAllPRs({ owner, repo, base }) { return all; } -// --- Get linked issues via GraphQL --- async function getLinkedIssues(prNumber) { const query = ` query ($owner: String!, $repo: String!, $number: Int!) { @@ -133,8 +122,7 @@ async function getLinkedIssues(prNumber) { } } } - } - `; + }`; try { const response = await graphqlWithAuth(query, { owner: OWNER, repo: REPO, number: prNumber }); return response.repository.pullRequest.closingIssuesReferences.nodes.map(i => ({ @@ -146,7 +134,6 @@ async function getLinkedIssues(prNumber) { } } -// --- Semantic versioning --- function nextVersion(lastTag) { if (!lastTag) return "v0.1.0"; const match = lastTag.match(/^v(\d+)\.(\d+)\.(\d+)/); @@ -156,7 +143,6 @@ function nextVersion(lastTag) { return `v${major}.${minor}.${patch}`; } -// --- Main --- async function main() { let lastRelease = null; try { @@ -203,10 +189,9 @@ async function main() { } const sections = { - "πŸš€ Tasks": [], - "πŸ”§ Enhancements": [], + "✨ New features & Enhancements": [], "🐞 Bug Fixes": [], - "✨ New Features": [], + "πŸš€ Tasks": [], "πŸ›  Refactoring": [], "πŸ“š Documentation": [], "βœ… Tests": [], @@ -215,7 +200,6 @@ async function main() { Other: [], }; - // Issues for (const [num, info] of Object.entries(issueMap)) { const title = normalizeTitleForNotes(info.title); const section = classifyTitle(title); @@ -226,7 +210,6 @@ async function main() { sections[section].push(`#${num} ${title}\n↳ PRs: ${prsText}`); } - // PRs without issues for (const pr of prsWithoutIssue) { const title = normalizeTitleForNotes(pr.title); const section = classifyTitle(title); @@ -234,7 +217,22 @@ async function main() { } let releaseNotesText = `## Draft Release Notes\n\n`; - for (const [sectionName, items] of Object.entries(sections)) { + + // New features & Enhancements β€” first + const orderedSections = [ + "✨ New features & Enhancements", + "🐞 Bug Fixes", + "πŸš€ Tasks", + "πŸ›  Refactoring", + "πŸ“š Documentation", + "βœ… Tests", + "βš™οΈ Chores", + "πŸ’‘ Ideas & Proposals", + "Other", + ]; + + for (const sectionName of orderedSections) { + const items = sections[sectionName]; if (!items.length) continue; items.sort((a, b) => parseInt(a.match(/#(\d+)/)[1]) - parseInt(b.match(/#(\d+)/)[1])); releaseNotesText += `### ${sectionName}\n`; @@ -267,7 +265,6 @@ async function main() { name: `Release ${newTag}`, body: releaseNotesText, draft: true, - prerelease: false, }); console.log(`βœ… Draft release created: ${newTag}`); } @@ -275,7 +272,6 @@ async function main() { console.log(`βœ… Release processing completed`); } -// Run main().catch(err => { console.error("Error:", err); process.exit(1);