qwen-code/scripts/tests/generate-changelog.test.js
顾盼 32fbedabd6
feat(ci): add auto-generated CHANGELOG.md synced from releases (#4872) (#4881)
* feat(ci): add auto-generated CHANGELOG.md synced from releases (#4872)

Add a Keep a Changelog CHANGELOG.md that is fully derived from the project's
GitHub Releases, so users no longer have to dig through commit history to see
what changed between versions.

- scripts/generate-changelog.js: fetch releases via the gh CLI, keep only
  stable vX.Y.Z tags (nightly/preview omitted), and re-group each release's
  auto-generated "What's Changed" list into Added/Changed/Fixed/Performance/
  Documentation/Other sections by the conventional-commit prefix every PR title
  uses. Zero runtime deps (node builtins + gh).
- release.yml: regenerate and commit CHANGELOG.md on stable releases; the
  commit rides the existing release-branch PR into main.
- package.json: add 'npm run changelog'.
- .prettierignore: skip the generated file.
- Seed CHANGELOG.md from the current stable release history.

Closes #4872

* ci(release): make CHANGELOG regeneration non-blocking

Add continue-on-error to the CHANGELOG step. Its only realistic failures
(the gh API read and the git push) are transient, and the generator rebuilds
from the full release history each run, so a skipped update self-heals on the
next stable release. This keeps a changelog hiccup from blocking the
version-bump PR to main that follows.

* fix(ci): harden changelog generator per review

Address review findings on the changelog generator:

- Command injection: fetchReleasesJsonl built a shell string with the repo
  interpolated; switch to execFileSync (no shell) plus an owner/name format
  check so --repo can never be a shell payload.
- Empty-response clobber: if the API returns zero stable releases (rate limit,
  auth, 5xx), refuse to overwrite CHANGELOG.md and exit 1 instead of committing
  a header-only stub. Paired with set -euo pipefail in the workflow so the
  failure is visible and the non-blocking step self-heals next release.
- Arg parsing: switch getArgs() to parseArgs() (already imported) so a --dryrun
  typo errors instead of silently overwriting, and -h works as documented.
- Breaking changes: capture the conventional-commit ! marker and prefix the
  entry with **BREAKING** (the repo uses feat()!:/refactor()!: in practice).
- Bot authors: ENTRY_RE now accepts a trailing [bot] so GitHub App authors
  (e.g. @dependabot[bot]) are not dropped from release notes.

Regenerates CHANGELOG.md (two entries now flagged **BREAKING**).

* refactor(ci): simplify changelog generator

Quality-only cleanup (output is byte-identical; 23 tests green):

- Single source of truth for sections: derive TYPE_TO_SECTION and
  SECTION_ORDER from one SECTIONS list so they can't drift.
- Parse each entry once: formatRelease now reuses the categorize() result for
  the noise check, section lookup, and formatEntry (was parsed up to 3x).
- Drop the redundant sortKey field; sort directly from version.
- Collapse the duplicated double-.map() setup in the selectStableReleases test.
2026-06-10 14:04:43 +08:00

282 lines
7.9 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('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('###');
});
});
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,}/);
});
});