qwen-code/scripts/tests/generate-changelog.test.js
易良 ae9e7782d4
feat(release): generate AI-assisted release notes (#6756)
* feat(release): generate AI-assisted release notes

* docs(release): remove AI release notes planning docs

* fix(release): preserve complete AI release notes

* refactor(release): trim AI notes generator surface

* ci: Open autofix issues for main CI failures

* fix(release): harden AI notes review feedback

* fix(release): close AI notes review gaps
2026-07-12 13:00:22 +00:00

362 lines
10 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
categorize,
isNoiseEntry,
parseReleaseEntries,
formatEntry,
formatRelease,
buildChangelog,
toReleaseModel,
selectStableReleases,
parseJsonl,
} from '../generate-changelog.js';
const PR = (n) => `https://github.com/QwenLM/qwen-code/pull/${n}`;
describe('categorize', () => {
it('splits type, scope, and description', () => {
expect(categorize('feat(cli): add x')).toEqual({
type: 'feat',
scope: 'cli',
description: 'add x',
breaking: false,
});
});
it('handles a missing scope', () => {
expect(categorize('fix: bar')).toEqual({
type: 'fix',
scope: null,
description: 'bar',
breaking: false,
});
});
it('captures the breaking-change "!" marker', () => {
expect(categorize('feat(core)!: breaking')).toEqual({
type: 'feat',
scope: 'core',
description: 'breaking',
breaking: true,
});
});
it('captures "!" even without a scope', () => {
expect(categorize('feat!: drop legacy flag')).toEqual({
type: 'feat',
scope: null,
description: 'drop legacy flag',
breaking: true,
});
});
it('treats a prefix-less subject as untyped', () => {
expect(categorize('Improve hooks matcher display')).toEqual({
type: null,
scope: null,
description: 'Improve hooks matcher display',
breaking: false,
});
});
});
describe('isNoiseEntry', () => {
it('drops the release bot version bump', () => {
expect(isNoiseEntry(categorize('chore(release): v0.17.0'))).toBe(true);
});
it('keeps other chores (e.g. dependency bumps)', () => {
expect(isNoiseEntry(categorize('chore(deps): update @google/genai'))).toBe(
false,
);
});
it('keeps real changes', () => {
expect(isNoiseEntry(categorize('feat(cli): add x'))).toBe(false);
});
});
describe('parseReleaseEntries', () => {
it('extracts only "What\'s Changed" bullets', () => {
const body = [
"## What's Changed",
'* feat(cli): add x by @alice in ' + PR(42),
'- fix(core): do y by @bob-1 in ' + PR(7),
'',
'## New Contributors',
'* @newbie made their first contribution in ' + PR(99),
'',
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1...v2',
].join('\n');
expect(parseReleaseEntries(body)).toEqual([
{
title: 'feat(cli): add x',
author: 'alice',
prUrl: PR(42),
prNumber: '42',
},
{
title: 'fix(core): do y',
author: 'bob-1',
prUrl: PR(7),
prNumber: '7',
},
]);
});
it('parses GitHub App authors with a [bot] suffix', () => {
const body = '* chore(deps): bump foo by @dependabot[bot] in ' + PR(33);
expect(parseReleaseEntries(body)).toEqual([
{
title: 'chore(deps): bump foo',
author: 'dependabot[bot]',
prUrl: PR(33),
prNumber: '33',
},
]);
});
it('parses entries with an additional collaborator credit', () => {
const body =
'* fix(ci): retry publishing by @alice with @Copilot in ' + PR(6574);
expect(parseReleaseEntries(body)).toEqual([
{
title: 'fix(ci): retry publishing',
author: 'alice',
prUrl: PR(6574),
prNumber: '6574',
},
]);
});
it('binds the trailing " by @… in …" to the last occurrence', () => {
const body = '* fix: stop saying "done" by @carol in ' + PR(5);
expect(parseReleaseEntries(body)).toEqual([
{
title: 'fix: stop saying "done"',
author: 'carol',
prUrl: PR(5),
prNumber: '5',
},
]);
});
it('returns an empty list for an empty or link-only body', () => {
expect(parseReleaseEntries('')).toEqual([]);
expect(
parseReleaseEntries(
'**Full Changelog**: https://github.com/o/r/compare/a...b',
),
).toEqual([]);
});
});
describe('formatEntry', () => {
it('strips a recognised type prefix but keeps the scope', () => {
expect(
formatEntry({ title: 'feat(cli): add x', prNumber: '42', prUrl: PR(42) }),
).toBe(`- cli: add x ([#42](${PR(42)}))`);
});
it('drops the prefix entirely when there is no scope', () => {
expect(
formatEntry({ title: 'fix: bar', prNumber: '7', prUrl: PR(7) }),
).toBe(`- bar ([#7](${PR(7)}))`);
});
it('keeps an untyped title verbatim', () => {
expect(
formatEntry({ title: 'Support openrouter', prNumber: '9', prUrl: PR(9) }),
).toBe(`- Support openrouter ([#9](${PR(9)}))`);
});
it('flags a breaking change with a BREAKING prefix', () => {
expect(
formatEntry({
title: 'refactor(core)!: replace X',
prNumber: '8',
prUrl: PR(8),
}),
).toBe(`- **BREAKING** core: replace X ([#8](${PR(8)}))`);
});
});
describe('formatRelease', () => {
it('groups entries into ordered sections and drops noise', () => {
const block = formatRelease({
version: '1.2.3',
date: '2026-01-02',
htmlUrl: 'https://example.com/v1.2.3',
entries: [
{ title: 'fix(core): a', prNumber: '1', prUrl: PR(1) },
{ title: 'feat(cli): b', prNumber: '2', prUrl: PR(2) },
{ title: 'chore(release): v1.2.3', prNumber: '3', prUrl: PR(3) },
{ title: 'docs: c', prNumber: '4', prUrl: PR(4) },
],
});
expect(block).toContain(
'## [1.2.3](https://example.com/v1.2.3) - 2026-01-02',
);
// Added before Fixed before Documentation.
expect(block.indexOf('### Added')).toBeLessThan(block.indexOf('### Fixed'));
expect(block.indexOf('### Fixed')).toBeLessThan(
block.indexOf('### Documentation'),
);
// The release bot bump is excluded.
expect(block).not.toContain('v1.2.3 (');
expect(block).not.toContain('### Other');
});
it('falls back to a release link when nothing parses', () => {
const block = formatRelease({
version: '0.0.2',
date: '2025-08-01',
htmlUrl: 'https://example.com/v0.0.2',
entries: [],
});
expect(block).toContain(
'_See [GitHub release](https://example.com/v0.0.2) for details._',
);
expect(block).not.toContain('###');
});
it('preserves curated release notes below the changelog version heading', () => {
const block = formatRelease({
version: '1.2.3',
date: '2026-01-02',
htmlUrl: 'https://example.com/v1.2.3',
body: [
'<!-- qwen-release-notes:v1 -->',
'',
'## Highlights',
'',
'- Easier session recovery. ([#1](https://example.com/pr/1))',
'',
'## Complete Change List',
'',
'### Bug Fixes',
'',
'- Preserves tool results. ([#1](https://example.com/pr/1))',
].join('\n'),
entries: [],
});
expect(block).toContain('### Highlights');
expect(block).toContain('### Complete Change List');
expect(block).toContain('#### Bug Fixes');
expect(block).not.toContain('qwen-release-notes:v1');
expect(block).not.toContain('_See [GitHub release]');
});
it('does not trust a curated marker embedded in an ordinary PR title', () => {
const block = formatRelease({
version: '1.2.3',
date: '2026-01-02',
htmlUrl: 'https://example.com/v1.2.3',
body: `## What's Changed\n* feat: ${'<'}!-- qwen-release-notes:v1 --${'>'} by @alice in ${PR(1)}`,
entries: [
{
title: 'feat: ordinary change',
prNumber: '1',
prUrl: PR(1),
},
],
});
expect(block).toContain('### Added');
expect(block).not.toContain("### What's Changed");
});
it('preserves curated release notes from the raw GitHub release model', () => {
const block = formatRelease(
toReleaseModel({
tag: 'v1.2.3',
date: '2026-01-02T00:00:00Z',
url: 'https://example.com/v1.2.3',
body: [
'<!-- qwen-release-notes:v1 -->',
'',
'## Highlights',
'',
'- Easier session recovery. ([#1](https://example.com/pr/1))',
].join('\n'),
}),
);
expect(block).toContain('### Highlights');
expect(block).toContain('Easier session recovery.');
});
});
describe('selectStableReleases', () => {
it('keeps only stable semver releases, newest first', () => {
const stable = (tag) => ({ tag, prerelease: false, draft: false });
const releases = selectStableReleases([
stable('v1.2.0'),
stable('v1.10.0'),
{ tag: 'v1.2.0-preview.1', prerelease: true, draft: false },
{ tag: 'v1.3.0-nightly.20260101.abc', prerelease: true, draft: false },
stable('some-random-tag'),
{ tag: 'v1.0.0', prerelease: false, draft: true },
]);
// Drops the preview/nightly pre-releases, the non-semver tag, and the
// draft; sorts the survivors newest-first (1.10.0 > 1.2.0).
expect(releases.map((r) => r.version)).toEqual(['1.10.0', '1.2.0']);
});
});
describe('toReleaseModel', () => {
it('derives version and trims the date', () => {
const model = toReleaseModel({
tag: 'v0.17.1',
date: '2026-06-03T11:58:14Z',
url: 'https://example.com/v0.17.1',
body: '* feat: x by @a in ' + PR(1),
});
expect(model.version).toBe('0.17.1');
expect(model.date).toBe('2026-06-03');
expect(model.entries).toHaveLength(1);
});
it('marks a non-semver tag as unversioned', () => {
expect(
toReleaseModel({ tag: 'nightly-build', body: '' }).version,
).toBeNull();
});
});
describe('parseJsonl', () => {
it('parses one object per line and skips blanks', () => {
const jsonl = '{"tag":"v1.0.0"}\n\n{"tag":"v1.1.0"}\n';
expect(parseJsonl(jsonl)).toEqual([{ tag: 'v1.0.0' }, { tag: 'v1.1.0' }]);
});
});
describe('buildChangelog', () => {
it('renders the header and each release once', () => {
const out = buildChangelog([
{
version: '1.0.0',
date: '2026-01-01',
htmlUrl: 'https://example.com/v1.0.0',
entries: [{ title: 'feat: launch', prNumber: '1', prUrl: PR(1) }],
},
]);
expect(out.startsWith('# Changelog')).toBe(true);
expect(out).toContain('Keep a Changelog');
expect(out).toContain(
'## [1.0.0](https://example.com/v1.0.0) - 2026-01-01',
);
expect(out.endsWith('\n')).toBe(true);
expect(out).not.toMatch(/\n{3,}/);
});
});