mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* 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.
This commit is contained in:
parent
0e28eb5f48
commit
32fbedabd6
6 changed files with 2999 additions and 1 deletions
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
|
|
@ -431,6 +431,36 @@ jobs:
|
|||
--generate-notes \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
- name: 'Regenerate CHANGELOG.md'
|
||||
# Stable releases only: nightly/preview ship daily and would drown out
|
||||
# the changelog. The just-created GitHub Release is already queryable,
|
||||
# so the generator picks it up. The release branch was already pushed
|
||||
# above, so push this follow-up commit too — otherwise the PR opened
|
||||
# below (whose head is the remote branch) would not include it.
|
||||
#
|
||||
# Non-blocking by design: the only realistic failures are transient
|
||||
# (the gh API read or the git push). The changelog is rebuilt from the
|
||||
# full release history on every run, so a skipped update self-heals on
|
||||
# the next stable release — never worth blocking the version-bump PR to
|
||||
# main that follows.
|
||||
if: |-
|
||||
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
|
||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
node scripts/generate-changelog.js
|
||||
git add CHANGELOG.md
|
||||
if git diff --cached --quiet -- CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already up to date."
|
||||
else
|
||||
git commit -m "docs(changelog): sync for ${RELEASE_TAG}"
|
||||
git push origin "${BRANCH_NAME}"
|
||||
fi
|
||||
|
||||
- name: 'Create PR to merge release branch into main'
|
||||
if: |-
|
||||
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
|
||||
|
|
@ -448,7 +478,7 @@ jobs:
|
|||
--base main \
|
||||
--head "${RELEASE_BRANCH}" \
|
||||
--title "chore(release): ${RELEASE_TAG}" \
|
||||
--body "Automated release PR for ${RELEASE_TAG}. Syncs package.json versions on main.")"
|
||||
--body "Automated release PR for ${RELEASE_TAG}. Syncs package.json versions and CHANGELOG.md on main.")"
|
||||
fi
|
||||
|
||||
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
*.tsbuildinfo
|
||||
*.vsix
|
||||
bower_components
|
||||
# Generated by scripts/generate-changelog.js — do not hand-format.
|
||||
CHANGELOG.md
|
||||
eslint.config.js
|
||||
**/generated
|
||||
gha-creds-*.json
|
||||
|
|
|
|||
2356
CHANGELOG.md
Normal file
2356
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -72,6 +72,7 @@
|
|||
"package:standalone:release": "node scripts/build-standalone-release.js",
|
||||
"verify:installation-release": "node scripts/verify-installation-release.js",
|
||||
"release:version": "node scripts/version.js",
|
||||
"changelog": "node scripts/generate-changelog.js",
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
|
|
|
|||
327
scripts/generate-changelog.js
Normal file
327
scripts/generate-changelog.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate `CHANGELOG.md` from the project's GitHub Releases.
|
||||
*
|
||||
* The changelog only lists *stable* releases (`vX.Y.Z`); nightly and preview
|
||||
* pre-releases are intentionally omitted because they ship daily and would
|
||||
* drown out the signal. Each release's auto-generated "What's Changed" list is
|
||||
* re-grouped into Keep a Changelog sections (Added / Changed / Fixed / ...) by
|
||||
* the conventional-commit prefix every PR title uses in this repo.
|
||||
*
|
||||
* The file is fully derived from the GitHub Releases API, so it is safe to
|
||||
* regenerate at any time and should not be edited by hand.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-changelog.js # write ./CHANGELOG.md
|
||||
* node scripts/generate-changelog.js --dry-run # print to stdout instead
|
||||
* node scripts/generate-changelog.js --repo=owner/name --output=path.md
|
||||
*
|
||||
* Requires the GitHub CLI (`gh`) to be installed and authenticated.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readJson } from './lib/release-helpers.js';
|
||||
import { isMainModule, parseArgs } from './release-script-utils.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
/**
|
||||
* Keep a Changelog sections, in render order, each listing the
|
||||
* conventional-commit types that feed it. `Other` is the catch-all for unmapped
|
||||
* types. This is the single source of truth — `TYPE_TO_SECTION` and
|
||||
* `SECTION_ORDER` are derived from it so the two can never drift apart.
|
||||
*/
|
||||
const SECTIONS = [
|
||||
{ name: 'Added', types: ['feat'] },
|
||||
{ name: 'Changed', types: ['refactor', 'revert'] },
|
||||
{ name: 'Fixed', types: ['fix'] },
|
||||
{ name: 'Performance', types: ['perf'] },
|
||||
{ name: 'Documentation', types: ['docs'] },
|
||||
{ name: 'Other', types: [] },
|
||||
];
|
||||
|
||||
const TYPE_TO_SECTION = Object.fromEntries(
|
||||
SECTIONS.flatMap((section) =>
|
||||
section.types.map((type) => [type, section.name]),
|
||||
),
|
||||
);
|
||||
|
||||
const SECTION_ORDER = SECTIONS.map((section) => section.name);
|
||||
|
||||
/** Matches a stable `vX.Y.Z` tag (no `-preview` / `-nightly` suffix). */
|
||||
const STABLE_TAG_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
/**
|
||||
* Matches a GitHub "What's Changed" bullet, e.g.
|
||||
* * fix(core): do a thing by @octocat in https://github.com/o/r/pull/42
|
||||
* The title is captured greedily so a trailing " by @user in <pr-url>" binds to
|
||||
* the last occurrence, and "New Contributors" / "Full Changelog" lines (which
|
||||
* lack the " by @… in …/pull/N" tail) are skipped. The author group allows a
|
||||
* trailing `[bot]` so GitHub App authors (e.g. `@dependabot[bot]`) still match.
|
||||
*/
|
||||
const ENTRY_RE =
|
||||
/^[*-]\s+(.+)\s+by\s+@([A-Za-z0-9-]+(?:\[bot\])?)\s+in\s+(https?:\/\/\S+\/pull\/(\d+))\s*$/;
|
||||
|
||||
/**
|
||||
* Splits a conventional-commit subject into
|
||||
* `{ type, scope, description, breaking }`. The `!` breaking-change marker
|
||||
* (e.g. `feat(core)!: …`) is captured so it can be surfaced in the output.
|
||||
*/
|
||||
export function categorize(title) {
|
||||
const match = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/.exec(title.trim());
|
||||
if (!match) {
|
||||
return {
|
||||
type: null,
|
||||
scope: null,
|
||||
description: title.trim(),
|
||||
breaking: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
scope: match[2] || null,
|
||||
description: match[4],
|
||||
breaking: Boolean(match[3]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure version-bump commits the release bot makes (`chore(release): vX.Y.Z`)
|
||||
* are noise in a user-facing changelog, so they are dropped. Takes a parsed
|
||||
* `categorize()` result so callers that already parsed the title don't re-parse.
|
||||
*/
|
||||
export function isNoiseEntry({ type, scope }) {
|
||||
return type === 'chore' && scope === 'release';
|
||||
}
|
||||
|
||||
/** Parse the "What's Changed" bullets out of a release body. */
|
||||
export function parseReleaseEntries(body) {
|
||||
const entries = [];
|
||||
for (const line of (body || '').split(/\r?\n/)) {
|
||||
const match = ENTRY_RE.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
title: match[1].trim(),
|
||||
author: match[2],
|
||||
prUrl: match[3],
|
||||
prNumber: match[4],
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Render a single "What's Changed" entry as a changelog list item. */
|
||||
export function formatEntry(entry, cat = categorize(entry.title)) {
|
||||
const { type, scope, description, breaking } = cat;
|
||||
let text;
|
||||
if (TYPE_TO_SECTION[type]) {
|
||||
// Recognised type: drop the redundant leading keyword (the section heading
|
||||
// already conveys it) but keep the scope for context.
|
||||
text = scope ? `${scope}: ${description}` : description;
|
||||
} else {
|
||||
// Unknown or prefix-less title: keep it verbatim.
|
||||
text = entry.title;
|
||||
}
|
||||
if (breaking) {
|
||||
text = `**BREAKING** ${text}`;
|
||||
}
|
||||
return `- ${text} ([#${entry.prNumber}](${entry.prUrl}))`;
|
||||
}
|
||||
|
||||
/** Render one release as a Markdown block. */
|
||||
export function formatRelease(release) {
|
||||
const lines = [];
|
||||
const heading = release.htmlUrl
|
||||
? `## [${release.version}](${release.htmlUrl}) - ${release.date}`
|
||||
: `## [${release.version}] - ${release.date}`;
|
||||
lines.push(heading, '');
|
||||
|
||||
const buckets = new Map();
|
||||
for (const entry of release.entries) {
|
||||
const cat = categorize(entry.title);
|
||||
if (isNoiseEntry(cat)) {
|
||||
continue;
|
||||
}
|
||||
const section = TYPE_TO_SECTION[cat.type] || 'Other';
|
||||
if (!buckets.has(section)) {
|
||||
buckets.set(section, []);
|
||||
}
|
||||
buckets.get(section).push(formatEntry(entry, cat));
|
||||
}
|
||||
|
||||
let rendered = false;
|
||||
for (const section of SECTION_ORDER) {
|
||||
const items = buckets.get(section);
|
||||
if (!items || items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
rendered = true;
|
||||
lines.push(`### ${section}`, '', ...items, '');
|
||||
}
|
||||
|
||||
if (!rendered) {
|
||||
const link = release.htmlUrl
|
||||
? `[GitHub release](${release.htmlUrl})`
|
||||
: 'the GitHub release';
|
||||
lines.push(`_See ${link} for details._`, '');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const HEADER = `# Changelog
|
||||
|
||||
All notable changes to [Qwen Code](https://github.com/QwenLM/qwen-code) are
|
||||
documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and the project follows
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Only stable releases
|
||||
are listed; nightly and preview pre-releases are intentionally omitted.
|
||||
|
||||
> **This file is generated automatically** from
|
||||
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
|
||||
> by hand — run \`npm run changelog\` to regenerate.
|
||||
`;
|
||||
|
||||
/** Build the full CHANGELOG.md contents from an ordered list of releases. */
|
||||
export function buildChangelog(releases) {
|
||||
const blocks = releases.map((release) => formatRelease(release));
|
||||
const body = `${HEADER}\n${blocks.join('\n')}`;
|
||||
// Collapse any run of blank lines and guarantee a single trailing newline.
|
||||
return `${body.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '')}\n`;
|
||||
}
|
||||
|
||||
/** Convert a raw GitHub Releases API object into our release model. */
|
||||
export function toReleaseModel(raw) {
|
||||
const match = STABLE_TAG_RE.exec(raw.tag || '');
|
||||
return {
|
||||
tag: raw.tag,
|
||||
version: match ? `${match[1]}.${match[2]}.${match[3]}` : null,
|
||||
date: (raw.date || '').slice(0, 10),
|
||||
htmlUrl: raw.url || '',
|
||||
entries: parseReleaseEntries(raw.body),
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep only stable releases, newest first. */
|
||||
export function selectStableReleases(rawReleases) {
|
||||
return rawReleases
|
||||
.filter((raw) => !raw.prerelease && !raw.draft)
|
||||
.map(toReleaseModel)
|
||||
.filter((release) => release.version)
|
||||
.sort((a, b) => {
|
||||
// Newest first, comparing numeric semver components.
|
||||
const x = a.version.split('.').map(Number);
|
||||
const y = b.version.split('.').map(Number);
|
||||
return y[0] - x[0] || y[1] - x[1] || y[2] - x[2];
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch every release (paginated) as newline-delimited JSON via the gh CLI. */
|
||||
function fetchReleasesJsonl(repo) {
|
||||
// Validate before shelling out, and pass args via execFileSync (no shell) so
|
||||
// a `repo` value can never be interpreted as a shell command.
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
||||
throw new Error(`Invalid repository "${repo}"; expected "owner/name".`);
|
||||
}
|
||||
return execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
`repos/${repo}/releases?per_page=100`,
|
||||
'--paginate',
|
||||
'--jq',
|
||||
'.[] | {tag: .tag_name, date: .published_at, prerelease: .prerelease, draft: .draft, url: .html_url, body: .body}',
|
||||
],
|
||||
{ encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse newline-delimited JSON (one release object per line). */
|
||||
export function parseJsonl(jsonl) {
|
||||
return jsonl
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
/** Resolve the default `owner/repo` from the environment or package.json. */
|
||||
function getDefaultRepo() {
|
||||
if (process.env.GITHUB_REPOSITORY) {
|
||||
return process.env.GITHUB_REPOSITORY;
|
||||
}
|
||||
const url = readJson(path.join(REPO_ROOT, 'package.json'))?.repository?.url;
|
||||
const match = /github\.com[/:]([^/]+\/[^/.]+)/.exec(url || '');
|
||||
return match ? match[1] : 'QwenLM/qwen-code';
|
||||
}
|
||||
|
||||
const HELP = `Generate CHANGELOG.md from GitHub Releases.
|
||||
|
||||
Usage:
|
||||
node scripts/generate-changelog.js [options]
|
||||
|
||||
Options:
|
||||
--repo=<owner/name> Source repository (default: $GITHUB_REPOSITORY or package.json).
|
||||
--output=<path> Output file (default: ./CHANGELOG.md).
|
||||
--dry-run Print to stdout instead of writing the file.
|
||||
-h, --help Show this help.
|
||||
`;
|
||||
|
||||
function main() {
|
||||
// parseArgs rejects unknown options (so a `--dryrun` typo errors instead of
|
||||
// silently overwriting) and handles `-h`/`--help` natively.
|
||||
const args = parseArgs(process.argv.slice(2), {
|
||||
'--repo': { key: 'repo', type: 'value' },
|
||||
'--output': { key: 'output', type: 'value' },
|
||||
'--dry-run': { key: 'dry-run', type: 'flag' },
|
||||
});
|
||||
if (args.help) {
|
||||
process.stdout.write(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = args.repo || getDefaultRepo();
|
||||
const output = args.output || path.join(REPO_ROOT, 'CHANGELOG.md');
|
||||
|
||||
const rawReleases = parseJsonl(fetchReleasesJsonl(repo));
|
||||
const releases = selectStableReleases(rawReleases);
|
||||
if (releases.length === 0) {
|
||||
// A populated repo returning zero stable releases means the fetch failed
|
||||
// (rate limit, auth expiry, transient 5xx). Refuse to overwrite a good
|
||||
// CHANGELOG with an empty stub; the next release run regenerates it.
|
||||
console.error(
|
||||
`ERROR: no stable releases found for ${repo}; refusing to overwrite ${path.basename(output)}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const changelog = buildChangelog(releases);
|
||||
|
||||
if (args['dry-run']) {
|
||||
process.stdout.write(changelog);
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(output, changelog);
|
||||
console.error(
|
||||
`Wrote ${releases.length} stable releases to ${path.relative(process.cwd(), output)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
282
scripts/tests/generate-changelog.test.js
Normal file
282
scripts/tests/generate-changelog.test.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* @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,}/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue