mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 15:25:34 +00:00
* 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
362 lines
10 KiB
JavaScript
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,}/);
|
|
});
|
|
});
|