Skip to content

Commit bb95a80

Browse files
neo-opus-adatobiu
andauthored
fix(github-workflow): full release history for bucketing + milestone semver guard (#12188) (#12192)
Issue/PR bucketing resolved closedAt-to-release over a syncStartDate-floored sortedReleases, so every pre-floor closed item collapsed into the oldest in-window release (a v8.1.0 catch-all of 4133/7263 archived issues). Now fetch the full release history into the bucketing reference while keeping release-notes floored in syncNotes (no extra on-disk notes or SSR routes); maxReleases raised above the total release count. Also guard the milestone-to-version branch with semver.valid so descriptive milestones no longer become garbage version folders; they fall through to closedAt-to-release. Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent e879264 commit bb95a80

7 files changed

Lines changed: 199 additions & 35 deletions

File tree

ai/mcp/server/github-workflow/config.template.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,13 @@ class Config extends BaseConfig {
197197
*/
198198
maxIssues: leaf(20000),
199199
/**
200-
* The maximum number of releases to fetch from the GitHub API.
200+
* Safety cap on releases fetched from the GitHub API. Must exceed the repo's total
201+
* release count: the closed-item bucketing reference (`ReleaseNotesSyncer.sortedReleases`)
202+
* spans the full history, so a cap below the total would drop the oldest releases and
203+
* mis-bucket pre-cap closed items.
201204
* @type {number}
202205
*/
203-
maxReleases: leaf(1000),
206+
maxReleases: leaf(2000),
204207
/**
205208
* The number of releases to fetch per page in GraphQL queries.
206209
* @type {number}

ai/services/github-workflow/sync/IssueSyncer.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,10 @@ class IssueSyncer extends Base {
353353
for (const issue of combined.values()) {
354354
let version = null;
355355
if (issue.state === 'CLOSED') {
356-
if (issue.milestone?.title) {
356+
// Treat a milestone as a version bucket ONLY when its title is valid semver. A
357+
// descriptive milestone (e.g. "neo.d.ts - Typescript definitions") must not become
358+
// a version folder; non-semver milestones fall through to closedAt→release resolution.
359+
if (issue.milestone?.title && semver.valid(semver.clean(issue.milestone.title))) {
357360
version = issue.milestone.title.startsWith(issueSyncConfig.versionDirectoryPrefix)
358361
? issue.milestone.title
359362
: issueSyncConfig.versionDirectoryPrefix + issue.milestone.title;

ai/services/github-workflow/sync/PullRequestSyncer.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import fs from 'fs/promises';
55
import logger from '../../../mcp/server/github-workflow/logger.mjs';
66
import matter from 'gray-matter';
77
import path from 'path';
8+
import semver from 'semver';
89
import GraphqlService from '../GraphqlService.mjs';
910
import ReleaseNotesSyncer from './ReleaseNotesSyncer.mjs';
1011
import {FETCH_PULL_REQUESTS_FOR_SYNC} from '../queries/pullRequestQueries.mjs';
@@ -108,7 +109,9 @@ class PullRequestSyncer extends Base {
108109

109110
for (const pr of combined.values()) {
110111
let version = null;
111-
if (pr.milestone?.title) {
112+
// Treat a milestone as a version bucket ONLY when its title is valid semver — a
113+
// descriptive milestone must not become a version folder (mirrors IssueSyncer).
114+
if (pr.milestone?.title && semver.valid(semver.clean(pr.milestone.title))) {
112115
version = pr.milestone.title.startsWith(issueSyncConfig.versionDirectoryPrefix)
113116
? pr.milestone.title
114117
: issueSyncConfig.versionDirectoryPrefix + pr.milestone.title;

ai/services/github-workflow/sync/ReleaseNotesSyncer.mjs

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,16 @@ class ReleaseNotesSyncer extends Base {
5959
}
6060

6161
/**
62-
* Fetches releases from GitHub using an optimized two-phase approach.
62+
* Fetches the full release history from GitHub using a two-phase approach.
6363
*
64-
* This optimization is necessary because the GitHub GraphQL `releases` endpoint does not
64+
* The two phases are necessary because the GitHub GraphQL `releases` endpoint does not
6565
* support a `since` parameter, making simple delta-based fetching impossible.
6666
*
67-
* First, it performs a quick check to see if the latest release is already cached.
68-
* If not, it performs a full, paginated fetch with an early-exit optimization that
69-
* stops querying when it reaches releases older than the `syncStartDate`.
67+
* First, a quick check sees whether the latest release is already cached (fast-path no-op).
68+
* If not, it performs a full paginated fetch of the COMPLETE release history (bounded only by
69+
* the `maxReleases` safety cap). The full set populates `sortedReleases`, the bucketing
70+
* reference for closed Issues/PRs/Discussions; release-NOTES are floored to `syncStartDate`
71+
* downstream in `syncNotes`.
7072
*
7173
* @param {object} metadata The sync metadata containing the cached releases.
7274
* @returns {Promise<void>}
@@ -108,14 +110,16 @@ class ReleaseNotesSyncer extends Base {
108110
}
109111
}
110112

111-
// Full paginated fetch with early exit.
112-
logger.info('Fetching releases from GitHub via GraphQL...');
113+
// Full paginated fetch of the COMPLETE release history. The bucketing reference
114+
// (`sortedReleases`, consumed by Issue/PR/Discussion closedAt→release resolution) must span
115+
// every release; otherwise closed items predating the oldest fetched release all collapse
116+
// into it (a catch-all bucket). Release-NOTES are floored separately in syncNotes.
117+
logger.info('Fetching the full release history from GitHub via GraphQL...');
113118

114119
let allReleases = [];
115120
let hasNextPage = true;
116121
let cursor = null;
117122
const maxReleases = issueSyncConfig.maxReleases;
118-
const startDate = new Date(issueSyncConfig.syncStartDate);
119123

120124
while (hasNextPage && allReleases.length < maxReleases) {
121125
const data = await GraphqlService.query(FETCH_RELEASES, {
@@ -132,34 +136,29 @@ class ReleaseNotesSyncer extends Base {
132136
break;
133137
}
134138

135-
// Check if oldest release in this batch is before our cutoff
136-
const oldestInBatch = releases.nodes[releases.nodes.length - 1];
137-
const oldestDate = new Date(oldestInBatch.publishedAt);
138-
139-
// Add all releases from the batch for now; we will filter after the loop
140139
allReleases.push(...releases.nodes);
141140

142-
logger.debug(`Fetched ${releases.nodes.length} releases (total raw: ${allReleases.length})`);
143-
144-
// Early exit if oldest release in batch is before our cutoff
145-
if (oldestDate < startDate) {
146-
logger.info(`Reached releases published before ${issueSyncConfig.syncStartDate}, stopping pagination.`);
147-
break;
148-
}
141+
logger.debug(`Fetched ${releases.nodes.length} releases (total: ${allReleases.length})`);
149142

150143
hasNextPage = releases.pageInfo.hasNextPage;
151144
cursor = releases.pageInfo.endCursor;
152145
}
153146

154-
// Now, filter and sort the collected releases
155-
const filteredAndSortedReleases = allReleases
156-
.filter(release => new Date(release.publishedAt) >= startDate)
147+
// No silent truncation: warn if the safety cap was hit before history was exhausted.
148+
if (hasNextPage && allReleases.length >= maxReleases) {
149+
logger.warn(`⚠️ Hit maxReleases cap (${maxReleases}) before exhausting the release history; the oldest releases are missing from the bucketing reference. Raise issueSyncConfig.maxReleases.`);
150+
}
151+
152+
// Sort the COMPLETE set ascending. Both the release map and the bucketing reference span the
153+
// full history; release-NOTES are floored by syncStartDate in syncNotes, so this wide set
154+
// never reaches disk as extra notes or SSR routes.
155+
const allSorted = allReleases
157156
.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
158157

159158
this.releases = {};
160159
this.sortedReleases = [];
161160

162-
filteredAndSortedReleases.forEach(release => {
161+
allSorted.forEach(release => {
163162
this.releases[release.tagName] = release;
164163
this.sortedReleases.push({
165164
tagName : release.tagName,
@@ -168,9 +167,9 @@ class ReleaseNotesSyncer extends Base {
168167
});
169168

170169
if (Object.keys(this.releases).length === 0) {
171-
logger.warn(`⚠️ No releases found since syncStartDate (${issueSyncConfig.syncStartDate}). Archiving may fall back to default.`);
170+
logger.warn('⚠️ No releases found. Archiving will fall back to active/Backlog.');
172171
} else {
173-
logger.info(`Found and cached ${Object.keys(this.releases).length} releases since ${issueSyncConfig.syncStartDate}.`);
172+
logger.info(`Found and cached ${Object.keys(this.releases).length} releases (full history).`);
174173
}
175174
}
176175

@@ -205,10 +204,18 @@ class ReleaseNotesSyncer extends Base {
205204
};
206205

207206
const cachedReleases = metadata.releases || {};
207+
const startDate = new Date(issueSyncConfig.syncStartDate);
208+
209+
// Release-notes content is floored to syncStartDate even though the bucketing reference
210+
// (`sortedReleases`) now spans the full history: we only write notes for in-window releases,
211+
// keeping the on-disk notes set and its chunk layout stable. Index within the floored set so
212+
// chunk numbers match the notes actually written.
213+
const notesReleases = this.sortedReleases.filter(r => new Date(r.publishedAt) >= startDate);
208214

209215
for (const release of Object.values(this.releases)) {
216+
if (new Date(release.publishedAt) < startDate) continue;
210217
try {
211-
const itemIndex = this.sortedReleases.findIndex(r => r.tagName === release.tagName);
218+
const itemIndex = notesReleases.findIndex(r => r.tagName === release.tagName);
212219
const chunkNumber = chunkNumberFor(itemIndex);
213220

214221
const filename = release.tagName.startsWith(issueSyncConfig.releaseFilenamePrefix)

test/playwright/unit/ai/services/github-workflow/IssueSyncer.spec.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import path from 'path';
3838
*/
3939
test.describe('Neo.ai.services.github-workflow.sync.IssueSyncer', () => {
4040
let IssueSyncer;
41+
let ReleaseNotesSyncer;
4142
let GraphqlService;
4243
let issueSyncConfig;
4344
let aiConfig;
@@ -66,6 +67,7 @@ test.describe('Neo.ai.services.github-workflow.sync.IssueSyncer', () => {
6667

6768
GraphqlService = (await import('../../../../../../ai/services/github-workflow/GraphqlService.mjs')).default;
6869
IssueSyncer = (await import('../../../../../../ai/services/github-workflow/sync/IssueSyncer.mjs')).default;
70+
ReleaseNotesSyncer = (await import('../../../../../../ai/services/github-workflow/sync/ReleaseNotesSyncer.mjs')).default;
6971
logger = (await import('../../../../../../ai/mcp/server/github-workflow/logger.mjs')).default;
7072

7173
originalQuery = GraphqlService.query.bind(GraphqlService);
@@ -257,6 +259,49 @@ test.describe('Neo.ai.services.github-workflow.sync.IssueSyncer', () => {
257259
expect(targetPath).not.toContain('unversioned');
258260
});
259261

262+
test('non-semver milestone is not a version bucket — falls through to closedAt→release (#12184)', async () => {
263+
// A descriptive (non-semver) milestone must NOT become a `v<title>` archive folder. Empirical:
264+
// #3286/#3287 carried milestones like "neo.d.ts - Typescript definitions ..." and were archived
265+
// as garbage version folders. With the semver guard, such a closed issue falls through to the
266+
// closedAt→release resolution and buckets into the real release that shipped after it closed.
267+
const mockIssue = buildMockIssue({
268+
number : 3286,
269+
title : 'Mock non-semver-milestone issue',
270+
timelineFirst: [],
271+
hasNextPage : false,
272+
endCursor : null
273+
});
274+
mockIssue.state = 'CLOSED';
275+
mockIssue.closedAt = '2024-09-15T00:00:00Z';
276+
mockIssue.milestone = {title: 'neo.d.ts - Typescript definitions for all neo framework classes'};
277+
278+
// A real release published AFTER the issue closed → the closedAt→release fallback resolves here.
279+
const originalSorted = ReleaseNotesSyncer.sortedReleases;
280+
ReleaseNotesSyncer.sortedReleases = [{tagName: 'v9.0.0', publishedAt: '2024-10-01T00:00:00Z'}];
281+
282+
GraphqlService.query = async (query) => {
283+
if (query.includes('FetchSingleIssue')) {
284+
return {repository: {issue: structuredClone(mockIssue)}};
285+
}
286+
throw new Error(`Unexpected GraphQL query in test: ${query.slice(0, 80)}`);
287+
};
288+
289+
const metadata = {issues: {}};
290+
291+
try {
292+
const stats = await IssueSyncer.refetchIssuesByNumber([mockIssue.number], metadata);
293+
expect(stats.refetched.count).toBe(1);
294+
expect(stats.errors).toHaveLength(0);
295+
296+
const bucketPath = metadata.issues[mockIssue.number].path;
297+
// Bucketed into the real release, NOT a title-derived garbage folder.
298+
expect(bucketPath).toContain(path.join('archive', 'issues', 'v9.0.0'));
299+
expect(bucketPath).not.toContain('neo.d.ts');
300+
} finally {
301+
ReleaseNotesSyncer.sortedReleases = originalSorted;
302+
}
303+
});
304+
260305
test('pullFromGitHub enforces sealed-chunk archive semantics', async () => {
261306
const mockIssue = buildMockIssue({
262307
number : 42044,

test/playwright/unit/ai/services/github-workflow/PullRequestSyncer.spec.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,39 @@ test.describe('Neo.ai.services.github-workflow.sync.PullRequestSyncer', () => {
116116
// `archiveVersion` is fully retired (#11364) — it is no longer written to metadata.
117117
expect(metadata.pulls[prNumber].archiveVersion).toBeUndefined();
118118
});
119+
120+
test('non-semver milestone is not a version bucket — PR falls through to closedAt→release (#12184)', async () => {
121+
const prNumber = 3287;
122+
123+
// A descriptive (non-semver) milestone must NOT become a `v<title>` archive folder (mirror of
124+
// the IssueSyncer guard). The merged PR falls through to the closedAt→release resolution and
125+
// buckets into the real release that shipped after it merged.
126+
const pr = buildPullRequest(prNumber);
127+
pr.milestone = {title: 'Neo-Material Component Library v0.1'};
128+
pr.mergedAt = '2024-09-15T00:00:00Z';
129+
pr.closedAt = '2024-09-15T00:00:00Z';
130+
131+
// A real release published AFTER the PR merged → the closedAt→release fallback resolves here.
132+
ReleaseNotesSyncer.sortedReleases = [{tagName: 'v9.0.0', publishedAt: '2024-10-01T00:00:00Z'}];
133+
134+
GraphqlService.query = async () => ({
135+
repository: {
136+
pullRequests: {
137+
nodes : [pr],
138+
pageInfo: {hasNextPage: false, endCursor: null}
139+
}
140+
}
141+
});
142+
143+
const stats = await PullRequestSyncer.syncPullRequests({pulls: {}});
144+
const releasePath = path.join(aiConfig.issueSync.contentRoot, 'archive', 'pulls', 'v9.0.0', 'chunk-1', `pr-${prNumber}.md`);
145+
const garbagePath = path.join(aiConfig.issueSync.contentRoot, 'archive', 'pulls', 'vNeo-Material Component Library v0.1', 'chunk-1', `pr-${prNumber}.md`);
146+
147+
expect(stats.synced).toEqual([prNumber]);
148+
// Bucketed into the real release, NOT a title-derived garbage folder.
149+
await expect(fs.pathExists(releasePath)).resolves.toBe(true);
150+
await expect(fs.pathExists(garbagePath)).resolves.toBe(false);
151+
});
119152
});
120153

121154
function buildPullRequest(number) {

0 commit comments

Comments
 (0)