Skip to content

Commit 9e995dc

Browse files
authored
fix(github-workflow): prune emptied sync chunk dirs (#13002) (#13009)
1 parent 2ce7a8e commit 9e995dc

7 files changed

Lines changed: 196 additions & 27 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
4+
/**
5+
* @module ai/services/github-workflow/shared/pruneEmptyDirs
6+
* @summary Root-preserving cleanup for empty content chunk directories after sync moves.
7+
*
8+
* Syncers can empty active or archive chunk directories when an item moves to a new
9+
* bucket or is dropped. Pipeline worktrees can also retain empty active chunks from
10+
* earlier runs because Git cannot represent them. This helper removes empty descendants
11+
* while deliberately preserving the configured root directory, so downstream sync/index
12+
* code can still treat the root as an existing collection boundary.
13+
*
14+
* @param {String} root Absolute or repository-relative directory whose empty descendants should be pruned.
15+
* @returns {Promise<void>}
16+
*/
17+
export default async function pruneEmptyDirs(root) {
18+
let entries;
19+
20+
try {
21+
entries = await fs.readdir(root, {withFileTypes: true});
22+
} catch {
23+
return; // root absent means there is nothing to prune.
24+
}
25+
26+
for (const entry of entries) {
27+
if (!entry.isDirectory()) continue;
28+
29+
const child = path.join(root, entry.name);
30+
await pruneEmptyDirs(child);
31+
32+
try {
33+
if ((await fs.readdir(child)).length === 0) {
34+
await fs.rmdir(child);
35+
}
36+
} catch {
37+
// Directory changed or disappeared during cleanup; the next sync can retry.
38+
}
39+
}
40+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createContentIndexEntry,
1414
updateContentIndex
1515
} from '../shared/contentIndex.mjs';
16+
import pruneEmptyDirs from '../shared/pruneEmptyDirs.mjs';
1617
import {verifyDiscussionFrontmatter} from './verifyFrontmatterIntegrity.mjs';
1718

1819
const issueSyncConfig = aiConfig.issueSync;
@@ -289,6 +290,7 @@ class DiscussionSyncer extends Base {
289290
}
290291

291292
const planBuckets = this.#planBuckets(metadata, allDiscussions);
293+
let shouldPruneEmptyDirs = false;
292294

293295
for (const discussion of allDiscussions) {
294296
try {
@@ -369,6 +371,7 @@ class DiscussionSyncer extends Base {
369371
if (oldAbsolutePath && oldAbsolutePath !== targetPath) {
370372
try {
371373
await fs.unlink(oldAbsolutePath);
374+
shouldPruneEmptyDirs = true;
372375
logger.debug(`📦 Moved Discussion #${discussion.number}: ${oldAbsolutePath}${targetPath}`);
373376
} catch (e) {
374377
// File might not exist
@@ -387,6 +390,11 @@ class DiscussionSyncer extends Base {
387390
}
388391
}
389392

393+
await pruneEmptyDirs(issueSyncConfig.discussionsDir);
394+
if (shouldPruneEmptyDirs) {
395+
await pruneEmptyDirs(path.join(issueSyncConfig.archiveRoot, 'discussions'));
396+
}
397+
390398
// Cache for the main orchestrator to merge
391399
metadata.discussions = {};
392400
const indexEntries = [];

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

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {FETCH_ISSUE_TIMELINE_PAGE, FETCH_ISSUES_FOR_SYNC, FETCH_SINGLE_ISSUE} fr
1313
import {GET_ISSUE_ID, UPDATE_ISSUE} from '../queries/mutations.mjs';
1414
import contentPath from '../shared/contentPath.mjs';
1515
import {createContentIndexEntry, updateContentIndex} from '../shared/contentIndex.mjs';
16+
import pruneEmptyDirs from '../shared/pruneEmptyDirs.mjs';
1617

1718
const issueSyncConfig = aiConfig.issueSync;
1819
const lineBreaksRegex = /[\r\n]+/g;
@@ -608,6 +609,7 @@ class IssueSyncer extends Base {
608609
};
609610

610611
const indexMutations = {upsert: [], remove: []};
612+
let shouldPruneEmptyDirs = false;
611613

612614
const planBuckets = this.#planBuckets(metadata, allIssues);
613615

@@ -650,6 +652,7 @@ class IssueSyncer extends Base {
650652
try {
651653
const oldPath = this.#resolvePath(oldPathRelative);
652654
await fs.unlink(oldPath);
655+
shouldPruneEmptyDirs = true;
653656
logger.debug(`🗑️ Removed dropped issue #${issueNumber}: ${oldPath}`);
654657
} catch (e) { /* File might not exist */ }
655658
}
@@ -690,10 +693,11 @@ class IssueSyncer extends Base {
690693
stats.pulled.moved++;
691694
try {
692695
await fs.rename(oldAbsolutePath, targetPath);
696+
shouldPruneEmptyDirs = true;
693697
logger.debug(`📦 Moved #${issueNumber}: ${oldAbsolutePath}${targetPath}`);
694698
} catch (e) {
695699
logger.warn(`Could not rename #${issueNumber}, falling back to write. Error: ${e.message}`);
696-
await fs.unlink(oldAbsolutePath).catch(() => {});
700+
await fs.unlink(oldAbsolutePath).then(() => { shouldPruneEmptyDirs = true; }).catch(() => {});
697701
}
698702
} else {
699703
stats.pulled.updated++;
@@ -789,6 +793,11 @@ class IssueSyncer extends Base {
789793
stats.pulled.issues.push(...refetchStats.refetched.issues);
790794
}
791795

796+
await pruneEmptyDirs(issueSyncConfig.issuesDir);
797+
if (shouldPruneEmptyDirs) {
798+
await pruneEmptyDirs(path.join(issueSyncConfig.archiveRoot, 'issues'));
799+
}
800+
792801
try {
793802
await updateContentIndex(issueSyncConfig, indexMutations);
794803
} catch (e) {
@@ -1023,6 +1032,7 @@ class IssueSyncer extends Base {
10231032
logger.info('🔄 Reconciling closed issue locations...');
10241033

10251034
const stats = { count: 0, issues: [] };
1035+
let shouldPruneEmptyDirs = false;
10261036

10271037
// Ensure releases are loaded
10281038
if (!ReleaseNotesSyncer.sortedReleases || ReleaseNotesSyncer.sortedReleases.length === 0) {
@@ -1078,6 +1088,7 @@ class IssueSyncer extends Base {
10781088

10791089
// Move the file
10801090
await fs.rename(currentAbsolutePath, correctPath);
1091+
shouldPruneEmptyDirs = true;
10811092

10821093
// Update metadata with relative path
10831094
metadata.issues[issueNumber].path = this.#relativePath(correctPath);
@@ -1092,6 +1103,8 @@ class IssueSyncer extends Base {
10921103
}
10931104
}
10941105

1106+
await pruneEmptyDirs(issueSyncConfig.issuesDir);
1107+
10951108
if (stats.count > 0) {
10961109
logger.info(`📦 Archived ${stats.count} closed issue(s)`);
10971110
} else {
@@ -1195,37 +1208,13 @@ class IssueSyncer extends Base {
11951208
await updateContentIndex(issueSyncConfig, {upsert: upserts});
11961209

11971210
// Prune chunk/version directories emptied by the relocation.
1198-
await this.#pruneEmptyDirs(path.join(issueSyncConfig.archiveRoot, 'issues'));
1199-
await this.#pruneEmptyDirs(issueSyncConfig.issuesDir);
1211+
await pruneEmptyDirs(path.join(issueSyncConfig.archiveRoot, 'issues'));
1212+
await pruneEmptyDirs(issueSyncConfig.issuesDir);
12001213

12011214
logger.info(`[REBUCKET] moved ${moves.length} issue(s), ${unchanged} unchanged. Distribution: ${JSON.stringify(byVersion)}`);
12021215
return summary;
12031216
}
12041217

1205-
/**
1206-
* Recursively removes now-empty directories under `root` (after a re-bucket relocation leaves
1207-
* source chunk/version folders empty). Children are pruned before parents; `root` is never removed.
1208-
* @param {string} root Absolute directory to prune within.
1209-
* @private
1210-
*/
1211-
async #pruneEmptyDirs(root) {
1212-
let entries;
1213-
try {
1214-
entries = await fs.readdir(root, {withFileTypes: true});
1215-
} catch {
1216-
return; // root absent — nothing to prune
1217-
}
1218-
1219-
for (const entry of entries) {
1220-
if (!entry.isDirectory()) continue;
1221-
const child = path.join(root, entry.name);
1222-
await this.#pruneEmptyDirs(child);
1223-
try {
1224-
if ((await fs.readdir(child)).length === 0) await fs.rmdir(child);
1225-
} catch { /* race / already removed */ }
1226-
}
1227-
}
1228-
12291218
/**
12301219
* Recursively scans the configured issue directory to find all local .md issue files.
12311220
* This operation is intentionally limited to the active issues directory as a performance

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ReleaseNotesSyncer from './ReleaseNotesSyncer.mjs';
1212
import {FETCH_PULL_REQUESTS_FOR_SYNC} from '../queries/pullRequestQueries.mjs';
1313
import contentPath from '../shared/contentPath.mjs';
1414
import {createContentIndexEntry, updateContentIndex} from '../shared/contentIndex.mjs';
15+
import pruneEmptyDirs from '../shared/pruneEmptyDirs.mjs';
1516

1617
const issueSyncConfig = aiConfig.issueSync;
1718
const pullRequestConfig = aiConfig.pullRequest;
@@ -282,6 +283,7 @@ class PullRequestSyncer extends Base {
282283

283284
const cachedPulls = metadata.pulls || {};
284285
const planBuckets = this.#planBuckets(metadata, allPullRequests);
286+
let shouldPruneEmptyDirs = false;
285287

286288
for (const pr of allPullRequests) {
287289
try {
@@ -354,6 +356,7 @@ class PullRequestSyncer extends Base {
354356
if (oldAbsolutePath && oldAbsolutePath !== targetPath) {
355357
try {
356358
await fs.unlink(oldAbsolutePath);
359+
shouldPruneEmptyDirs = true;
357360
logger.debug(`📦 Moved PR #${pr.number}: ${oldAbsolutePath}${targetPath}`);
358361
} catch (e) {
359362
// File might not exist
@@ -372,6 +375,11 @@ class PullRequestSyncer extends Base {
372375
}
373376
}
374377

378+
await pruneEmptyDirs(issueSyncConfig.pullsDir);
379+
if (shouldPruneEmptyDirs) {
380+
await pruneEmptyDirs(path.join(issueSyncConfig.archiveRoot, 'pulls'));
381+
}
382+
375383
// Cache for the main orchestrator to merge
376384
metadata.pulls = {};
377385
const indexEntries = [];

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,48 @@ test.describe('Neo.ai.services.github-workflow.sync.DiscussionSyncer', () => {
275275
expect(content).toMatch(/^closedAt: '2026-05-01T00:00:00Z'$/m);
276276
});
277277

278+
test('syncDiscussions prunes emptied active chunk directories after archive moves (#13002)', async () => {
279+
const discussion = buildDiscussion(24006, {
280+
closed : true,
281+
closedAt: '2026-05-01T00:00:00Z'
282+
});
283+
const oldPath = path.join(aiConfig.issueSync.discussionsDir, 'chunk-77', 'discussion-24006.md');
284+
const oldRel = path.relative(aiConfig.projectRoot, oldPath);
285+
286+
await fs.ensureDir(path.dirname(oldPath));
287+
await fs.writeFile(oldPath, 'OLD DISCUSSION CONTENT', 'utf8');
288+
289+
GraphqlService.query = async () => ({
290+
repository: {
291+
discussions: {
292+
nodes : [discussion],
293+
pageInfo: {hasNextPage: false, endCursor: null}
294+
}
295+
}
296+
});
297+
298+
const metadata = {
299+
discussions: {
300+
24006: {
301+
closed : false,
302+
closedAt : null,
303+
contentHash: 'old-hash',
304+
path : oldRel
305+
}
306+
}
307+
};
308+
309+
const stats = await DiscussionSyncer.syncDiscussions(metadata);
310+
const targetPath = path.join(aiConfig.issueSync.archiveRoot, 'discussions', 'v13.0.0', 'chunk-1', 'discussion-24006.md');
311+
312+
expect(stats.synced).toEqual([24006]);
313+
await expect(fs.pathExists(targetPath)).resolves.toBe(true);
314+
await expect(fs.pathExists(oldPath)).resolves.toBe(false);
315+
await expect(fs.pathExists(path.dirname(oldPath))).resolves.toBe(false);
316+
await expect(fs.pathExists(aiConfig.issueSync.discussionsDir)).resolves.toBe(true);
317+
expect(metadata.discussions[24006].path).toBe(path.relative(aiConfig.projectRoot, targetPath));
318+
});
319+
278320
test('delta cutoff stops discussion pagination once a batch predates the cached high-water mark (#12190)', async () => {
279321
// Mirror of the PR/issue delta: order UPDATED_AT DESC + stop at the cached high-water mark.
280322
const metadata = {

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,47 @@ test.describe('Neo.ai.services.github-workflow.sync.IssueSyncer', () => {
401401
}
402402
});
403403

404+
test('reconcileClosedIssueLocations prunes emptied active chunk directories after archiving (#13002)', async () => {
405+
const originalSorted = ReleaseNotesSyncer.sortedReleases;
406+
const issueNumber = 6003;
407+
const oldAbs = path.join(issueSyncConfig.issuesDir, 'chunk-77', `issue-${issueNumber}.md`);
408+
const oldRel = path.relative(aiConfig.projectRoot, oldAbs);
409+
410+
await fs.ensureDir(path.dirname(oldAbs));
411+
await fs.writeFile(oldAbs, 'CLOSED ISSUE CONTENT', 'utf8');
412+
413+
ReleaseNotesSyncer.sortedReleases = [{tagName: 'v13.0.0', publishedAt: '2026-05-10T00:00:00Z'}];
414+
415+
const metadata = {
416+
issues: {
417+
[issueNumber]: {
418+
state : 'CLOSED',
419+
path : oldRel,
420+
updatedAt : '2026-05-02T00:00:00Z',
421+
closedAt : '2026-05-01T00:00:00Z',
422+
milestone : null,
423+
title : 'Closed issue ready for archival',
424+
contentHash : 'hash',
425+
commentsTotal: 0
426+
}
427+
}
428+
};
429+
430+
try {
431+
const stats = await IssueSyncer.reconcileClosedIssueLocations(metadata);
432+
const targetAbs = path.join(issueSyncConfig.archiveRoot, 'issues', 'v13.0.0', 'chunk-1', `issue-${issueNumber}.md`);
433+
434+
expect(stats.count).toBe(1);
435+
expect(metadata.issues[issueNumber].path).toBe(path.relative(aiConfig.projectRoot, targetAbs));
436+
await expect(fs.pathExists(targetAbs)).resolves.toBe(true);
437+
await expect(fs.pathExists(oldAbs)).resolves.toBe(false);
438+
await expect(fs.pathExists(path.dirname(oldAbs))).resolves.toBe(false);
439+
await expect(fs.pathExists(issueSyncConfig.issuesDir)).resolves.toBe(true);
440+
} finally {
441+
ReleaseNotesSyncer.sortedReleases = originalSorted;
442+
}
443+
});
444+
404445
test('pullFromGitHub enforces sealed-chunk archive semantics', async () => {
405446
const mockIssue = buildMockIssue({
406447
number : 42044,

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,47 @@ test.describe('Neo.ai.services.github-workflow.sync.PullRequestSyncer', () => {
206206
await expect(fs.pathExists(cutArchivePath)).resolves.toBe(true);
207207
});
208208

209+
test('syncPullRequests prunes emptied active chunk directories after archive moves (#13002)', async () => {
210+
const prNumber = 3291;
211+
const pr = buildPullRequest(prNumber);
212+
const oldPath = path.join(aiConfig.issueSync.pullsDir, 'chunk-77', `pr-${prNumber}.md`);
213+
const oldRel = path.relative(aiConfig.projectRoot, oldPath);
214+
215+
ReleaseNotesSyncer.sortedReleases = [{tagName: 'v13.0.0', publishedAt: '2026-05-10T00:00:00Z'}];
216+
217+
await fs.ensureDir(path.dirname(oldPath));
218+
await fs.writeFile(oldPath, 'OLD PR CONTENT', 'utf8');
219+
220+
GraphqlService.query = async () => ({
221+
repository: {
222+
pullRequests: {
223+
nodes : [pr],
224+
pageInfo: {hasNextPage: false, endCursor: null}
225+
}
226+
}
227+
});
228+
229+
const metadata = {
230+
pulls: {
231+
[prNumber]: {
232+
state : 'OPEN',
233+
updatedAt: '2026-05-01T00:00:00Z',
234+
path : oldRel
235+
}
236+
}
237+
};
238+
239+
const stats = await PullRequestSyncer.syncPullRequests(metadata);
240+
const targetPath = path.join(aiConfig.issueSync.archiveRoot, 'pulls', 'v13.0.0', 'chunk-1', `pr-${prNumber}.md`);
241+
242+
expect(stats.synced).toEqual([prNumber]);
243+
await expect(fs.pathExists(targetPath)).resolves.toBe(true);
244+
await expect(fs.pathExists(oldPath)).resolves.toBe(false);
245+
await expect(fs.pathExists(path.dirname(oldPath))).resolves.toBe(false);
246+
await expect(fs.pathExists(aiConfig.issueSync.pullsDir)).resolves.toBe(true);
247+
expect(metadata.pulls[prNumber].path).toBe(path.relative(aiConfig.projectRoot, targetPath));
248+
});
249+
209250
test('delta cutoff stops PR pagination once a batch predates the cached high-water mark (#12190)', async () => {
210251
// The `pullRequests` connection has no server-side `since`, so the syncer orders UPDATED_AT
211252
// DESC and stops paginating at the cached high-water mark. Pre-fix it scanned the full corpus.

0 commit comments

Comments
 (0)