mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-24 16:16:13 +00:00
* feat(release): user-facing bilingual digest for release notes Stable release notes read as a type-bucketed PR list, which users find hard to scan. The finalize step now asks the model to group changes into user-facing themes with short intros, mirrors highlights and themes into a Chinese digest, attaches screenshots found in merged PR bodies (host allowlist, per-release cap), and collapses the full PR list into an appendix with normalized titles. Every model failure path keeps today's v1 output byte-for-byte, and CHANGELOG.md accepts the new v2 marker. * fix(release): tighten v2 digest fallbacks and changelog skeleton (#9216) Address review round 1 findings: - usedAi only counts themes that carry content, so a release whose digest has zero model text is no longer reported as AI-generated - hasChinese is derived from what the Chinese block actually renders, not raw model output, so zh-only-on-breaking releases no longer emit an empty or English-only section - a PR repeated inside one theme is deduped instead of discarding the whole themes digest with a misleading cross-theme error - fallback titles in the v2 digest are normalized like the appendix, killing the mixed-style look in the degradation case - normalizeAppendixTitle strips only the conventional types the changelog's formatEntry strips, keeping ci/test/security prefixes - the changelog unwraps the v2 appendix at the same sibling rank as v1's Complete Change List instead of nesting it under the previous section - drop a dead summaries max_tokens scaling term and a verbatim copy of renderChangeLine's attribution rendering * fix(release): close digest image breakout and tighten fallback signals (#9216) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(release): drop camo proxy and harden digest text validation (#9216) * fix(release): neutralize markdown breakouts in digest text and images (#9216) * fix(release): close classification, image-URL, and text-validation bypasses (#9216) --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2395 lines
74 KiB
JavaScript
2395 lines
74 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
appendFileSync,
|
|
chmodSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
appendDegradedStepSummary,
|
|
buildPullRequestQuery,
|
|
classifyChange,
|
|
createOpenAiCompleter,
|
|
enrichEntries,
|
|
escapeWorkflowCommand,
|
|
extractImages,
|
|
generateAiContent,
|
|
generateReleaseNotes,
|
|
isAllowedImageUrl,
|
|
normalizeAppendixTitle,
|
|
parseGeneratedEntries,
|
|
renderReleaseNotes,
|
|
renderReleaseNotesV2,
|
|
tryAppendDegradedStepSummary,
|
|
} from '../generate-release-notes.js';
|
|
|
|
const PR = (number) => `https://github.com/QwenLM/qwen-code/pull/${number}`;
|
|
|
|
const entry = (number, title, labels = []) => ({
|
|
number,
|
|
title,
|
|
url: PR(number),
|
|
author: 'alice',
|
|
labels,
|
|
body: '',
|
|
});
|
|
|
|
describe('parseGeneratedEntries', () => {
|
|
it('extracts the authoritative PR list from GitHub generated notes', () => {
|
|
const body = [
|
|
"## What's Changed",
|
|
`* feat(cli): add session search by @alice in ${PR(12)}`,
|
|
`* fix(core): preserve tool results by @bob in ${PR(8)}`,
|
|
`* fix(ci): retry publishing by @carol with @Copilot in ${PR(6574)}`,
|
|
'',
|
|
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1...v2',
|
|
].join('\n');
|
|
|
|
expect(parseGeneratedEntries(body)).toEqual([
|
|
{
|
|
number: 12,
|
|
title: 'feat(cli): add session search',
|
|
url: PR(12),
|
|
author: 'alice',
|
|
},
|
|
{
|
|
number: 8,
|
|
title: 'fix(core): preserve tool results',
|
|
url: PR(8),
|
|
author: 'bob',
|
|
},
|
|
{
|
|
number: 6574,
|
|
title: 'fix(ci): retry publishing',
|
|
url: PR(6574),
|
|
author: 'carol',
|
|
coAuthors: ['Copilot'],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects a partially parsed GitHub PR list', () => {
|
|
const body = [
|
|
`* feat(cli): parsed by @alice in ${PR(1)}`,
|
|
`* fix(core): changed format by @bob and @carol in ${PR(2)}`,
|
|
].join('\n');
|
|
|
|
expect(() => parseGeneratedEntries(body)).toThrow(
|
|
/Could not parse every pull request entry/,
|
|
);
|
|
});
|
|
|
|
it('does not drop a PR bullet when GitHub omits the author phrase', () => {
|
|
const body = [
|
|
"## What's Changed",
|
|
`* feat(cli): parsed by @alice in ${PR(1)}`,
|
|
`* fix(core): changed format in ${PR(2)}`,
|
|
'',
|
|
'## New Contributors',
|
|
`* @newbie made their first contribution in ${PR(2)}`,
|
|
].join('\n');
|
|
|
|
expect(parseGeneratedEntries(body)).toEqual([
|
|
{
|
|
number: 1,
|
|
title: 'feat(cli): parsed',
|
|
url: PR(1),
|
|
author: 'alice',
|
|
},
|
|
{
|
|
number: 2,
|
|
title: 'fix(core): changed format',
|
|
url: PR(2),
|
|
author: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('classifyChange', () => {
|
|
it.each([
|
|
['feat(cli): add x', [], 'Features'],
|
|
['fix(core): repair x', [], 'Bug Fixes'],
|
|
['perf: speed up x', [], 'Performance'],
|
|
['docs: explain x', [], 'Documentation'],
|
|
['test(core): cover x', [], 'Internal Changes'],
|
|
// Object.prototype members must fall back like any unknown type instead
|
|
// of resolving to their inherited truthy value.
|
|
['constructor: rebuild session flow', [], 'Internal Changes'],
|
|
['__proto__: rebuild session flow', [], 'Internal Changes'],
|
|
])('classifies %s deterministically', (title, labels, expected) => {
|
|
expect(classifyChange(entry(1, title, labels))).toBe(expected);
|
|
});
|
|
|
|
it('lets an explicit breaking-change label override the title category', () => {
|
|
expect(
|
|
classifyChange(
|
|
entry(1, 'refactor(core): replace x', ['breaking-change']),
|
|
),
|
|
).toBe('Breaking Changes');
|
|
});
|
|
|
|
it.each([
|
|
['type/feature-request', 'Features'],
|
|
['type/bug', 'Bug Fixes'],
|
|
['category/performance', 'Performance'],
|
|
['type/documentation', 'Documentation'],
|
|
['scope/documentation', 'Documentation'],
|
|
])('uses an explicit %s label for prefixless titles', (label, expected) => {
|
|
expect(classifyChange(entry(1, 'A clearer change title', [label]))).toBe(
|
|
expected,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renderReleaseNotes', () => {
|
|
it('renders highlights and every PR exactly once in the complete list', () => {
|
|
const entries = [
|
|
{
|
|
...entry(1, 'feat(cli): add session search'),
|
|
coAuthors: ['Copilot'],
|
|
},
|
|
entry(2, 'fix(core): preserve tool results'),
|
|
entry(3, 'docs: explain session search'),
|
|
entry(4, 'refactor(core): remove legacy path', ['breaking-change']),
|
|
];
|
|
const summaries = new Map([
|
|
[1, 'Adds session search to the CLI.'],
|
|
[2, 'Preserves tool results when history is repaired.'],
|
|
[3, 'Documents session search.'],
|
|
[4, 'Removes a legacy compatibility path.'],
|
|
]);
|
|
|
|
const markdown = renderReleaseNotes({
|
|
entries,
|
|
summaries,
|
|
highlights: [
|
|
{
|
|
text: 'Session workflows are easier to find and recover.',
|
|
prs: [1, 2],
|
|
},
|
|
],
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(markdown).toContain('<!-- qwen-release-notes:v1 -->');
|
|
expect(markdown).toContain('## Highlights');
|
|
expect(markdown).toContain(
|
|
'Session workflows are easier to find and recover. ([#1]',
|
|
);
|
|
expect(markdown).toContain('## Complete Change List');
|
|
expect(markdown).toContain('### Features');
|
|
expect(markdown).toContain('### Bug Fixes');
|
|
expect(markdown).toContain('### Documentation');
|
|
expect(markdown).toContain(
|
|
`Adds session search to the CLI. ([#1](${PR(1)})) by @alice with @Copilot`,
|
|
);
|
|
for (const number of [1, 2, 3, 4]) {
|
|
expect(markdown.match(new RegExp(`\\[#${number}\\]`, 'g'))).toHaveLength(
|
|
number < 3 ? 2 : 1,
|
|
);
|
|
}
|
|
expect(markdown).toContain(
|
|
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1.0.0...v1.1.0',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isAllowedImageUrl', () => {
|
|
it.each([
|
|
'https://github.com/user-attachments/assets/abc-123',
|
|
'https://user-images.githubusercontent.com/1/x.png',
|
|
'https://private-user-images.githubusercontent.com/1/x.png',
|
|
'https://raw.githubusercontent.com/QwenLM/qwen-code/0123456789abcdef0123456789abcdef01234567/docs/x.png',
|
|
])('accepts %s', (url) => {
|
|
expect(isAllowedImageUrl(url)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
// camo signs arbitrary external URLs with a deployment-wide key, so it
|
|
// would re-admit every host the allowlist exists to exclude.
|
|
'https://camo.githubusercontent.com/dead/beef',
|
|
// Branch refs are mutable: the repo owner can swap the image in an
|
|
// already-published release.
|
|
'https://raw.githubusercontent.com/QwenLM/qwen-code/main/docs/x.png',
|
|
// GitHub's fetchers normalize before serving: empty path segments and
|
|
// %2F shift the validated position, dot segments resolve away, and
|
|
// CommonMark strips backslash escapes at render time.
|
|
'https://raw.githubusercontent.com/attacker//0123456789abcdef0123456789abcdef01234567/main/payload.png',
|
|
'https://raw.githubusercontent.com/attacker/repo%2Fsub/0123456789abcdef0123456789abcdef01234567/main/payload.png',
|
|
'https://raw.githubusercontent.com/QwenLM/qwen-code/0123456789abcdef0123456789abcdef01234567/../../other/repo/main/payload.png',
|
|
'https://raw.githubusercontent.com/QwenLM/qwen-code/%2e%2e/main/payload.png',
|
|
'https://github.com/user-attachments/../../attacker/repo/raw/main/payload.png',
|
|
'https://github.com/user-attachments/assets/..\\..\\attacker/payload.png',
|
|
'http://github.com/user-attachments/assets/abc',
|
|
'https://evil.example.com/github.com/user-attachments/assets/abc',
|
|
'https://evil.example.com/x.png',
|
|
'data:image/png;base64,AAAA',
|
|
'javascript:alert(1)',
|
|
])('rejects %s', (url) => {
|
|
expect(isAllowedImageUrl(url)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extractImages', () => {
|
|
const ATTACHMENT = 'https://github.com/user-attachments/assets/abc-123';
|
|
const ATTACHMENT_2 = 'https://github.com/user-attachments/assets/def-456';
|
|
|
|
it('collects markdown images, img tags, and bare image URLs in order', () => {
|
|
const body = [
|
|
'### Evidence (Before & After)',
|
|
``,
|
|
'<img src="https://user-images.githubusercontent.com/9/shot.png" width="400">',
|
|
`Bare link: https://raw.githubusercontent.com/QwenLM/qwen-code/0123456789abcdef0123456789abcdef01234567/docs/after.png`,
|
|
].join('\n');
|
|
|
|
expect(extractImages(body, { maxPerEntry: 3 })).toEqual([
|
|
{ url: ATTACHMENT, alt: 'Before' },
|
|
{
|
|
url: 'https://user-images.githubusercontent.com/9/shot.png',
|
|
alt: '',
|
|
},
|
|
{
|
|
url: 'https://raw.githubusercontent.com/QwenLM/qwen-code/0123456789abcdef0123456789abcdef01234567/docs/after.png',
|
|
alt: '',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('applies the per-entry cap in body order across syntaxes', () => {
|
|
const body = [
|
|
'<img src="https://user-images.githubusercontent.com/9/before.png" width="400">',
|
|
``,
|
|
``,
|
|
].join('\n');
|
|
|
|
expect(extractImages(body)).toEqual([
|
|
{
|
|
url: 'https://user-images.githubusercontent.com/9/before.png',
|
|
alt: '',
|
|
},
|
|
{ url: ATTACHMENT, alt: 'After 1' },
|
|
]);
|
|
});
|
|
|
|
it('drops images hosted outside the allowlist', () => {
|
|
const body =
|
|
'\n' + `<img src="${ATTACHMENT}">`;
|
|
|
|
expect(extractImages(body)).toEqual([{ url: ATTACHMENT, alt: '' }]);
|
|
});
|
|
|
|
it('drops img src values that would break out of the image syntax', () => {
|
|
const breakout = `<img src="${ATTACHMENT})">`;
|
|
const spaced = `<img src="${ATTACHMENT} with space.png">`;
|
|
|
|
expect(extractImages(breakout)).toEqual([]);
|
|
expect(extractImages(spaced)).toEqual([]);
|
|
});
|
|
|
|
it('drops markdown image URLs carrying Markdown-active characters', () => {
|
|
const unbalanced = ``;
|
|
const backticked = '';
|
|
|
|
expect(extractImages(unbalanced)).toEqual([]);
|
|
expect(extractImages(backticked)).toEqual([]);
|
|
});
|
|
|
|
it('drops bare image URLs whose extension is split by a backtick', () => {
|
|
expect(
|
|
extractImages('see https://github.com/user-attachments/abc`.png'),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('captures the src attribute, not a data-src stand-in', () => {
|
|
const real = 'https://user-images.githubusercontent.com/9/shot.png';
|
|
for (const tag of [
|
|
`<img src="${real}" data-src="${ATTACHMENT_2}">`,
|
|
`<img data-src="${ATTACHMENT_2}" src="${real}">`,
|
|
]) {
|
|
expect(extractImages(tag)).toEqual([{ url: real, alt: '' }]);
|
|
}
|
|
expect(extractImages(`<img data-src="${ATTACHMENT_2}">`)).toEqual([]);
|
|
});
|
|
|
|
it('neutralizes alt text that would break the interpolated image', () => {
|
|
expect(extractImages(``)).toEqual([
|
|
{ url: ATTACHMENT, alt: 'before' },
|
|
]);
|
|
expect(extractImages(``)).toEqual([
|
|
{ url: ATTACHMENT, alt: 'ab' },
|
|
]);
|
|
// Stripping the bracket exposes a trailing backslash that must go too.
|
|
expect(extractImages(``)).toEqual([
|
|
{ url: ATTACHMENT, alt: 'a' },
|
|
]);
|
|
});
|
|
|
|
it('does not treat ordinary links as images', () => {
|
|
const body =
|
|
'[design doc](https://raw.githubusercontent.com/QwenLM/qwen-code/main/docs/design.md)\n' +
|
|
'see https://example.com/page.html';
|
|
|
|
expect(extractImages(body)).toEqual([]);
|
|
});
|
|
|
|
it('caps the images per entry and dedupes repeats', () => {
|
|
const body = `   `;
|
|
|
|
expect(extractImages(body)).toEqual([
|
|
{ url: ATTACHMENT, alt: 'a' },
|
|
{ url: ATTACHMENT_2, alt: 'b' },
|
|
]);
|
|
});
|
|
|
|
it('normalizes multiline alt text to one line', () => {
|
|
const body = ``;
|
|
|
|
expect(extractImages(body)).toEqual([
|
|
{ url: ATTACHMENT, alt: 'before after' },
|
|
]);
|
|
});
|
|
|
|
it('returns nothing for an empty body', () => {
|
|
expect(extractImages('')).toEqual([]);
|
|
expect(extractImages(null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeAppendixTitle', () => {
|
|
it.each([
|
|
[
|
|
'feat(web-shell): improve compact tool activity',
|
|
'web-shell: improve compact tool activity',
|
|
],
|
|
[
|
|
'fix: keep workspace picker suggestions closed',
|
|
'keep workspace picker suggestions closed',
|
|
],
|
|
['feat(core)!: drop legacy flag', 'core: drop legacy flag'],
|
|
[
|
|
'refactor(core): rework session storage',
|
|
'refactor(core): rework session storage',
|
|
],
|
|
['docs: explain session search', 'explain session search'],
|
|
[
|
|
'fix: see [docs](https://attacker.example/q)',
|
|
'see docs(https://attacker.example/q)',
|
|
],
|
|
['Update README', 'Update README'],
|
|
// Types that classifyChange routes to Internal Changes keep their
|
|
// prefix: the Internal Changes heading alone does not name them.
|
|
[
|
|
'revert: fix crash when opening settings',
|
|
'revert: fix crash when opening settings',
|
|
],
|
|
['ci: bump action cache', 'ci: bump action cache'],
|
|
['security: patch CVE-2026-1234', 'security: patch CVE-2026-1234'],
|
|
['test: cover retry paths', 'test: cover retry paths'],
|
|
['chore(deps): bump @google/genai', 'chore(deps): bump @google/genai'],
|
|
])('normalizes %j to %j', (title, expected) => {
|
|
expect(normalizeAppendixTitle(title)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('renderReleaseNotesV2', () => {
|
|
const entries = [
|
|
entry(1, 'feat(web-shell): upload files'),
|
|
entry(2, 'fix(desktop): icon safe area'),
|
|
entry(3, 'chore(ci): tidy runners', ['scope/ci-cd']),
|
|
entry(4, 'feat(api)!: drop v1 endpoint', ['breaking-change']),
|
|
];
|
|
const summaries = new Map([
|
|
[1, 'Upload workspace files from the composer.'],
|
|
[2, 'Fixes the macOS icon safe area.'],
|
|
[3, 'Tidies CI runner setup.'],
|
|
[4, 'Removes the legacy v1 API endpoint.'],
|
|
]);
|
|
const summariesZh = new Map([
|
|
[1, '支持从输入框上传工作区文件。'],
|
|
[2, '修复 macOS 图标安全区。'],
|
|
[3, '整理 CI runner 配置。'],
|
|
[4, '移除旧版 v1 API 端点。'],
|
|
]);
|
|
const themes = [
|
|
{
|
|
title: 'Web Shell',
|
|
titleZh: 'Web Shell',
|
|
intro: 'Composer uploads landed this release.',
|
|
introZh: '本次发布支持了输入框上传。',
|
|
items: [1],
|
|
},
|
|
{
|
|
title: 'Desktop',
|
|
titleZh: '桌面应用',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [2],
|
|
},
|
|
];
|
|
const base = {
|
|
entries,
|
|
summaries,
|
|
summariesZh,
|
|
themes,
|
|
highlights: [
|
|
{
|
|
text: 'Upload files straight into the Web Shell composer.',
|
|
textZh: '直接向 Web Shell 输入框上传文件。',
|
|
prs: [1],
|
|
},
|
|
],
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
};
|
|
|
|
it('renders PR titles in the appendix without live links', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
entries: [entry(5, 'fix: see [docs](https://attacker.example/q)')],
|
|
summaries: new Map(),
|
|
themes: [],
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(markdown).not.toContain('[docs]');
|
|
expect(markdown).toContain(
|
|
`see docs(https://attacker.example/q) ([#5](${PR(5)}))`,
|
|
);
|
|
});
|
|
|
|
it('lists prototype-key titles instead of dropping them from the appendix', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
entries: [
|
|
entry(1, 'constructor: rebuild session flow'),
|
|
entry(2, 'feat(web-shell): upload files'),
|
|
],
|
|
summaries: new Map(),
|
|
summariesZh: new Map(),
|
|
highlights: [],
|
|
themes: [],
|
|
});
|
|
|
|
expect(markdown).toContain(
|
|
'<summary>Complete Change List (2 pull requests)</summary>',
|
|
);
|
|
// Once in the catch-all digest and once in the Internal Changes appendix;
|
|
// an inherited prototype member used to classify as a non-string that
|
|
// matched no section, silently dropping the appendix occurrence.
|
|
expect(markdown.match(/\[#1\]/g)).toHaveLength(2);
|
|
});
|
|
|
|
it('renders the bilingual digest, catch-all, and collapsed appendix', () => {
|
|
const markdown = renderReleaseNotesV2(base);
|
|
|
|
expect(markdown).toContain('<!-- qwen-release-notes:v2 -->');
|
|
expect(markdown).toContain('## Highlights');
|
|
expect(markdown).toContain(
|
|
`Upload files straight into the Web Shell composer. ([#1](${PR(1)}))`,
|
|
);
|
|
// Breaking changes are bilingual and stay out of the appendix.
|
|
expect(markdown).toContain(
|
|
`Removes the legacy v1 API endpoint. ([#4](${PR(4)})) by @alice`,
|
|
);
|
|
expect(markdown).toContain(' - 移除旧版 v1 API 端点。');
|
|
// Themes with intro, then the catch-all for the unassigned PR.
|
|
expect(markdown.indexOf('## Web Shell')).toBeGreaterThan(0);
|
|
expect(markdown).toContain('Composer uploads landed this release.');
|
|
expect(markdown).toContain('## Desktop');
|
|
expect(markdown).toContain('## Other Changes');
|
|
expect(markdown).toContain(`Tidies CI runner setup. ([#3](${PR(3)}))`);
|
|
// Chinese block mirrors highlights and themes.
|
|
expect(markdown).toContain('---\n\n## 中文摘要');
|
|
expect(markdown).toContain('### 亮点');
|
|
expect(markdown).toContain(
|
|
`直接向 Web Shell 输入框上传文件。 ([#1](${PR(1)}))`,
|
|
);
|
|
expect(markdown).toContain('### Web Shell');
|
|
expect(markdown).toContain('本次发布支持了输入框上传。');
|
|
expect(markdown).toContain('### 桌面应用');
|
|
expect(markdown).toContain('### 其他变更');
|
|
// Appendix: collapsed, normalized titles, authors kept, no breaking PR.
|
|
expect(markdown).toContain('<details>');
|
|
expect(markdown).toContain(
|
|
'<summary>Complete Change List (3 pull requests)</summary>',
|
|
);
|
|
expect(markdown).toContain(
|
|
`web-shell: upload files ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
expect(markdown).toContain('### Internal Changes');
|
|
expect(markdown).not.toContain('v1 endpoint ([#4]');
|
|
expect(markdown).toContain('</details>');
|
|
expect(markdown).toContain(
|
|
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1.0.0...v1.1.0',
|
|
);
|
|
});
|
|
|
|
it('attaches entry screenshots under digest items only', () => {
|
|
const shot = 'https://github.com/user-attachments/assets/abc-123';
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
images: new Map([[2, [{ url: shot, alt: 'Icon preview' }]]]),
|
|
});
|
|
|
|
const digestIndex = markdown.indexOf(
|
|
`Fixes the macOS icon safe area. ([#2](${PR(2)}))`,
|
|
);
|
|
const imageIndex = markdown.indexOf(``);
|
|
expect(imageIndex).toBeGreaterThan(digestIndex);
|
|
expect(markdown.match(new RegExp(`!\\[Icon preview\\]`, 'g'))).toHaveLength(
|
|
1,
|
|
);
|
|
// The Chinese digest and the appendix carry no images.
|
|
expect(markdown.indexOf('## 中文摘要')).toBeLessThan(
|
|
markdown.indexOf('</details>'),
|
|
);
|
|
expect(imageIndex).toBeLessThan(markdown.indexOf('---'));
|
|
});
|
|
|
|
it('caps the total number of rendered images per release', () => {
|
|
const images = new Map();
|
|
for (const number of [1, 2, 3, 4, 5]) {
|
|
images.set(number, [
|
|
{
|
|
url: `https://github.com/user-attachments/assets/a${number}`,
|
|
alt: '',
|
|
},
|
|
{
|
|
url: `https://github.com/user-attachments/assets/b${number}`,
|
|
alt: '',
|
|
},
|
|
]);
|
|
}
|
|
const wideTheme = {
|
|
title: 'Everything',
|
|
titleZh: '全部',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 2, 3, 4, 5],
|
|
};
|
|
const manyEntries = [1, 2, 3, 4, 5].map((number) =>
|
|
entry(number, `feat: change ${number}`),
|
|
);
|
|
const manySummaries = new Map(
|
|
[1, 2, 3, 4, 5].map((number) => [number, `Change ${number}.`]),
|
|
);
|
|
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
entries: manyEntries,
|
|
summaries: manySummaries,
|
|
summariesZh: new Map(),
|
|
themes: [wideTheme],
|
|
highlights: [],
|
|
images,
|
|
});
|
|
|
|
expect(markdown.match(/^ {2}!\[/gm)).toHaveLength(8);
|
|
// The degraded-release shape keeps its user-visible placeholders.
|
|
expect(markdown).toContain('_See the complete change list below._');
|
|
expect(markdown).toContain('No known breaking changes.');
|
|
expect(markdown).toContain(
|
|
'',
|
|
);
|
|
expect(markdown).not.toContain('assets/b5');
|
|
});
|
|
|
|
it('falls back to English text in the Chinese digest when a translation is missing', () => {
|
|
const partialZh = new Map([[1, '支持上传。']]);
|
|
|
|
const markdown = renderReleaseNotesV2({ ...base, summariesZh: partialZh });
|
|
|
|
expect(markdown).toContain(`支持上传。 ([#1](${PR(1)}))`);
|
|
// Entry 2 has no Chinese summary, so its English one is shown instead.
|
|
const zhSection = markdown.slice(markdown.indexOf('## 中文摘要'));
|
|
expect(zhSection).toContain(
|
|
`Fixes the macOS icon safe area. ([#2](${PR(2)}))`,
|
|
);
|
|
});
|
|
|
|
it('omits the Chinese block and bilingual sub-lines without translations', () => {
|
|
const englishOnlyThemes = themes.map((theme) => ({
|
|
...theme,
|
|
titleZh: theme.title,
|
|
introZh: '',
|
|
}));
|
|
const englishOnlyHighlights = base.highlights.map((highlight) => ({
|
|
...highlight,
|
|
textZh: highlight.text,
|
|
}));
|
|
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
summariesZh: new Map(),
|
|
themes: englishOnlyThemes,
|
|
highlights: englishOnlyHighlights,
|
|
});
|
|
|
|
expect(markdown).not.toContain('## 中文摘要');
|
|
expect(markdown).not.toContain('\n---\n');
|
|
expect(markdown).not.toContain(' - Removes the legacy v1 API endpoint.');
|
|
});
|
|
|
|
it('omits the Chinese block when Chinese summaries only echo English', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
summariesZh: new Map([[1, 'Upload workspace files from the composer.']]),
|
|
highlights: base.highlights.map((highlight) => ({
|
|
...highlight,
|
|
textZh: highlight.text,
|
|
})),
|
|
themes: themes.map((theme) => ({
|
|
...theme,
|
|
titleZh: theme.title,
|
|
introZh: '',
|
|
})),
|
|
});
|
|
|
|
expect(markdown).not.toContain('## 中文摘要');
|
|
});
|
|
|
|
it('omits the breaking sub-line when the Chinese summary echoes English', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
summariesZh: new Map([[4, 'Removes the legacy v1 API endpoint.']]),
|
|
highlights: [],
|
|
themes: [],
|
|
});
|
|
|
|
expect(markdown).toContain(
|
|
`Removes the legacy v1 API endpoint. ([#4](${PR(4)})) by @alice`,
|
|
);
|
|
expect(markdown).not.toContain(' - Removes the legacy v1 API endpoint.');
|
|
expect(markdown).not.toContain('## 中文摘要');
|
|
});
|
|
|
|
it('normalizes fallback titles in digest items like the appendix does', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
summaries: new Map(),
|
|
summariesZh: new Map(),
|
|
highlights: [],
|
|
themes: [
|
|
{
|
|
title: 'Web Shell',
|
|
titleZh: 'Web Shell',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(markdown).toContain(`web-shell: upload files ([#1](${PR(1)}))`);
|
|
expect(markdown).not.toContain('feat(web-shell): upload files');
|
|
expect(markdown).toContain('api: drop v1 endpoint');
|
|
});
|
|
|
|
it('renders breaking entries only in the Breaking Changes section', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
themes: [
|
|
{ title: 'API', titleZh: 'API', intro: '', introZh: '', items: [4, 1] },
|
|
{
|
|
title: 'Only breaking',
|
|
titleZh: '仅破坏性',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [4],
|
|
},
|
|
],
|
|
});
|
|
|
|
// Entry 4 appears exactly once: in the bilingual breaking section.
|
|
expect(markdown.match(/\[#4\]/g)).toHaveLength(1);
|
|
expect(markdown).toContain('## API');
|
|
// A theme whose only item is breaking collapses away entirely.
|
|
expect(markdown).not.toContain('Only breaking');
|
|
expect(markdown).not.toContain('仅破坏性');
|
|
});
|
|
|
|
it('omits the Chinese block when translations exist only on breaking entries', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
...base,
|
|
summariesZh: new Map([[4, '移除旧版 v1 API 端点。']]),
|
|
highlights: [],
|
|
themes: [],
|
|
});
|
|
|
|
// The breaking section keeps its bilingual sub-line...
|
|
expect(markdown).toContain(' - 移除旧版 v1 API 端点。');
|
|
// ...but nothing translated renders inside the block, so it stays omitted.
|
|
expect(markdown).not.toContain('## 中文摘要');
|
|
});
|
|
|
|
it('omits the Chinese block for an all-breaking release', () => {
|
|
const markdown = renderReleaseNotesV2({
|
|
entries: [entry(4, 'feat(api)!: drop v1 endpoint', ['breaking-change'])],
|
|
summaries: new Map([[4, 'Removes the legacy v1 API endpoint.']]),
|
|
summariesZh: new Map([[4, '移除旧版 v1 API 端点。']]),
|
|
highlights: [],
|
|
themes: [
|
|
{ title: 'API', titleZh: '接口', intro: '', introZh: '', items: [4] },
|
|
],
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(markdown).toContain(' - 移除旧版 v1 API 端点。');
|
|
expect(markdown).not.toContain('## 中文摘要');
|
|
});
|
|
|
|
it('counts only listed entries and pluralizes the appendix header', () => {
|
|
const single = renderReleaseNotesV2({
|
|
...base,
|
|
entries: [entries[0]],
|
|
themes: [],
|
|
});
|
|
|
|
expect(single).toContain(
|
|
'<summary>Complete Change List (1 pull request)</summary>',
|
|
);
|
|
|
|
// Entry 4 is breaking and is listed only under Breaking Changes.
|
|
expect(renderReleaseNotesV2(base)).toContain(
|
|
'<summary>Complete Change List (3 pull requests)</summary>',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('generateAiContent themes', () => {
|
|
const themeComplete = (themes) => async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: `Summary ${item.number}.`,
|
|
summaryZh: `摘要 ${item.number}。`,
|
|
})),
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes });
|
|
};
|
|
|
|
it('rejects themes that reference unknown or duplicated pull requests', async () => {
|
|
const entries = [entry(1, 'feat: one'), entry(2, 'feat: two')];
|
|
|
|
const unknown = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{ title: 'A', titleZh: '甲', intro: '', introZh: '', items: [1, 99] },
|
|
]),
|
|
);
|
|
expect(unknown.themes).toBeNull();
|
|
expect(unknown.warnings[0]).toMatch(
|
|
/Themes fallback:.*Unknown pull request in theme: 99/,
|
|
);
|
|
|
|
const duplicated = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{ title: 'A', titleZh: '甲', intro: '', introZh: '', items: [1] },
|
|
{ title: 'B', titleZh: '乙', intro: '', introZh: '', items: [1, 2] },
|
|
]),
|
|
);
|
|
expect(duplicated.themes).toBeNull();
|
|
expect(duplicated.warnings[0]).toMatch(
|
|
/Themes fallback:.*assigned to two themes: 1/,
|
|
);
|
|
});
|
|
|
|
it('dedupes a pull request repeated inside one theme', async () => {
|
|
const entries = [entry(1, 'feat: one'), entry(2, 'feat: two')];
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'A',
|
|
titleZh: '甲',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 1, 2],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes).toEqual([
|
|
{ title: 'A', titleZh: '甲', intro: '', introZh: '', items: [1, 2] },
|
|
]);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it('rejects more than eight themes', async () => {
|
|
const entries = [entry(1, 'feat: one')];
|
|
const themes = Array.from({ length: 9 }, (_, index) => ({
|
|
title: `Theme ${index}`,
|
|
titleZh: `主题 ${index}`,
|
|
intro: '',
|
|
introZh: '',
|
|
items: index === 0 ? [1] : [],
|
|
}));
|
|
|
|
const result = await generateAiContent(entries, themeComplete(themes));
|
|
|
|
expect(result.themes).toBeNull();
|
|
expect(result.warnings[0]).toMatch(/Themes fallback:.*too many themes/);
|
|
});
|
|
|
|
it('falls back to the English title when a Chinese theme title is invalid', async () => {
|
|
const entries = [entry(1, 'feat: one')];
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: 'See https://example.com for sessions.',
|
|
intro: 'Overview.',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes).toEqual([
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: 'Sessions',
|
|
intro: 'Overview.',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
'Chinese theme fallback for 1 theme field(s); English text is shown instead.',
|
|
]);
|
|
});
|
|
|
|
it('warns when an invalid English theme intro is dropped', async () => {
|
|
const entries = [entry(1, 'feat: one')];
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: '会话',
|
|
intro: 'See https://example.com for details.',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes).toEqual([
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: '会话',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
'Theme intro fallback for 1 theme field(s); the intro was dropped.',
|
|
]);
|
|
});
|
|
|
|
it('drops intros that would inject Markdown structure', async () => {
|
|
const entries = [1, 2, 3, 4, 5, 6].map((number) =>
|
|
entry(number, `feat: change ${number}`),
|
|
);
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'A',
|
|
titleZh: '甲',
|
|
intro: '# Known issues',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
{
|
|
title: 'B',
|
|
titleZh: '乙',
|
|
intro: '---',
|
|
introZh: '',
|
|
items: [2],
|
|
},
|
|
{
|
|
title: 'C',
|
|
titleZh: '丙',
|
|
// A reference definition arms shortcut links in sibling fields.
|
|
intro: '[click]: //evil.example/phish',
|
|
introZh: '',
|
|
items: [3],
|
|
},
|
|
{
|
|
title: 'D',
|
|
titleZh: '丁',
|
|
intro: '- - -',
|
|
introZh: '',
|
|
items: [4],
|
|
},
|
|
{
|
|
title: 'E',
|
|
titleZh: '戊',
|
|
intro: '- nested list item',
|
|
introZh: '',
|
|
items: [5],
|
|
},
|
|
{
|
|
title: 'F',
|
|
titleZh: '己',
|
|
intro: '1. numbered list item',
|
|
introZh: '',
|
|
items: [6],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes.map((theme) => theme.intro)).toEqual([
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
'Theme intro fallback for 6 theme field(s); the intro was dropped.',
|
|
]);
|
|
});
|
|
|
|
it('drops intros built on escapes, long list markers, and tilde fences', async () => {
|
|
const entries = [1, 2, 3].map((number) =>
|
|
entry(number, `feat: change ${number}`),
|
|
);
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'A',
|
|
titleZh: '甲',
|
|
// Escaped brackets hide "]" from the label checks.
|
|
intro: '[a\\]]: //evil.example/phish',
|
|
introZh: '',
|
|
items: [1],
|
|
},
|
|
{
|
|
title: 'B',
|
|
titleZh: '乙',
|
|
intro: '2025. This release ships improvements.',
|
|
introZh: '',
|
|
items: [2],
|
|
},
|
|
{
|
|
title: 'C',
|
|
titleZh: '丙',
|
|
intro: '~~~',
|
|
introZh: '',
|
|
items: [3],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes.map((theme) => theme.intro)).toEqual(['', '', '']);
|
|
expect(result.warnings).toEqual([
|
|
'Theme intro fallback for 3 theme field(s); the intro was dropped.',
|
|
]);
|
|
});
|
|
|
|
it('drops bare list markers and single-underscore emphasis intros', async () => {
|
|
const entries = [1, 2, 3, 4, 5].map((number) =>
|
|
entry(number, `feat: change ${number}`),
|
|
);
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{ title: 'A', titleZh: '甲', intro: '-', introZh: '', items: [1] },
|
|
{ title: 'B', titleZh: '乙', intro: '+', introZh: '', items: [2] },
|
|
{ title: 'C', titleZh: '丙', intro: '1.', introZh: '', items: [3] },
|
|
{ title: 'D', titleZh: '丁', intro: '2)', introZh: '', items: [4] },
|
|
{
|
|
title: 'E',
|
|
titleZh: '戊',
|
|
// CommonMark accepts list markers at end of line, and _em_ formats.
|
|
intro: '_Known issues_ coming soon',
|
|
introZh: '',
|
|
items: [5],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes.map((theme) => theme.intro)).toEqual([
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
'Theme intro fallback for 5 theme field(s); the intro was dropped.',
|
|
]);
|
|
});
|
|
|
|
it('does not count an invisible Chinese intro fallback', async () => {
|
|
const entries = [entry(1, 'feat: one')];
|
|
|
|
const result = await generateAiContent(
|
|
entries,
|
|
themeComplete([
|
|
{
|
|
title: 'A',
|
|
titleZh: '甲',
|
|
intro: '',
|
|
introZh: 'See https://example.com for details.',
|
|
items: [1],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(result.themes).toEqual([
|
|
{ title: 'A', titleZh: '甲', intro: '', introZh: '', items: [1] },
|
|
]);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('generateAiContent', () => {
|
|
it('summarizes bounded batches and then generates highlights', async () => {
|
|
const entries = [
|
|
entry(1, 'feat(cli): add session search'),
|
|
entry(2, 'fix(core): preserve tool results'),
|
|
entry(3, 'docs: explain session search'),
|
|
];
|
|
const calls = [];
|
|
const complete = async (request) => {
|
|
calls.push(request);
|
|
if (request.kind === 'summaries') {
|
|
return `\`\`\`json\n${JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: `User-facing summary for ${item.number}.`,
|
|
summaryZh: `面向用户的摘要 ${item.number}。`,
|
|
})),
|
|
})}\n\`\`\``;
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return `\`\`\`json\n${JSON.stringify({
|
|
highlights: [
|
|
{
|
|
text: 'Session workflows are clearer.',
|
|
textZh: '会话工作流更清晰。',
|
|
prs: [1, 2],
|
|
},
|
|
],
|
|
})}\n\`\`\``;
|
|
}
|
|
return `\`\`\`json\n${JSON.stringify({
|
|
themes: [
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: '会话',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 2, 3],
|
|
},
|
|
],
|
|
})}\n\`\`\``;
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete, { batchSize: 2 });
|
|
|
|
expect(calls.map((call) => call.kind)).toEqual([
|
|
'summaries',
|
|
'summaries',
|
|
'highlights',
|
|
'themes',
|
|
]);
|
|
expect(result.summaries.get(3)).toBe('User-facing summary for 3.');
|
|
expect(result.summariesZh.get(3)).toBe('面向用户的摘要 3。');
|
|
expect(result.highlights).toEqual([
|
|
{
|
|
text: 'Session workflows are clearer.',
|
|
textZh: '会话工作流更清晰。',
|
|
prs: [1, 2],
|
|
},
|
|
]);
|
|
expect(result.themes).toEqual([
|
|
{
|
|
title: 'Sessions',
|
|
titleZh: '会话',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 2, 3],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('sends only title, a bounded body excerpt, and category to the model', async () => {
|
|
const long = { ...entry(1, 'feat: long body'), body: 'x'.repeat(5000) };
|
|
const calls = [];
|
|
const complete = async (request) => {
|
|
calls.push(request);
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: 'Summary.',
|
|
})),
|
|
});
|
|
}
|
|
return JSON.stringify({ highlights: [] });
|
|
};
|
|
|
|
await generateAiContent([long], complete);
|
|
|
|
const [payload] = calls[0].entries;
|
|
expect(Object.keys(payload).sort()).toEqual([
|
|
'body',
|
|
'category',
|
|
'number',
|
|
'title',
|
|
]);
|
|
expect(payload.body).toHaveLength(700);
|
|
});
|
|
|
|
it('falls back to original titles for an invalid summary batch', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return '{not-json';
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toHaveLength(1);
|
|
});
|
|
|
|
it('falls back to original titles when the model omits a PR summary', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({ summaries: [{ pr: 1, summary: 'Only one.' }] });
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings[0]).toMatch(/missing pull request summaries/);
|
|
});
|
|
|
|
it('falls back only the summary whose text is unsafe', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'A safe summary.', summaryZh: '安全的摘要。' },
|
|
{
|
|
pr: 2,
|
|
summary: '@QwenLM/security should review this.',
|
|
summaryZh: '安全审查。',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'A safe summary.'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toEqual([
|
|
'Summary fallback for #2: Summary for pull request 2 must be plain text without links or HTML.',
|
|
]);
|
|
});
|
|
|
|
it('rejects GFM autolinks and encoded mentions from model text', async () => {
|
|
const entries = [
|
|
entry(1, 'feat: original one'),
|
|
entry(2, 'fix: original two'),
|
|
entry(3, 'docs: original three'),
|
|
];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{
|
|
pr: 1,
|
|
summary: 'Visit www.example.com for details.',
|
|
summaryZh: '摘要一。',
|
|
},
|
|
{
|
|
pr: 2,
|
|
summary: 'Contact security@example.com.',
|
|
summaryZh: '摘要二。',
|
|
},
|
|
{
|
|
pr: 3,
|
|
summary: 'Ping @octocat for details.',
|
|
summaryZh: '摘要三。',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original one'],
|
|
[2, 'fix: original two'],
|
|
[3, 'docs: original three'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toHaveLength(3);
|
|
});
|
|
|
|
it('rejects escaped-bracket links and formatting markers in summaries', async () => {
|
|
const entries = [
|
|
entry(1, 'feat: original one'),
|
|
entry(2, 'fix: original two'),
|
|
entry(3, 'docs: original three'),
|
|
];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{
|
|
pr: 1,
|
|
summary: 'Click [a\\]](//evil.example) now',
|
|
summaryZh: '摘要一。',
|
|
},
|
|
{ pr: 2, summary: 'See [a\\]] here', summaryZh: '摘要二。' },
|
|
{
|
|
pr: 3,
|
|
summary: 'Ship the *fast* path for ~~legacy~~ parsing',
|
|
summaryZh: '摘要三。',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original one'],
|
|
[2, 'fix: original two'],
|
|
[3, 'docs: original three'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toEqual([
|
|
'Summary fallback for #1: Summary for pull request 1 must be plain text without links or HTML.',
|
|
'Summary fallback for #2: Summary for pull request 2 must be plain text without links or HTML.',
|
|
'Summary fallback for #3: Summary for pull request 3 must be plain text without links or HTML.',
|
|
]);
|
|
});
|
|
|
|
it('drops invalid highlights without losing the complete list', async () => {
|
|
const entries = [entry(1, 'feat: original')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [{ pr: 1, summary: 'Readable.', summaryZh: '可读。' }],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({
|
|
highlights: [{ text: 'Invented.', textZh: '虚构。', prs: [99] }],
|
|
});
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries.get(1)).toBe('Readable.');
|
|
expect(result.highlights).toEqual([]);
|
|
expect(result.warnings).toHaveLength(1);
|
|
});
|
|
|
|
it('falls back to the English summary when a Chinese summary is missing', async () => {
|
|
const entries = [entry(1, 'feat: one'), entry(2, 'feat: two')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'First change.', summaryZh: '第一项变更。' },
|
|
{ pr: 2, summary: 'Second change.' },
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries.get(2)).toBe('Second change.');
|
|
expect(result.summariesZh.has(2)).toBe(false);
|
|
expect(result.warnings).toEqual([
|
|
'Chinese summary fallback for 1 pull request(s); the Chinese digest shows their English summaries.',
|
|
]);
|
|
});
|
|
|
|
it('falls back to the English highlight text when the translation is missing', async () => {
|
|
const entries = [entry(1, 'feat: one')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'First change.', summaryZh: '第一项变更。' },
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({
|
|
highlights: [{ text: 'First change.', prs: [1] }],
|
|
});
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.highlights).toEqual([
|
|
{ text: 'First change.', textZh: 'First change.', prs: [1] },
|
|
]);
|
|
expect(result.warnings).toEqual([
|
|
'Chinese highlight fallback for 1 highlight(s); English text is shown instead.',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('enrichEntries', () => {
|
|
it('keeps authoritative order and fills metadata returned by GitHub', () => {
|
|
const base = parseGeneratedEntries(
|
|
`* feat: a by @alice in ${PR(2)}\n* fix: b by @bob in ${PR(1)}`,
|
|
);
|
|
const enriched = enrichEntries(base, [
|
|
{
|
|
number: 1,
|
|
body: 'Why it matters.',
|
|
labels: [{ name: 'type/bug' }],
|
|
},
|
|
]);
|
|
|
|
expect(enriched.map((item) => item.number)).toEqual([2, 1]);
|
|
expect(enriched[0].body).toBe('');
|
|
expect(enriched[1].body).toBe('Why it matters.');
|
|
expect(enriched[1].labels).toEqual([{ name: 'type/bug' }]);
|
|
});
|
|
});
|
|
|
|
describe('buildPullRequestQuery', () => {
|
|
it('builds one aliased metadata lookup per authoritative PR number', () => {
|
|
const query = buildPullRequestQuery([12, 8]);
|
|
|
|
expect(query).toContain('pr0: pullRequest(number: 12)');
|
|
expect(query).toContain('pr1: pullRequest(number: 8)');
|
|
expect(query).toContain('labels(first: 20)');
|
|
expect(query).not.toContain('files(first: 40)');
|
|
expect(query).not.toContain('pullRequest(number: undefined)');
|
|
});
|
|
});
|
|
|
|
describe('createOpenAiCompleter', () => {
|
|
it('uses a tool-free JSON completion request', async () => {
|
|
const requests = [];
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
fetchImpl: async (url, init) => {
|
|
requests.push({ url, init });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"summaries":[]}' } }],
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
|
|
expect(requests[0].url).toBe('https://model.example/v1/chat/completions');
|
|
expect(requests[0].init.headers.Authorization).toBe('Bearer secret');
|
|
const body = JSON.parse(requests[0].init.body);
|
|
expect(body.model).toBe('qwen-test');
|
|
expect(body.response_format).toEqual({ type: 'json_object' });
|
|
expect(body.max_tokens).toBe(4096);
|
|
expect(body.tools).toBeUndefined();
|
|
expect(requests[0].init.signal).toBeDefined();
|
|
});
|
|
|
|
it('scales the themes token budget with the PR count and caps it', async () => {
|
|
const requests = [];
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
fetchImpl: async (url, init) => {
|
|
requests.push({ url, init });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"themes":[]}' } }],
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
const stubEntries = (count) =>
|
|
Array.from({ length: count }, (_, index) => ({ number: index + 1 }));
|
|
|
|
await complete({ kind: 'themes', entries: stubEntries(50) });
|
|
await complete({ kind: 'themes', entries: stubEntries(150) });
|
|
|
|
expect(JSON.parse(requests[0].init.body).max_tokens).toBe(5824);
|
|
expect(JSON.parse(requests[1].init.body).max_tokens).toBe(8192);
|
|
});
|
|
});
|
|
|
|
describe('generateReleaseNotes', () => {
|
|
it('returns GitHub notes unchanged when there are no PR entries', async () => {
|
|
const generatedBody =
|
|
'**Full Changelog**: https://example.com/compare/a...b';
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete: async () => {
|
|
throw new Error('must not be called');
|
|
},
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.0.1',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toBe(generatedBody);
|
|
expect(result.usedAi).toBe(false);
|
|
});
|
|
|
|
it('renders the complete fallback list and new contributor credits', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
'',
|
|
'## New Contributors',
|
|
`* @newbie made their first contribution in ${PR(1)}`,
|
|
].join('\n');
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete: null,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('### Features');
|
|
expect(result.markdown).toContain(
|
|
`feat(cli): add search ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
expect(result.markdown).toContain('## New Contributors');
|
|
expect(result.markdown).toContain(
|
|
`- @newbie made their first contribution in [#1](${PR(1)})`,
|
|
);
|
|
expect(result.usedAi).toBe(false);
|
|
expect(result.warnings).toEqual(['Model configuration is unavailable.']);
|
|
});
|
|
|
|
it('renders the v2 bilingual digest when the model supplies themes', async () => {
|
|
const shot = 'https://github.com/user-attachments/assets/abc-123';
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(web-shell): upload files by @alice in ${PR(1)}`,
|
|
`* fix(core): preserve tool results by @bob in ${PR(2)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: `Summary for ${item.number}.`,
|
|
summaryZh: `摘要 ${item.number}。`,
|
|
})),
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({
|
|
highlights: [{ text: 'Big news.', textZh: '重大消息。', prs: [1] }],
|
|
});
|
|
}
|
|
return JSON.stringify({
|
|
themes: [
|
|
{
|
|
title: 'Web Shell',
|
|
titleZh: 'Web Shell',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 2],
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [
|
|
{
|
|
number: 1,
|
|
body: `### Evidence\n`,
|
|
labels: { nodes: [{ name: 'type/feature' }] },
|
|
},
|
|
{ number: 2, body: '', labels: { nodes: [{ name: 'type/bug' }] } },
|
|
],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('<!-- qwen-release-notes:v2 -->');
|
|
expect(result.markdown).toContain('## Web Shell');
|
|
expect(result.markdown).toContain('## 中文摘要');
|
|
expect(result.markdown).toContain(``);
|
|
expect(result.markdown).toContain(
|
|
'<summary>Complete Change List (2 pull requests)</summary>',
|
|
);
|
|
expect(result.markdown).toContain(
|
|
`web-shell: upload files ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
expect(result.usedAi).toBe(true);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it('does not report AI output when no model text renders', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return '{not-json';
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('<!-- qwen-release-notes:v2 -->');
|
|
expect(result.usedAi).toBe(false);
|
|
expect(result.warnings[0]).toMatch(/Summary batch fallback/);
|
|
});
|
|
|
|
it('reports AI output when only the Chinese summaries are model-written', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{
|
|
pr: 1,
|
|
summary: 'See https://example.com for details.',
|
|
summaryZh: '新增搜索。',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('## 中文摘要');
|
|
expect(result.markdown).toContain(`新增搜索。 ([#1](${PR(1)}))`);
|
|
expect(result.usedAi).toBe(true);
|
|
});
|
|
|
|
it('does not count Chinese summaries that the v1 layout never renders', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{
|
|
pr: 1,
|
|
summary: 'See https://example.com for details.',
|
|
summaryZh: '新增搜索。',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return '{not-json';
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('<!-- qwen-release-notes:v1 -->');
|
|
expect(result.usedAi).toBe(false);
|
|
});
|
|
|
|
it('normalizes fallback titles in the v2 digest like the appendix', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(web-shell): upload files by @alice in ${PR(1)}`,
|
|
`* fix(core): preserve tool results by @bob in ${PR(2)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'Upload files.', summaryZh: '上传文件。' },
|
|
{
|
|
pr: 2,
|
|
summary: 'See https://example.com/design for details.',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({
|
|
themes: [
|
|
{
|
|
title: 'Work',
|
|
titleZh: '工作',
|
|
intro: '',
|
|
introZh: '',
|
|
items: [1, 2],
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain(
|
|
`core: preserve tool results ([#2](${PR(2)}))`,
|
|
);
|
|
expect(result.markdown).not.toContain('fix(core): preserve tool results');
|
|
});
|
|
|
|
it('falls back to the v1 layout when the themes call fails', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
].join('\n');
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'Adds search.', summaryZh: '新增搜索。' },
|
|
],
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({
|
|
highlights: [{ text: 'Search.', textZh: '搜索。', prs: [1] }],
|
|
});
|
|
}
|
|
return '{not-json';
|
|
};
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('<!-- qwen-release-notes:v1 -->');
|
|
expect(result.markdown).toContain('## Complete Change List');
|
|
expect(result.markdown).not.toContain('## 中文摘要');
|
|
expect(result.usedAi).toBe(true);
|
|
expect(result.warnings[0]).toMatch(/Themes fallback/);
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'runs the CLI path with fake gh data and writes fallback notes',
|
|
() => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'release-notes-cli-'));
|
|
try {
|
|
const gh = join(dir, 'gh');
|
|
const output = join(dir, 'notes.md');
|
|
const summaryPath = join(dir, 'summary.md');
|
|
writeFileSync(
|
|
gh,
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'const args = process.argv.slice(2);',
|
|
"if (args[0] === 'api' && args.includes('repos/QwenLM/qwen-code/releases/generate-notes')) {",
|
|
" process.stdout.write([\"## What's Changed\", '* feat: add cli path by @alice in https://github.com/QwenLM/qwen-code/pull/1'].join('\\n'));",
|
|
' process.exit(0);',
|
|
'}',
|
|
"if (args[0] === 'api' && args[1] === 'graphql') {",
|
|
" process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', labels: { nodes: [] } } } } }));",
|
|
' process.exit(0);',
|
|
'}',
|
|
'process.exit(1);',
|
|
].join('\n'),
|
|
);
|
|
chmodSync(gh, 0o755);
|
|
|
|
const cli = spawnSync(
|
|
process.execPath,
|
|
[
|
|
'scripts/generate-release-notes.js',
|
|
'--tag=v1.0.1',
|
|
'--previous-tag=v1.0.0',
|
|
`--output=${output}`,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
|
OPENAI_API_KEY: '',
|
|
OPENAI_BASE_URL: '',
|
|
OPENAI_MODEL: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(cli.status).toBe(0);
|
|
expect(cli.stderr).toContain(
|
|
'::warning::Model configuration is unavailable.',
|
|
);
|
|
expect(readFileSync(summaryPath, 'utf8')).toContain(
|
|
'Release notes: AI generation degraded',
|
|
);
|
|
const markdown = readFileSync(output, 'utf8');
|
|
expect(markdown).toContain('### Features');
|
|
expect(markdown).toContain(
|
|
`feat: add cli path ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('does not duplicate ERROR prefixes for argument failures', () => {
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
'scripts/generate-release-notes.js',
|
|
'--repo=bad repo',
|
|
'--tag=v1.0.1',
|
|
'--previous-tag=v1.0.0',
|
|
'--dry-run',
|
|
],
|
|
{ encoding: 'utf8', stdio: 'pipe' },
|
|
);
|
|
throw new Error('expected command to fail');
|
|
} catch (error) {
|
|
expect(error.stderr).toContain(
|
|
'ERROR: Invalid repository "bad repo"; expected "owner/name".',
|
|
);
|
|
expect(error.stderr).not.toContain('ERROR: ERROR:');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('createOpenAiCompleter retries', () => {
|
|
const okResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"summaries":[]}' } }],
|
|
}),
|
|
};
|
|
|
|
it('retries a 500 once and then succeeds', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1 ? { ok: false, status: 500 } : okResponse;
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).resolves.toBe(
|
|
'{"summaries":[]}',
|
|
);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('retries a timeout before giving up after maxRetries', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
maxRetries: 2,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
const error = new Error('timed out');
|
|
error.name = 'TimeoutError';
|
|
throw error;
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'timed out',
|
|
);
|
|
expect(calls).toBe(3);
|
|
});
|
|
|
|
it('does not retry a 400', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 400 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'HTTP 400',
|
|
);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('retries HTTP 429 (rate limiting)', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1 ? { ok: false, status: 429 } : okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('retries network errors without HTTP status', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error('fetch failed: ECONNRESET');
|
|
}
|
|
return okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('does not retry content-validation errors', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
// Returns 200 OK but empty content — triggers content-validation error
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '' } }] }),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'Model response did not contain message content.',
|
|
);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('preserves original error in deadline-expired message', async () => {
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
return { ok: false, status: 503 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 503/,
|
|
);
|
|
});
|
|
|
|
it('preserves the original error as the deadline error cause', async () => {
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
return { ok: false, status: 503 };
|
|
},
|
|
});
|
|
|
|
const error = await complete({ kind: 'summaries', entries: [] }).catch(
|
|
(err) => err,
|
|
);
|
|
expect(error.message).toMatch(/budget exhausted.*HTTP 503/);
|
|
expect(error.cause?.message).toBe('Model request failed with HTTP 503.');
|
|
});
|
|
|
|
it('preserves original error when the deadline expires after backoff', async () => {
|
|
let now = 0;
|
|
let calls = 0;
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const random = vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
const timeout = vi
|
|
.spyOn(globalThis, 'setTimeout')
|
|
.mockImplementation((callback) => {
|
|
now = 1001;
|
|
callback();
|
|
return 0;
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 1000,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 500 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 500/,
|
|
);
|
|
expect(calls).toBe(1);
|
|
clock.mockRestore();
|
|
random.mockRestore();
|
|
timeout.mockRestore();
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('logs a retry line when backing off', async () => {
|
|
let calls = 0;
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1
|
|
? new Response('\n::error::forged', { status: 200 })
|
|
: okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
const retryLine = errSpy.mock.calls
|
|
.map((args) => args[0])
|
|
.find((line) => String(line).startsWith('Model request retry '));
|
|
expect(retryLine).toBeDefined();
|
|
expect(retryLine).not.toContain('\n');
|
|
expect(retryLine).toContain('%0A::error::forged');
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('stops retrying before the shared time budget expires', async () => {
|
|
let calls = 0;
|
|
const timeout = vi.spyOn(globalThis, 'setTimeout');
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 500 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 500/,
|
|
);
|
|
expect(calls).toBe(1);
|
|
expect(timeout).not.toHaveBeenCalled();
|
|
timeout.mockRestore();
|
|
});
|
|
|
|
it('caps each request at the remaining shared time budget', async () => {
|
|
const timeout = vi.spyOn(AbortSignal, 'timeout');
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
timeoutMs: 10_000,
|
|
totalTimeoutMs: 1_000,
|
|
fetchImpl: async () => okResponse,
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(timeout.mock.calls[0][0]).toBeLessThanOrEqual(1_000);
|
|
timeout.mockRestore();
|
|
});
|
|
|
|
it('shares the time budget across calls', async () => {
|
|
let now = 0;
|
|
let calls = 0;
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
now = 51;
|
|
await expect(complete({ kind: 'highlights', entries: [] })).rejects.toThrow(
|
|
'Model generation time budget exhausted: unknown error',
|
|
);
|
|
expect(calls).toBe(1);
|
|
clock.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('generateAiContent circuit breaker', () => {
|
|
it('stops calling the model after consecutive batch failures', async () => {
|
|
const calls = [];
|
|
const failing = async (request) => {
|
|
calls.push(request.kind);
|
|
throw new Error('model down');
|
|
};
|
|
const entries = [
|
|
entry(1, 'one'),
|
|
entry(2, 'two'),
|
|
entry(3, 'three'),
|
|
entry(4, 'four'),
|
|
entry(5, 'five'),
|
|
];
|
|
|
|
const result = await generateAiContent(entries, failing, { batchSize: 1 });
|
|
|
|
// 3 batch attempts, then the breaker opens: no more batch calls and no
|
|
// highlights call at all.
|
|
expect(calls).toEqual(['summaries', 'summaries', 'summaries']);
|
|
expect([...result.summaries.values()]).toEqual([
|
|
'one',
|
|
'two',
|
|
'three',
|
|
'four',
|
|
'five',
|
|
]);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('stopped after 3 consecutive failures'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('Highlights fallback: skipped'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('Themes fallback: skipped'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('recovers without the breaker when a later batch succeeds', async () => {
|
|
const calls = [];
|
|
let summaryCalls = 0;
|
|
const flaky = async (request) => {
|
|
calls.push(request.kind);
|
|
if (request.kind === 'summaries') {
|
|
summaryCalls += 1;
|
|
if (summaryCalls !== 3) throw new Error('transient');
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((entry) => ({
|
|
pr: entry.number,
|
|
summary: `${entry.title} summary`,
|
|
summaryZh: `${entry.title} 摘要`,
|
|
})),
|
|
});
|
|
}
|
|
if (request.kind === 'highlights') {
|
|
return JSON.stringify({ highlights: [] });
|
|
}
|
|
return JSON.stringify({ themes: [] });
|
|
};
|
|
const entries = [1, 2, 3, 4, 5].map((number) =>
|
|
entry(number, String(number)),
|
|
);
|
|
|
|
const result = await generateAiContent(entries, flaky, { batchSize: 1 });
|
|
|
|
expect(calls).toEqual([
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'highlights',
|
|
'themes',
|
|
]);
|
|
expect(
|
|
result.warnings.some((warning) => warning.includes('stopped after')),
|
|
).toBe(false);
|
|
expect(result.summaries.get(3)).toBe('3 summary');
|
|
});
|
|
});
|
|
|
|
describe('appendDegradedStepSummary', () => {
|
|
it('appends a degraded note when warnings exist', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
// test-setup mocks appendFileSync, so assert on the call instead of the file.
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['Summary batch fallback: HTTP 500'] },
|
|
summaryPath,
|
|
);
|
|
|
|
expect(vi.mocked(appendFileSync)).toHaveBeenCalledTimes(1);
|
|
const [writtenPath, written] = vi.mocked(appendFileSync).mock.calls[0];
|
|
expect(writtenPath).toBe(summaryPath);
|
|
expect(written).toContain('AI generation degraded');
|
|
expect(written).toContain('Summary batch fallback: HTTP 500');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does nothing without warnings', async () => {
|
|
const fs = await import('node:fs');
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary({ usedAi: true, warnings: [] }, summaryPath);
|
|
expect(vi.mocked(appendFileSync)).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(summaryPath)).toBe(false);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does not propagate summary write failures (tryAppend)', () => {
|
|
vi.mocked(appendFileSync).mockImplementationOnce(() => {
|
|
throw new Error('ENOSPC');
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
expect(() =>
|
|
tryAppendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['Summary batch fallback: HTTP 500'] },
|
|
join(tmpdir(), 'summary.md'),
|
|
),
|
|
).not.toThrow();
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('failed to write the degraded step summary'),
|
|
);
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('escapeWorkflowCommand', () => {
|
|
it('percent-encodes newlines so model text cannot forge a runner command', () => {
|
|
const malicious = 'batch failed\n::error::forged annotation';
|
|
const escaped = escapeWorkflowCommand(malicious);
|
|
expect(escaped).not.toContain('\n');
|
|
expect(escaped).not.toContain('\r');
|
|
expect(escaped).toBe('batch failed%0A::error::forged annotation');
|
|
});
|
|
|
|
it('encodes percent signs so encoded sequences are not double-decoded', () => {
|
|
expect(escapeWorkflowCommand('100% done')).toBe('100%25 done');
|
|
});
|
|
|
|
it('encodes carriage returns', () => {
|
|
expect(escapeWorkflowCommand('a\rb')).toBe('a%0Db');
|
|
});
|
|
});
|
|
|
|
describe('appendDegradedStepSummary markdown hardening', () => {
|
|
it('renders warnings as escaped single-line code', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary(
|
|
{
|
|
usedAi: true,
|
|
warnings: ['line1\n ```tick``` <b>&'],
|
|
},
|
|
summaryPath,
|
|
);
|
|
expect(vi.mocked(appendFileSync)).toHaveBeenCalledTimes(1);
|
|
const [, written] = vi.mocked(appendFileSync).mock.calls[0];
|
|
expect(written).toContain(
|
|
'AI generation was partially degraded; see the warnings on this run.',
|
|
);
|
|
expect(written).toContain(
|
|
'- ```` line1  ```tick``` <b>& ````',
|
|
);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('escapes the failed-summary warning through tryAppend', () => {
|
|
vi.mocked(appendFileSync).mockImplementationOnce(() => {
|
|
throw new Error('ENOSPC');
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
tryAppendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['degraded'] },
|
|
join(tmpdir(), 'summary.md'),
|
|
);
|
|
const emitted = errSpy.mock.calls[0][0];
|
|
expect(emitted).toMatch(/^::warning::/);
|
|
expect(emitted).not.toContain('\n');
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|