Skip to content

Commit cd3f03d

Browse files
authored
Bugfix: Fix patch version release aggregation
Fix patch version release aggregation
2 parents 8bdd9a9 + ae27012 commit cd3f03d

2 files changed

Lines changed: 229 additions & 28 deletions

File tree

scripts/build-gb-releases.ts

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Usage:
66
* npm run data-sync:gb-releases # Parse all releases
77
* npm run data-sync:gb-releases -- --version 20.0 # Parse specific version
8+
* npm run data-sync:gb-releases -- --version 20.0.1 # Refresh the 20.0 aggregate
89
* npm run data-sync:gb-releases -- --from 19.0 --to 20.0 # Parse range
910
*
1011
* @module scripts/build-gb-releases
@@ -13,11 +14,12 @@
1314
import { parseArgs } from 'node:util';
1415
import { existsSync, mkdirSync } from 'node:fs';
1516
import { dirname, basename } from 'node:path';
17+
import { pathToFileURL } from 'node:url';
1618
import { writeJsonIfChanged } from './utils/file-utils.js';
1719
import {
1820
fetchAllReleases,
19-
fetchReleaseByTag,
2021
filterReleasesByVersion,
22+
isReleaseCandidate,
2123
} from './utils/github-api.js';
2224
import { parseRelease } from './utils/changelog-parser.js';
2325
import {
@@ -26,7 +28,7 @@ import {
2628
getMinorVersion,
2729
aggregatePatchReleases,
2830
} from './utils/release-utils.js';
29-
import type { ParseArgs } from './utils/types.js';
31+
import type { GitHubRelease, ParseArgs } from './utils/types.js';
3032
import type { Release } from './types.js';
3133

3234
const PARSER_VERSION = '1.0.0';
@@ -86,11 +88,60 @@ function loadExistingReleases(outputPath: string): Release[] {
8688
}
8789
}
8890

91+
function getReleaseVersion(release: GitHubRelease): string {
92+
return release.tag_name.replace(/^v/, '');
93+
}
94+
95+
function getRequestedVersion(version: string): string {
96+
return version.replace(/^v/, '');
97+
}
98+
99+
function hasStableReleaseTag(releases: GitHubRelease[], version: string): boolean {
100+
const requestedVersion = getRequestedVersion(version);
101+
102+
return releases.some((release) => {
103+
const releaseVersion = getReleaseVersion(release);
104+
return releaseVersion === requestedVersion && !isReleaseCandidate(releaseVersion);
105+
});
106+
}
107+
108+
function shouldRequireExactStableTag(version: string): boolean {
109+
return getRequestedVersion(version).split('.').length !== 2;
110+
}
111+
112+
/**
113+
* Select stable releases for a requested version.
114+
*
115+
* Two-part versions return every stable release for that minor. Three-part
116+
* versions prove the requested tag exists first, then still return the whole
117+
* minor line because gb-releases.json only stores minor aggregates.
118+
*/
119+
export function selectStableReleasesForVersion(
120+
releases: GitHubRelease[],
121+
version: string
122+
): GitHubRelease[] {
123+
if (shouldRequireExactStableTag(version) && !hasStableReleaseTag(releases, version)) {
124+
return [];
125+
}
126+
127+
const targetMinorVersion = getMinorVersion(getRequestedVersion(version));
128+
129+
return releases.filter((release) => {
130+
const releaseVersion = getReleaseVersion(release);
131+
132+
if (isReleaseCandidate(releaseVersion)) {
133+
return false;
134+
}
135+
136+
return getMinorVersion(releaseVersion) === targetMinorVersion;
137+
});
138+
}
139+
89140
/**
90141
* Merge new releases with existing ones.
91142
* New data takes precedence, but preserves contributorAggregates from existing.
92143
*/
93-
function mergeReleases(existing: Release[], newReleases: Release[]): Release[] {
144+
export function mergeReleases(existing: Release[], newReleases: Release[]): Release[] {
94145
const releaseMap = new Map<string, Release>();
95146

96147
// Add existing releases (keyed by minor version)
@@ -136,14 +187,20 @@ async function main() {
136187
console.log('Gutenberg Release Parser');
137188
console.log('========================');
138189

190+
const requestedVersion = args.version ? getRequestedVersion(args.version) : undefined;
139191
// Check if --version is a minor version (e.g., "20.0") or patch version (e.g., "20.0.1")
140-
const isMinorVersion = args.version && args.version.split('.').length === 2;
192+
const isMinorVersion = requestedVersion && requestedVersion.split('.').length === 2;
193+
const requestedMinorVersion = requestedVersion
194+
? getMinorVersion(requestedVersion)
195+
: undefined;
141196

142197
if (args.version) {
143198
if (isMinorVersion) {
144-
console.log(`Parsing minor version: ${args.version} (all patch releases)`);
199+
console.log(`Parsing minor version: ${requestedMinorVersion} (stable releases only)`);
145200
} else {
146-
console.log(`Parsing single version: ${args.version}`);
201+
console.log(
202+
`Parsing version: ${requestedVersion} (refreshing the ${requestedMinorVersion} aggregate)`
203+
);
147204
}
148205
} else if (args.from || args.to) {
149206
console.log(`Parsing version range: ${args.from ?? 'earliest'} to ${args.to ?? 'latest'}`);
@@ -155,29 +212,17 @@ async function main() {
155212

156213
try {
157214
// Fetch releases
158-
let releases;
159-
if (args.version && !isMinorVersion) {
160-
// Exact patch version lookup (e.g., "20.0.1")
161-
const release = await fetchReleaseByTag(args.version);
162-
releases = release ? [release] : [];
163-
if (releases.length === 0) {
164-
console.error(`Release not found: ${args.version}`);
165-
process.exit(1);
166-
}
167-
} else if (args.version && isMinorVersion) {
168-
// Minor version: fetch all and filter by matching minor version
215+
let releases: GitHubRelease[];
216+
if (args.version) {
169217
const allReleases = await fetchAllReleases();
170-
releases = allReleases.filter((r) => {
171-
const releaseVersion = r.tag_name.replace(/^v/, '');
172-
// Skip release candidates
173-
if (releaseVersion.includes('-rc') || releaseVersion.includes('-RC')) {
174-
return false;
175-
}
176-
// Match by minor version (e.g., "20.0" matches "20.0.0", "20.0.1", etc.)
177-
return getMinorVersion(releaseVersion) === args.version;
178-
});
218+
releases = selectStableReleasesForVersion(allReleases, args.version);
219+
179220
if (releases.length === 0) {
180-
console.error(`No releases found for minor version: ${args.version}`);
221+
if (isMinorVersion) {
222+
console.error(`No stable releases found for minor version: ${requestedMinorVersion}`);
223+
} else {
224+
console.error(`Stable release not found: ${requestedVersion}`);
225+
}
181226
process.exit(1);
182227
}
183228
} else {
@@ -271,4 +316,6 @@ async function main() {
271316
}
272317
}
273318

274-
main();
319+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
320+
main();
321+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
mergeReleases,
4+
selectStableReleasesForVersion,
5+
} from '../../../scripts/build-gb-releases.js';
6+
import { aggregatePatchReleases } from '../../../scripts/utils/release-utils.js';
7+
import type { Release } from '../../../scripts/types.js';
8+
import type { GitHubRelease } from '../../../scripts/utils/types.js';
9+
10+
function createGitHubRelease(version: string): GitHubRelease {
11+
const tagName = version.startsWith('v') ? version : `v${version}`;
12+
13+
return {
14+
tag_name: tagName,
15+
name: tagName,
16+
body: '',
17+
published_at: '2026-01-01T00:00:00Z',
18+
html_url: `https://example.test/releases/${tagName}`,
19+
};
20+
}
21+
22+
function createRelease(version: string, overrides: Partial<Release> = {}): Release {
23+
return {
24+
gbVersion: version,
25+
wpVersion: null,
26+
date: '2026-01-01',
27+
isLastBeforeWPBeta: false,
28+
totalPRs: 10,
29+
categories: { 'Bug Fixes': 10 },
30+
contributors: 0,
31+
newContributors: 0,
32+
contributorsList: [],
33+
newContributorsList: [],
34+
changelogUrl: `https://example.test/releases/v${version}`,
35+
parsedAt: '2026-01-01T00:00:00Z',
36+
parserVersion: '1.0.0',
37+
...overrides,
38+
};
39+
}
40+
41+
function releaseVersion(release: GitHubRelease): string {
42+
return release.tag_name.replace(/^v/, '');
43+
}
44+
45+
describe('selectStableReleasesForVersion', () => {
46+
it('returns the stable minor line for a patch request', () => {
47+
const releases = [
48+
createGitHubRelease('20.0.0'),
49+
createGitHubRelease('20.0.1'),
50+
createGitHubRelease('20.0.2'),
51+
createGitHubRelease('20.0.3-rc.1'),
52+
createGitHubRelease('20.1.0'),
53+
createGitHubRelease('19.9.9'),
54+
];
55+
56+
const selected = selectStableReleasesForVersion(releases, '20.0.1');
57+
58+
expect(selected.map((release) => release.tag_name)).toEqual([
59+
'v20.0.0',
60+
'v20.0.1',
61+
'v20.0.2',
62+
]);
63+
});
64+
65+
it('returns the stable minor line for an x.y.0 request', () => {
66+
const releases = [
67+
createGitHubRelease('20.0.0'),
68+
createGitHubRelease('20.0.1'),
69+
createGitHubRelease('20.1.0'),
70+
];
71+
72+
const selected = selectStableReleasesForVersion(releases, '20.0.0');
73+
74+
expect(selected.map((release) => release.tag_name)).toEqual(['v20.0.0', 'v20.0.1']);
75+
});
76+
77+
it('returns no releases when the requested stable tag is missing', () => {
78+
const releases = [
79+
createGitHubRelease('20.0.0'),
80+
createGitHubRelease('20.0.2'),
81+
];
82+
83+
const selected = selectStableReleasesForVersion(releases, '20.0.1');
84+
85+
expect(selected).toEqual([]);
86+
});
87+
88+
it('does not need an exact x.y tag for a minor request', () => {
89+
const releases = [
90+
createGitHubRelease('20.0.0'),
91+
createGitHubRelease('20.0.1'),
92+
createGitHubRelease('20.0.2-rc.1'),
93+
createGitHubRelease('20.1.0'),
94+
];
95+
96+
const selected = selectStableReleasesForVersion(releases, '20.0');
97+
98+
expect(selected.map((release) => release.tag_name)).toEqual(['v20.0.0', 'v20.0.1']);
99+
});
100+
});
101+
102+
describe('mergeReleases', () => {
103+
it('keeps patch requests from replacing an aggregate with patch-only totals', () => {
104+
const fetchedReleases = [
105+
createGitHubRelease('20.0.0'),
106+
createGitHubRelease('20.0.1'),
107+
createGitHubRelease('20.0.2'),
108+
createGitHubRelease('20.1.0'),
109+
];
110+
const selected = selectStableReleasesForVersion(fetchedReleases, '20.0.1');
111+
const totalsByVersion: Record<string, number> = {
112+
'20.0.0': 100,
113+
'20.0.1': 20,
114+
'20.0.2': 5,
115+
};
116+
117+
const aggregatedReleases = aggregatePatchReleases(
118+
selected.map((release) =>
119+
createRelease(releaseVersion(release), {
120+
totalPRs: totalsByVersion[releaseVersion(release)] ?? 0,
121+
})
122+
)
123+
);
124+
const contributorAggregates: Release['contributorAggregates'] = {
125+
stats: {
126+
total: 2,
127+
newContributors: 1,
128+
},
129+
sponsorBreakdown: {
130+
Automattic: 2,
131+
},
132+
countryBreakdown: {
133+
'United States': 2,
134+
},
135+
aggregatedAt: '2026-01-01T00:00:00Z',
136+
};
137+
138+
const merged = mergeReleases(
139+
[
140+
createRelease('20.0', {
141+
totalPRs: 999,
142+
contributorAggregates,
143+
}),
144+
],
145+
aggregatedReleases
146+
);
147+
148+
const release = merged.find((item) => item.gbVersion === '20.0');
149+
150+
expect(release?.totalPRs).toBe(125);
151+
expect(release?.totalPRs).not.toBe(20);
152+
expect(release?.contributorAggregates).toEqual(contributorAggregates);
153+
});
154+
});

0 commit comments

Comments
 (0)