Skip to content

Commit 2cf7726

Browse files
committed
Implement GitHub to Local Sync (Pull) #7566
1 parent d58e368 commit 2cf7726

File tree

3 files changed

+296
-102
lines changed

3 files changed

+296
-102
lines changed

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

Lines changed: 207 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import Base from '../../../../../src/core/Base.mjs';
2-
import fs from 'fs/promises';
3-
import path from 'path';
1+
import Base from '../../../../../src/core/Base.mjs';
2+
import fs from 'fs/promises';
3+
import matter from 'gray-matter';
4+
import path from 'path';
5+
import {exec} from 'child_process';
6+
import {promisify} from 'util';
7+
8+
const execAsync = promisify(exec);
49

510
const metadataPath = path.resolve(process.cwd(), '.github', '.sync-metadata.json');
11+
const issuesDir = path.resolve(process.cwd(), '.github', 'ISSUES');
12+
const archiveDir = path.resolve(process.cwd(), '.github', 'ISSUE_ARCHIVE');
613

714
/**
815
* @class Neo.ai.mcp.server.github-workflow.SyncService
@@ -20,20 +27,146 @@ class SyncService extends Base {
2027
* @member {Boolean} singleton=true
2128
* @protected
2229
*/
23-
singleton: true
30+
singleton: true,
31+
/**
32+
* @member {String[]} droppedLabels=['dropped', 'wontfix', 'duplicate']
33+
* @protected
34+
*/
35+
droppedLabels: ['dropped', 'wontfix', 'duplicate'],
36+
/**
37+
* Defines the release schedule for archiving. Newest first.
38+
* @member {Object[]} releases
39+
* @protected
40+
*/
41+
releases: [
42+
{ version: 'v11.0', cutoffDate: '2025-11-01' },
43+
{ version: 'v10.9', cutoffDate: '2025-08-01' },
44+
{ version: 'v10.8', cutoffDate: '2025-05-01' },
45+
]
2446
}
2547

2648
/**
27-
* Placeholder for the main sync orchestration logic.
49+
* The main sync orchestration logic.
2850
* @returns {Promise<object>}
2951
*/
3052
async runFullSync() {
31-
// 1. Push local changes
53+
const metadata = await this.#loadMetadata();
54+
55+
// 1. Push local changes (to be implemented in the next ticket)
56+
// await this.#pushToGitHub(metadata);
57+
3258
// 2. Pull remote changes
59+
const newMetadata = await this.#pullFromGitHub(metadata);
60+
3361
// 3. Save metadata
62+
await this.#saveMetadata(newMetadata);
63+
3464
return { message: 'Synchronization complete.' };
3565
}
3666

67+
/**
68+
* Executes a gh CLI command and returns the parsed JSON result.
69+
* @param {String} cmd
70+
* @returns {Promise<any>}
71+
* @private
72+
*/
73+
async #ghCommand(cmd) {
74+
try {
75+
const { stdout } = await execAsync(`gh ${cmd}`, { encoding: 'utf-8' });
76+
return JSON.parse(stdout);
77+
} catch (error) {
78+
console.error(`Error executing: gh ${cmd}`);
79+
console.error(error.stderr || error.message);
80+
throw error;
81+
}
82+
}
83+
84+
/**
85+
* Formats a GitHub issue and its comments into a Markdown string with YAML frontmatter.
86+
* @param {object} issue
87+
* @param {object[]} comments
88+
* @returns {string}
89+
* @private
90+
*/
91+
#formatIssueMarkdown(issue, comments) {
92+
const frontmatter = {
93+
id : issue.number,
94+
title : issue.title,
95+
state : issue.state,
96+
labels : issue.labels.map(l => l.name),
97+
assignees : issue.assignees.map(a => a.login),
98+
created_at : issue.createdAt,
99+
updated_at : issue.updatedAt,
100+
github_url : issue.url,
101+
author : issue.author.login,
102+
comments_count: comments.length
103+
};
104+
105+
if (issue.closedAt) {
106+
frontmatter.closed_at = issue.closedAt;
107+
}
108+
if (issue.milestone) {
109+
frontmatter.milestone = issue.milestone.title;
110+
}
111+
112+
let body = `# ${issue.title}\n\n`;
113+
body += `**Reported by:** @${issue.author.login} on ${issue.createdAt.split('T')[0]}\n\n`;
114+
body += issue.body || '*(No description provided)*';
115+
body += '\n\n';
116+
117+
if (comments.length > 0) {
118+
body += '## Comments\n\n';
119+
for (const comment of comments) {
120+
const date = comment.createdAt.split('T')[0];
121+
const time = comment.createdAt.split('T')[1].substring(0, 5);
122+
body += `### @${comment.author.login} - ${date} ${time}\n\n`;
123+
body += comment.body;
124+
body += '\n\n';
125+
}
126+
}
127+
128+
return matter.stringify(body, frontmatter);
129+
}
130+
131+
/**
132+
* Determines the correct local file path for a given issue.
133+
* @param {object} issue
134+
* @returns {string|null}
135+
* @private
136+
*/
137+
#getIssuePath(issue) {
138+
const number = String(issue.number).padStart(4, '0');
139+
const labels = issue.labels.map(l => l.name.toLowerCase());
140+
141+
const isDropped = this.droppedLabels.some(label => labels.includes(label));
142+
if (isDropped) {
143+
return null; // Dropped issues are not stored locally.
144+
}
145+
146+
if (issue.state === 'OPEN') {
147+
return path.join(issuesDir, `${number}.md`);
148+
}
149+
150+
if (issue.state === 'CLOSED') {
151+
const closed = new Date(issue.closedAt);
152+
let version = this.releases[this.releases.length - 1]?.version || 'unknown';
153+
154+
if (issue.milestone?.title) {
155+
version = issue.milestone.title;
156+
} else {
157+
for (const release of this.releases) {
158+
if (closed >= new Date(release.cutoffDate)) {
159+
version = release.version;
160+
break;
161+
}
162+
}
163+
}
164+
return path.join(archiveDir, version, `${number}.md`);
165+
}
166+
167+
return null;
168+
}
169+
37170
/**
38171
* Loads the synchronization metadata file from disk.
39172
* @returns {Promise<object>}
@@ -54,6 +187,73 @@ class SyncService extends Base {
54187
}
55188
}
56189

190+
/**
191+
* Fetches all issues from GitHub and updates the local Markdown files.
192+
* @param {object} metadata
193+
* @returns {Promise<object>} The new metadata object.
194+
* @private
195+
*/
196+
async #pullFromGitHub(metadata) {
197+
console.log('📥 Fetching issues from GitHub...');
198+
const allIssues = await this.#ghCommand('issue list --limit 10000 --state all --json number,title,state,labels,assignees,milestone,createdAt,updatedAt,closedAt,url,author,body');
199+
console.log(`Found ${allIssues.length} issues`);
200+
201+
const newMetadata = {
202+
issues: {},
203+
dropped_ids: [],
204+
last_sync: new Date().toISOString()
205+
};
206+
207+
for (const issue of allIssues) {
208+
const issueNumber = issue.number;
209+
const targetPath = this.#getIssuePath(issue);
210+
211+
if (!targetPath) {
212+
newMetadata.dropped_ids.push(issueNumber);
213+
const oldPath = metadata.issues[issueNumber]?.path;
214+
if (oldPath) {
215+
try {
216+
await fs.unlink(oldPath);
217+
console.log(`🗑️ Removed dropped issue #${issueNumber}: ${oldPath}`);
218+
} catch (e) { /* File might not exist, that's ok */ }
219+
}
220+
continue;
221+
}
222+
223+
const oldIssue = metadata.issues[issueNumber];
224+
const needsUpdate = !oldIssue ||
225+
oldIssue.updated !== issue.updatedAt ||
226+
oldIssue.path !== targetPath;
227+
228+
if (needsUpdate) {
229+
const comments = await this.#ghCommand(`issue view ${issueNumber} --json comments --jq '.comments'`);
230+
const markdown = this.#formatIssueMarkdown(issue, comments);
231+
232+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
233+
await fs.writeFile(targetPath, markdown, 'utf-8');
234+
235+
if (oldIssue?.path && oldIssue.path !== targetPath) {
236+
try {
237+
await fs.unlink(oldIssue.path);
238+
console.log(`📦 Moved #${issueNumber}: ${oldIssue.path}${targetPath}`);
239+
} catch (e) { /* Old file might not exist */ }
240+
} else {
241+
console.log(`✅ Updated #${issueNumber}: ${targetPath}`);
242+
}
243+
}
244+
245+
newMetadata.issues[issueNumber] = {
246+
state: issue.state,
247+
path: targetPath,
248+
updated: issue.updatedAt,
249+
closed_at: issue.closedAt || null,
250+
milestone: issue.milestone?.title || null,
251+
title: issue.title
252+
};
253+
}
254+
return newMetadata;
255+
}
256+
57257
/**
58258
* Saves the synchronization metadata to disk.
59259
* @param {object} metadata
@@ -67,4 +267,4 @@ class SyncService extends Base {
67267
}
68268
}
69269

70-
export default Neo.setupClass(SyncService);
270+
export default Neo.setupClass(SyncService);

0 commit comments

Comments
 (0)