diff --git a/package.json b/package.json index 839564f..ba9cdbc 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/node": "^22.5.2", "@types/yargs": "^17.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e20dc98..ae2d1ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@eslint/js': specifier: ^9.38.0 version: 9.38.0 + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -52,7 +55,7 @@ importers: version: 3.7.4 ts-jest: specifier: '29' - version: 29.4.1(@babel/core@7.28.5)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.5))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.17.2)(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)))(typescript@5.9.2) + version: 29.4.1(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.1.2(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.17.2)(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)))(typescript@5.9.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2) @@ -616,6 +619,13 @@ packages: } engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + '@jest/transform@30.2.0': + resolution: + { + integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + '@jest/types@29.6.3': resolution: { @@ -630,6 +640,13 @@ packages: } engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + '@jest/types@30.2.0': + resolution: + { + integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + '@jridgewell/gen-mapping@0.3.13': resolution: { @@ -2203,6 +2220,13 @@ packages: } engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-haste-map@30.2.0: + resolution: + { + integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-leak-detector@29.7.0: resolution: { @@ -2306,6 +2330,13 @@ packages: } engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-util@30.2.0: + resolution: + { + integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-validate@29.7.0: resolution: { @@ -2334,6 +2365,13 @@ packages: } engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-worker@30.2.0: + resolution: + { + integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest@29.7.0: resolution: { @@ -4002,6 +4040,27 @@ snapshots: - supports-color optional: true + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.28.5 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + optional: true + '@jest/types@29.6.3': dependencies: '@jest/schemas': 29.6.3 @@ -4022,6 +4081,17 @@ snapshots: chalk: 4.1.2 optional: true + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.1 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5161,6 +5231,22 @@ snapshots: fsevents: 2.3.3 optional: true + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.19.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + optional: true + jest-leak-detector@29.7.0: dependencies: jest-get-type: 29.6.3 @@ -5316,6 +5402,16 @@ snapshots: picomatch: 4.0.3 optional: true + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.19.1 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + optional: true + jest-validate@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -5352,6 +5448,15 @@ snapshots: supports-color: 8.1.1 optional: true + jest-worker@30.2.0: + dependencies: + '@types/node': 22.19.1 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + optional: true + jest@29.7.0(@types/node@22.17.2)(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)) @@ -5735,7 +5840,7 @@ snapshots: dependencies: typescript: 5.9.2 - ts-jest@29.4.1(@babel/core@7.28.5)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.5))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.17.2)(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)))(typescript@5.9.2): + ts-jest@29.4.1(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.1.2(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.17.2)(ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2)))(typescript@5.9.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -5750,10 +5855,10 @@ snapshots: yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.28.5 - '@jest/transform': 30.1.2 - '@jest/types': 30.0.5 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 babel-jest: 30.1.2(@babel/core@7.28.5) - jest-util: 30.0.5 + jest-util: 30.2.0 ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2): dependencies: diff --git a/src/constants/markdown.ts b/src/constants/markdown.ts index 4edb983..8f003b4 100644 --- a/src/constants/markdown.ts +++ b/src/constants/markdown.ts @@ -4,3 +4,4 @@ export const ANY_H2_HEADING_RE = /^##\s.*$/m; export const H3_SUBHEADER_CAPTURE_RE = /^###\s+(.+?)\s*$/; export const RELEASE_HEADER_CAPTURE_RE = /^##\s*\[([^\]]+)\](.*)$/; +export const BULLET_PREFIX_RE = /^[*-]\s+/; diff --git a/src/utils/category-tune.ts b/src/utils/category-tune.ts index d345d5d..b2fd203 100644 --- a/src/utils/category-tune.ts +++ b/src/utils/category-tune.ts @@ -18,6 +18,7 @@ import { scoreCategories, SCORE_THRESHOLDS, } from '@/utils/category-score.js'; +import { isDependencyUpdateTitle } from '@/utils/dependency-update.js'; import { isBucketName } from '@/utils/is.js'; import type { BucketName } from '@/types/changelog.js'; @@ -182,9 +183,23 @@ export function tuneCategoriesByTitle( if (!adjusted[SECTION_CHANGED]) adjusted[SECTION_CHANGED] = []; if (!adjusted[SECTION_ADDED]) adjusted[SECTION_ADDED] = []; + const allKnownTitles = Array.from(knownTitles); + + // Rule: Dependency-only updates should remain in Chore to avoid noise in Changed. + const dependencyUpdates: string[] = []; + for (const title of allKnownTitles) { + if (isDependencyUpdateTitle(title)) dependencyUpdates.push(title); + } + if (dependencyUpdates.length) + moveTitlesToCategory(adjusted, dependencyUpdates, SECTION_CHORE); + const dependencyUpdateSet = new Set(dependencyUpdates); + const nonDependencyTitles = allKnownTitles.filter( + (title) => !dependencyUpdateSet.has(title), + ); + // Collect titles that should be moved. const toMove: string[] = []; - for (const title of knownTitles) { + for (const title of nonDependencyTitles) { if (isImplicitFixTitle(title)) toMove.push(title); } @@ -196,7 +211,7 @@ export function tuneCategoriesByTitle( // Examples: fix: msg, fix!: msg, fix(scope): msg, fix(scope)!: msg const FIX_PREFIX_RE = /^fix(?:!:|(?:\([^)]*\))?!?:)/i; const conventionalFixes: string[] = []; - for (const title of knownTitles) { + for (const title of nonDependencyTitles) { if (FIX_PREFIX_RE.test(title)) conventionalFixes.push(title); } if (conventionalFixes.length) @@ -217,7 +232,7 @@ export function tuneCategoriesByTitle( }; const toChanged: string[] = []; - for (const title of knownTitles) { + for (const title of nonDependencyTitles) { if (!isRefactorLike(title) && !isChangeLike(title)) continue; const current = findCategory(title); if (current === SECTION_FIXED) continue; // don't override explicit/implicit fixes @@ -234,7 +249,7 @@ export function tuneCategoriesByTitle( // Examples: feat: msg, feat!: msg, feat(scope): msg, feat(scope)!: msg const FEAT_PREFIX_RE = /^feat(?:!:|(?:\([^)]*\))?!?:)/i; const toAdded: string[] = []; - for (const title of knownTitles) { + for (const title of nonDependencyTitles) { if (FEAT_PREFIX_RE.test(title)) toAdded.push(title); } if (toAdded.length) moveTitlesToCategory(adjusted, toAdded, SECTION_ADDED); @@ -245,7 +260,7 @@ export function tuneCategoriesByTitle( SECTION_DOCS, SECTION_TEST, ]); - for (const title of knownTitles) { + for (const title of nonDependencyTitles) { const current = findCategory(title); if (!current || !WEAK_BUCKETS.has(current)) continue; const scores = scoreCategories(title); diff --git a/src/utils/dependency-update.ts b/src/utils/dependency-update.ts new file mode 100644 index 0000000..54589cf --- /dev/null +++ b/src/utils/dependency-update.ts @@ -0,0 +1,39 @@ +import { CONVENTIONAL_PREFIX_RE } from '@/constants/conventional.js'; +import { BULLET_PREFIX_RE } from '@/constants/markdown.js'; +import { BUMP_OR_UPGRADE_RE, VERSION_FROM_TO_RE } from '@/constants/scoring.js'; + +const CONVENTIONAL_SCOPE_RE = /^[a-z]+(?:\(([^)]+)\))?!?:/i; +const DEP_SCOPE_RE = /\bdeps(?:-dev|-prod)?\b|\bdependencies?\b/i; +const DEP_BOT_RE = /\brenovate\b|\bdependabot\b|\bdeps?bot\b/i; +const DEP_ACTION_RE = /\b(bump|upgrade|update|pin|refresh|lockfile)\b/i; +const DEP_VERSION_RE = /\bto\s+v?\d+(?:\.\d+){0,3}\b/i; + +/** + * Detect whether a title represents a dependency-only update. + * @param rawTitle PR title or changelog bullet text to inspect. + * @returns True when the title looks like a dependency update. + */ +export function isDependencyUpdateTitle(rawTitle: string): boolean { + if (!rawTitle) return false; + const trimmedTitle = rawTitle.replace(BULLET_PREFIX_RE, '').trim(); + if (!trimmedTitle) return false; + + const scopeMatch = trimmedTitle.match(CONVENTIONAL_SCOPE_RE); + const scope = scopeMatch?.[1]; + if (scope && DEP_SCOPE_RE.test(scope)) return true; + + const lower = trimmedTitle.toLowerCase(); + if (DEP_BOT_RE.test(lower)) return true; + + const core = lower.replace(CONVENTIONAL_PREFIX_RE, '').trim(); + const hasDepPlural = /\bdeps\b|\bdependencies\b/.test(core); + const hasDepSingular = /\bdependency\b/.test(core); + const hasAction = DEP_ACTION_RE.test(core) || BUMP_OR_UPGRADE_RE.test(core); + const hasVersionHint = + VERSION_FROM_TO_RE.test(core) || DEP_VERSION_RE.test(core); + + if (hasDepPlural && hasAction) return true; + if (hasDepSingular && hasAction && hasVersionHint) return true; + + return false; +} diff --git a/src/utils/release.ts b/src/utils/release.ts index af44726..05b1c92 100644 --- a/src/utils/release.ts +++ b/src/utils/release.ts @@ -10,9 +10,9 @@ import { normalizeTitle, } from '@/utils/title-normalize.js'; import { FULL_CHANGELOG_RE } from '@/constants/release.js'; +import { BULLET_PREFIX_RE } from '@/constants/markdown.js'; const H2_HEADING_RE = /^##\s+(.*)$/; -const BULLET_PREFIX_RE = /^[*-]\s+/; const PR_URL_RE = /https?:\/\/\S+\/pull\/(\d+)/; // captures PR number const PR_REF_RE = /\(#?(\d+)\)|#(\d+)/; // (#123) or #123 const AUTHOR_RE = /@([A-Za-z0-9_-]+)/; diff --git a/src/utils/section-postprocess.ts b/src/utils/section-postprocess.ts index 87c1e96..ca8233c 100644 --- a/src/utils/section-postprocess.ts +++ b/src/utils/section-postprocess.ts @@ -1,5 +1,163 @@ import { removeMergedPRs } from '@/utils/remove-merged-prs.js'; import { attachPrNumbers } from '@/utils/attach-pr.js'; +import { isDependencyUpdateTitle } from '@/utils/dependency-update.js'; +import { + SECTION_CHANGED, + SECTION_CHORE, + SECTION_ORDER, +} from '@/constants/changelog.js'; +import { + BULLET_PREFIX_RE, + H3_SUBHEADER_CAPTURE_RE, +} from '@/constants/markdown.js'; + +const SECTION_ORDER_INDEX = new Map( + SECTION_ORDER.map((sectionName, index) => [sectionName.toLowerCase(), index]), +); + +type SectionBlock = { + headingLine: string; + name: string; + lines: string[]; +}; + +function parseSectionName(line: string): string | null { + const match = line.match(H3_SUBHEADER_CAPTURE_RE); + return match ? match[1].trim() : null; +} + +function splitSections(markdown: string): { + preamble: string[]; + sections: SectionBlock[]; +} { + const lines = markdown.split('\n'); + const preamble: string[] = []; + const sections: SectionBlock[] = []; + let current: SectionBlock | null = null; + + for (const line of lines) { + const heading = parseSectionName(line); + if (heading) { + if (current) sections.push(current); + current = { headingLine: line, name: heading, lines: [] }; + continue; + } + if (current) { + current.lines.push(line); + } else { + preamble.push(line); + } + } + + if (current) sections.push(current); + return { preamble, sections }; +} + +function hasMeaningfulContent(lines: string[]): boolean { + return lines.some((line) => line.trim().length > 0); +} + +function appendBulletLines(section: SectionBlock, bullets: string[]): void { + if (!bullets.length) return; + const existing = new Set( + section.lines + .filter((line) => BULLET_PREFIX_RE.test(line)) + .map((line) => line.trim()), + ); + const uniqueBullets = bullets.filter((line) => !existing.has(line.trim())); + if (!uniqueBullets.length) return; + + const hasContent = section.lines.some((line) => line.trim().length > 0); + if (!hasContent) { + section.lines = ['', ...uniqueBullets, '']; + return; + } + + if (!section.lines.length || section.lines[0].trim() !== '') { + section.lines.unshift(''); + } + + let insertIndex = section.lines.length; + while (insertIndex > 0 && section.lines[insertIndex - 1].trim() === '') { + insertIndex -= 1; + } + section.lines.splice(insertIndex, 0, ...uniqueBullets); + + if (!section.lines.length || section.lines[section.lines.length - 1].trim()) { + section.lines.push(''); + } +} + +function moveDependencyUpdatesToChore(markdown: string): string { + if (!markdown) return markdown; + const { preamble, sections } = splitSections(markdown); + if (!sections.length) return markdown; + + const changedIndex = sections.findIndex( + (section) => section.name.toLowerCase() === SECTION_CHANGED.toLowerCase(), + ); + if (changedIndex === -1) return markdown; + + const changedSection = sections[changedIndex]; + const movedBullets: string[] = []; + const retainedLines: string[] = []; + + for (const line of changedSection.lines) { + if (!BULLET_PREFIX_RE.test(line)) { + retainedLines.push(line); + continue; + } + const title = line.replace(BULLET_PREFIX_RE, '').trim(); + if (isDependencyUpdateTitle(title)) { + movedBullets.push(line); + } else { + retainedLines.push(line); + } + } + + if (!movedBullets.length) return markdown; + changedSection.lines = retainedLines; + + if (!hasMeaningfulContent(changedSection.lines)) { + sections.splice(changedIndex, 1); + } + + let choreSection = sections.find( + (section) => section.name.toLowerCase() === SECTION_CHORE.toLowerCase(), + ); + if (!choreSection) { + const choreOrder = + SECTION_ORDER_INDEX.get(SECTION_CHORE.toLowerCase()) ?? + Number.POSITIVE_INFINITY; + let insertIndex = sections.length; + for (let i = 0; i < sections.length; i += 1) { + const currentOrder = + SECTION_ORDER_INDEX.get(sections[i].name.toLowerCase()) ?? + Number.POSITIVE_INFINITY; + if (currentOrder > choreOrder) { + insertIndex = i; + break; + } + } + choreSection = { + headingLine: `### ${SECTION_CHORE}`, + name: SECTION_CHORE, + lines: [], + }; + sections.splice(insertIndex, 0, choreSection); + } + + appendBulletLines(choreSection, movedBullets); + + const output: string[] = []; + output.push(...preamble); + for (const section of sections) { + output.push(section.headingLine); + output.push(...section.lines); + } + + return output.join('\n').replace(/\n{3,}/g, '\n\n'); +} /** * Apply standard post-processing to a generated changelog section. @@ -17,5 +175,6 @@ export function postprocessSection( // WHY: Keep the section concise and link-rich for reviewers. let processedMarkdown = removeMergedPRs(markdown); processedMarkdown = attachPrNumbers(processedMarkdown, titleToPr, repo); + processedMarkdown = moveDependencyUpdatesToChore(processedMarkdown); return processedMarkdown; } diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..35bb0ea --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.tests.json", + "include": ["**/*.ts"] +} diff --git a/tests/utils/category-tune.test.ts b/tests/utils/category-tune.test.ts index 5679151..cf52660 100644 --- a/tests/utils/category-tune.test.ts +++ b/tests/utils/category-tune.test.ts @@ -53,4 +53,25 @@ describe('tuneCategoriesByTitle', () => { expect(out.Added).toContain('feat(core)!: new something'); expect(out.Fixed).toContain('fix(api)!: patch issue'); }); + + test('keeps dependency-only updates in Chore', () => { + const items: ReleaseItem[] = [ + { + title: 'Update dependency prettier to v3.8.0', + rawTitle: 'chore(deps): Update dependency prettier to v3.8.0', + }, + ]; + const categories: CategoryMap = { + Changed: ['chore(deps): Update dependency prettier to v3.8.0'], + }; + const out = tuneCategoriesByTitle(items, categories); + expect(out.Chore).toContain( + 'chore(deps): Update dependency prettier to v3.8.0', + ); + expect( + out.Changed?.includes( + 'chore(deps): Update dependency prettier to v3.8.0', + ), + ).toBeFalsy(); + }); }); diff --git a/tests/utils/dependency-update.test.ts b/tests/utils/dependency-update.test.ts new file mode 100644 index 0000000..1541ec3 --- /dev/null +++ b/tests/utils/dependency-update.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from '@jest/globals'; +import { isDependencyUpdateTitle } from '@/utils/dependency-update.js'; + +describe('isDependencyUpdateTitle', () => { + test('detects conventional deps scope updates', () => { + expect( + isDependencyUpdateTitle( + 'chore(deps): Update dependency prettier to v3.8.0 #113', + ), + ).toBe(true); + expect( + isDependencyUpdateTitle( + 'chore(deps-dev): bump @types/node from 18 to 20', + ), + ).toBe(true); + }); + + test('detects dependency keywords with action and version hints', () => { + expect(isDependencyUpdateTitle('deps: update lockfile')).toBe(true); + expect( + isDependencyUpdateTitle('chore: bump dependency lodash from 4 to 5'), + ).toBe(true); + }); + + test('ignores non-dependency updates without version hints', () => { + expect(isDependencyUpdateTitle('chore: update dependency resolver')).toBe( + false, + ); + expect(isDependencyUpdateTitle('docs: update README')).toBe(false); + }); +}); diff --git a/tests/utils/github-auth.test.ts b/tests/utils/github-auth.test.ts index 39515cb..94cf141 100644 --- a/tests/utils/github-auth.test.ts +++ b/tests/utils/github-auth.test.ts @@ -21,6 +21,12 @@ type FetchOptions = { headers?: Record; } & Record; +type FetchMock = jest.MockedFunction; + +function createFetchMock(): FetchMock { + return jest.fn() as FetchMock; +} + describe('github-auth utils', () => { const originalEnv = { ...process.env }; const originalFetch = global.fetch; @@ -57,12 +63,15 @@ describe('github-auth utils', () => { const calls: Array<{ url: string; options: FetchOptions }> = []; // Mock fetch for: GET /repos/{owner}/{repo}/installation then POST /app/installations/{id}/access_tokens - global.fetch = jest - .fn() - .mockImplementation(async (url: string, options: FetchOptions) => { - calls.push({ url, options }); + const fetchMock = createFetchMock().mockImplementation( + async (url, options) => { + const fetchOptions = (options ?? {}) as FetchOptions; + calls.push({ url: String(url), options: fetchOptions }); if (String(url).includes('/repos/acme/repo/installation')) { - return { ok: true, text: async () => JSON.stringify({ id: 999 }) }; + return { + ok: true, + text: async () => JSON.stringify({ id: 999 }), + } as Response; } if (String(url).includes('/app/installations/999/access_tokens')) { return { @@ -72,10 +81,16 @@ describe('github-auth utils', () => { token: 'ghs_install_token', expires_at: '2030-01-01T00:00:00Z', }), - }; + } as Response; } - return { ok: false, status: 404, text: async () => 'not found' }; - }); + return { + ok: false, + status: 404, + text: async () => 'not found', + } as Response; + }, + ) as FetchMock; + global.fetch = fetchMock; const auth = await resolveGitHubAuth('acme', 'repo'); expect(auth?.source).toBe('app'); @@ -98,7 +113,7 @@ describe('github-auth utils', () => { process.env.CHANGELOG_BOT_APP_INSTALLATION_ID = '777'; let requestCount = 0; - global.fetch = jest.fn().mockImplementation(async (url: string) => { + const fetchMock = createFetchMock().mockImplementation(async (url) => { requestCount += 1; if (String(url).includes('/app/installations/777/access_tokens')) { return { @@ -108,10 +123,15 @@ describe('github-auth utils', () => { token: 'ghs_token_777', expires_at: '2029-12-31T00:00:00Z', }), - }; + } as Response; } - return { ok: false, status: 404, text: async () => 'not found' }; - }); + return { + ok: false, + status: 404, + text: async () => 'not found', + } as Response; + }) as FetchMock; + global.fetch = fetchMock; const auth = await resolveGitHubAuth('acme', 'repo'); expect(auth?.source).toBe('app'); diff --git a/tests/utils/section-postprocess.test.ts b/tests/utils/section-postprocess.test.ts index 678070f..38ed4ca 100644 --- a/tests/utils/section-postprocess.test.ts +++ b/tests/utils/section-postprocess.test.ts @@ -1,24 +1,38 @@ -// @ts-nocheck -import { describe, test, expect } from '@jest/globals'; +import { describe, expect, test } from '@jest/globals'; import { postprocessSection } from '@/utils/section-postprocess.js'; -describe('section-postprocess', () => { - test('removes merged PRs and attaches missing PR numbers', () => { - const input = [ - '## [v1.0.0] - 2024-01-01', +function extractSection(md: string, heading: string): string { + const pattern = new RegExp(`###\\s+${heading}[\\s\\S]*?(?=###\\s+|$)`, 'i'); + const match = md.match(pattern); + return match ? match[0] : ''; +} + +describe('postprocessSection', () => { + test('moves dependency update bullets from Changed to Chore', () => { + const markdown = [ + '## [v1.2.3] - 2025-01-01', + '', + '### Changed', + '', + '- chore(deps): Update dependency prettier to v3.8.0', + '- Refactor core pipeline', '', - '### Added', - '- Add Login', + '### Fixed', '', - '### Merged PRs', - '- Merge pull request #1 from foo', + '- Fix bug', '', ].join('\n'); - const titleToPr = { 'Add Login': 123 }; - const out = postprocessSection(input, titleToPr); + const out = postprocessSection(markdown, {}); + const changedSection = extractSection(out, 'Changed'); + const choreSection = extractSection(out, 'Chore'); - expect(out).toContain('- Add Login (#123)'); - expect(out).not.toContain('### Merged PRs'); + expect(changedSection).toContain('- Refactor core pipeline'); + expect(changedSection).not.toContain( + 'chore(deps): Update dependency prettier to v3.8.0', + ); + expect(choreSection).toContain( + '- chore(deps): Update dependency prettier to v3.8.0', + ); }); });