Skip to content

Commit c5076e7

Browse files
committed
Optimize SyncService Release Fetching #7618
1 parent 5c2a8d1 commit c5076e7

2 files changed

Lines changed: 101 additions & 17 deletions

File tree

ai/mcp/server/github-workflow/services/SyncService.mjs

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import path from 'path';
88
import GraphqlService from './GraphqlService.mjs';
99
import {FETCH_ISSUES_FOR_SYNC, DEFAULT_QUERY_LIMITS} from './queries/issueQueries.mjs';
1010
import {GET_ISSUE_ID, UPDATE_ISSUE} from './queries/mutations.mjs';
11-
import {FETCH_RELEASES} from './queries/releaseQueries.mjs';
11+
import {FETCH_RELEASES, FETCH_LATEST_RELEASE} from './queries/releaseQueries.mjs';
1212

1313
const issueSyncConfig = aiConfig.issueSync;
1414

@@ -60,12 +60,13 @@ class SyncService extends Base {
6060
*
6161
* This method orchestrates the entire bi-directional sync workflow in a specific order
6262
* to ensure data integrity and minimize conflicts:
63-
* 1. Fetches and caches GitHub release data to aid in archiving.
64-
* 2. Loads the persistent metadata from the last sync.
63+
* 1. Loads the persistent metadata from the last sync.
64+
* 2. Fetches and caches GitHub release data, using the loaded metadata to perform a
65+
* quick check and avoid unnecessary fetches.
6566
* 3. **Pushes** any local changes (detected via content hash comparison) to GitHub.
6667
* 4. **Pulls** the latest changes from GitHub, updating local files.
6768
* 5. Syncs release notes into local Markdown files.
68-
* 6. Saves the updated metadata to disk for the next run.
69+
* 6. Saves the updated metadata (including the new release cache) to disk for the next run.
6970
*
7071
* @returns {Promise<object>} A comprehensive object containing detailed statistics and timing
7172
* information about all operations performed during the sync, conforming to the
@@ -87,10 +88,11 @@ class SyncService extends Base {
8788
async runFullSync() {
8889
const startTime = new Date();
8990

90-
await this.#fetchAndCacheReleases();
91-
9291
const metadata = await this.#loadMetadata();
9392

93+
// Pass metadata to check cache
94+
await this.#fetchAndCacheReleases(metadata);
95+
9496
// 1. Push local changes
9597
const pushStats = await this.#pushToGitHub(metadata);
9698

@@ -105,7 +107,11 @@ class SyncService extends Base {
105107
newMetadata.pushFailures = newMetadata.pushFailures.filter(failedId => !newMetadata.issues[failedId]);
106108
}
107109

108-
// 5. Save metadata
110+
// 5. Cache releases in metadata for next run
111+
newMetadata.releases = this.releases;
112+
newMetadata.releasesLastFetched = new Date().toISOString();
113+
114+
// 6. Save metadata
109115
await this.#saveMetadata(newMetadata);
110116

111117
const endTime = new Date();
@@ -182,18 +188,55 @@ class SyncService extends Base {
182188
}
183189

184190
/**
185-
* Fetches all releases from GitHub using GraphQL with automatic pagination.
191+
* Fetches releases from GitHub using an optimized two-phase approach.
192+
*
193+
* This optimization is necessary because the GitHub GraphQL `releases` endpoint does not
194+
* support a `since` parameter, making simple delta-based fetching impossible.
195+
*
196+
* First, it performs a quick check to see if the latest release is already cached.
197+
* If not, it performs a full, paginated fetch with an early-exit optimization that
198+
* stops querying when it reaches releases older than the `syncStartDate`.
199+
*
200+
* @param {object} metadata The sync metadata containing the cached releases.
186201
* @private
187202
*/
188-
async #fetchAndCacheReleases() {
189-
logger.info('Fetching and caching releases via GraphQL...');
203+
async #fetchAndCacheReleases(metadata) {
204+
logger.info('Checking for new releases...');
205+
206+
// Phase 1: Quick check against the cached latest release
207+
if (metadata.releases && metadata.releases.length > 0) {
208+
try {
209+
const latestData = await GraphqlService.query(FETCH_LATEST_RELEASE, {
210+
owner: aiConfig.owner,
211+
repo : aiConfig.repo
212+
});
213+
214+
const latestRelease = latestData.repository.latestRelease;
215+
const cachedLatest = metadata.releases[metadata.releases.length - 1]; // Releases are sorted ascending by date
216+
217+
if (latestRelease && cachedLatest &&
218+
latestRelease.tagName === cachedLatest.tagName &&
219+
latestRelease.publishedAt === cachedLatest.publishedAt) {
220+
logger.info(`✅ Releases are up-to-date (latest: ${latestRelease.tagName})`);
221+
this.releases = metadata.releases;
222+
return;
223+
}
224+
225+
logger.info(`New release detected: ${latestRelease.tagName} (cached was: ${cachedLatest?.tagName})`);
226+
} catch (e) {
227+
logger.warn(`Could not check latest release, falling back to full fetch: ${e.message}`);
228+
}
229+
}
230+
231+
// Phase 2: Full fetch with early exit
232+
logger.info('Fetching releases from GitHub via GraphQL...');
190233

191234
let allReleases = [];
192235
let hasNextPage = true;
193236
let cursor = null;
194237
const maxReleases = issueSyncConfig.maxReleases;
238+
const startDate = new Date(issueSyncConfig.syncStartDate);
195239

196-
// Paginate through all releases
197240
while (hasNextPage && allReleases.length < maxReleases) {
198241
const data = await GraphqlService.query(FETCH_RELEASES, {
199242
owner: aiConfig.owner,
@@ -203,25 +246,41 @@ class SyncService extends Base {
203246
});
204247

205248
const releases = data.repository.releases;
249+
250+
if (releases.nodes.length === 0) {
251+
logger.debug('No more releases found in pagination.');
252+
break;
253+
}
254+
255+
// Check if oldest release in this batch is before our cutoff
256+
const oldestInBatch = releases.nodes[releases.nodes.length - 1];
257+
const oldestDate = new Date(oldestInBatch.publishedAt);
258+
259+
// Add all releases from the batch for now; we will filter after the loop
206260
allReleases.push(...releases.nodes);
207261

262+
logger.debug(`Fetched ${releases.nodes.length} releases (total raw: ${allReleases.length})`);
263+
264+
// Early exit if oldest release in batch is before our cutoff
265+
if (oldestDate < startDate) {
266+
logger.info(`Reached releases published before ${issueSyncConfig.syncStartDate}, stopping pagination.`);
267+
break;
268+
}
269+
208270
hasNextPage = releases.pageInfo.hasNextPage;
209271
cursor = releases.pageInfo.endCursor;
210-
211-
logger.debug(`Fetched ${releases.nodes.length} releases (total: ${allReleases.length})`);
212272
}
213273

214-
// Filter by syncStartDate
215-
const startDate = new Date(issueSyncConfig.syncStartDate);
274+
// Now, filter and sort the collected releases
216275
this.releases = allReleases
217276
.filter(release => new Date(release.publishedAt) >= startDate)
218277
.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
219278

220279
if (this.releases.length === 0) {
221280
logger.warn(`⚠️ No releases found since syncStartDate (${issueSyncConfig.syncStartDate}). Archiving may fall back to default.`);
281+
} else {
282+
logger.info(`Found and cached ${this.releases.length} releases since ${issueSyncConfig.syncStartDate}.`);
222283
}
223-
224-
logger.info(`Found and cached ${this.releases.length} releases since ${issueSyncConfig.syncStartDate}.`);
225284
}
226285

227286
/**

ai/mcp/server/github-workflow/services/queries/releaseQueries.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@
44
* @module Neo.ai.mcp.server.github-workflow.queries.releaseQueries
55
*/
66

7+
/**
8+
* Query to fetch the single latest release.
9+
* This is used for a quick check to see if the release cache is up-to-date.
10+
*
11+
* Variables required:
12+
* - $owner: String!
13+
* - $repo: String!
14+
*/
15+
export const FETCH_LATEST_RELEASE = `
16+
query FetchLatestRelease(
17+
$owner: String!
18+
$repo: String!
19+
) {
20+
repository(owner: $owner, name: $repo) {
21+
latestRelease {
22+
tagName
23+
name
24+
publishedAt
25+
isPrerelease
26+
isDraft
27+
}
28+
}
29+
}
30+
`;
31+
732
/**
833
* Query to fetch releases with pagination support.
934
*

0 commit comments

Comments
 (0)