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
510const 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