claude-autopm 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * EpicService - Epic Management Service
3
3
  *
4
- * Pure service layer for epic operations without any I/O operations.
5
- * Follows 3-layer architecture: Service (logic) -> No direct I/O
4
+ * Pure service layer for epic operations following ClaudeAutoPM patterns.
5
+ * Follows 3-layer architecture: Service (logic) -> Provider (I/O) -> CLI (presentation)
6
6
  *
7
- * Provides 12 pure business logic methods:
7
+ * Provides comprehensive epic lifecycle management:
8
8
  *
9
9
  * 1. Status & Categorization (5 methods):
10
10
  * - categorizeStatus: Categorize epic status
@@ -26,11 +26,21 @@
26
26
  * - generateEpicContent: Build complete epic markdown
27
27
  * - buildTaskSection: Format tasks as markdown list
28
28
  *
29
+ * 5. GitHub Epic Sync Methods (6 methods):
30
+ * - syncEpicToGitHub: Push epic to GitHub with conflict detection
31
+ * - syncEpicFromGitHub: Pull GitHub epic to local
32
+ * - syncEpicBidirectional: Full bidirectional sync
33
+ * - createGitHubEpic: Create GitHub issue with "epic" label
34
+ * - updateGitHubEpic: Update GitHub epic
35
+ * - getEpicSyncStatus: Get sync status for epic
36
+ *
29
37
  * Documentation Queries:
30
38
  * - mcp://context7/agile/epic-management - Epic management best practices
31
39
  * - mcp://context7/agile/task-breakdown - Task breakdown patterns
32
40
  * - mcp://context7/project-management/dependencies - Dependency management
33
41
  * - mcp://context7/markdown/frontmatter - YAML frontmatter patterns
42
+ * - mcp://context7/github/issues-api - GitHub Issues API v3 best practices
43
+ * - mcp://context7/conflict-resolution/sync - Conflict resolution strategies
34
44
  */
35
45
 
36
46
  const PRDService = require('./PRDService');
@@ -878,6 +888,1085 @@ ${prdContent}`;
878
888
  const path = require('path');
879
889
  return path.join(this.getEpicPath(epicName), 'epic.md');
880
890
  }
891
+
892
+ // ==========================================
893
+ // 7. GITHUB EPIC SYNC METHODS (6 METHODS)
894
+ // ==========================================
895
+
896
+ /**
897
+ * Sync local epic to GitHub (enhanced push with conflict detection)
898
+ *
899
+ * @param {string} epicName - Local epic name
900
+ * @param {Object} [options={}] - Sync options
901
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
902
+ * @returns {Promise<Object>} Result: { success, epicName, githubNumber, action, conflict? }
903
+ * @throws {Error} If no provider configured or epic not found
904
+ */
905
+ async syncEpicToGitHub(epicName, options = {}) {
906
+ if (!this.provider) {
907
+ throw new Error('No provider configured for GitHub sync');
908
+ }
909
+
910
+ const fs = require('fs-extra');
911
+ const epicFilePath = this.getEpicFilePath(epicName);
912
+
913
+ // Check if epic exists
914
+ const exists = await fs.pathExists(epicFilePath);
915
+ if (!exists) {
916
+ throw new Error(`Epic not found: ${epicName}`);
917
+ }
918
+
919
+ // Read epic content
920
+ const content = await fs.readFile(epicFilePath, 'utf8');
921
+ const metadata = this.parseFrontmatter(content);
922
+
923
+ // Extract overview and tasks from content
924
+ const overviewMatch = content.match(/## Overview\s+([\s\S]*?)(?=\n## |$)/);
925
+ const overview = overviewMatch ? overviewMatch[1].trim() : '';
926
+
927
+ const tasksMatch = content.match(/## Tasks?\s+([\s\S]*?)(?=\n## |$)/);
928
+ const tasksContent = tasksMatch ? tasksMatch[1].trim() : '';
929
+
930
+ // Parse tasks from markdown checkboxes
931
+ const tasks = this._parseTasksFromContent(tasksContent);
932
+
933
+ const epicData = {
934
+ name: epicName,
935
+ title: metadata?.name || epicName,
936
+ overview,
937
+ tasks,
938
+ priority: metadata?.priority || 'P2',
939
+ status: metadata?.status || 'planning',
940
+ updated: metadata?.updated
941
+ };
942
+
943
+ const syncMap = await this._loadEpicSyncMap();
944
+ const githubNumber = syncMap['epic-to-github'][epicName];
945
+
946
+ let result;
947
+ let action;
948
+
949
+ if (githubNumber) {
950
+ // Check for conflicts if enabled
951
+ if (options.detectConflicts) {
952
+ const githubIssue = await this.provider.getIssue(githubNumber);
953
+ const conflict = this._detectEpicConflict(epicData, githubIssue);
954
+
955
+ if (conflict.hasConflict && conflict.remoteNewer) {
956
+ return {
957
+ success: false,
958
+ epicName,
959
+ githubNumber: String(githubNumber),
960
+ conflict
961
+ };
962
+ }
963
+ }
964
+
965
+ // Update existing GitHub epic
966
+ result = await this.updateGitHubEpic(githubNumber, epicData);
967
+ action = 'updated';
968
+ } else {
969
+ // Create new GitHub epic
970
+ result = await this.createGitHubEpic(epicData);
971
+ action = 'created';
972
+ }
973
+
974
+ // Update sync-map
975
+ await this._updateEpicSyncMap(epicName, String(result.number));
976
+
977
+ return {
978
+ success: true,
979
+ epicName,
980
+ githubNumber: String(result.number),
981
+ action
982
+ };
983
+ }
984
+
985
+ /**
986
+ * Sync GitHub epic to local (enhanced pull with merge)
987
+ *
988
+ * @param {number|string} githubNumber - GitHub issue number
989
+ * @param {Object} [options={}] - Sync options
990
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
991
+ * @returns {Promise<Object>} Result: { success, epicName, githubNumber, action, conflict? }
992
+ * @throws {Error} If issue is not an epic
993
+ */
994
+ async syncEpicFromGitHub(githubNumber, options = {}) {
995
+ const fs = require('fs-extra');
996
+ const path = require('path');
997
+
998
+ const githubIssue = await this.provider.getIssue(githubNumber);
999
+
1000
+ // Verify it's an epic
1001
+ const labels = githubIssue.labels || [];
1002
+ const isEpic = labels.some(label => {
1003
+ const name = typeof label === 'string' ? label : label.name;
1004
+ return name === 'epic';
1005
+ });
1006
+
1007
+ if (!isEpic) {
1008
+ throw new Error(`GitHub issue #${githubNumber} is not an epic`);
1009
+ }
1010
+
1011
+ const syncMap = await this._loadEpicSyncMap();
1012
+ let epicName = syncMap['github-to-epic'][String(githubNumber)];
1013
+
1014
+ let action;
1015
+
1016
+ if (epicName) {
1017
+ // Check for conflicts if enabled
1018
+ if (options.detectConflicts) {
1019
+ const epicFilePath = this.getEpicFilePath(epicName);
1020
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1021
+ const localMetadata = this.parseFrontmatter(localContent);
1022
+
1023
+ const conflict = this._detectEpicConflict(
1024
+ { updated: localMetadata?.updated },
1025
+ githubIssue
1026
+ );
1027
+
1028
+ if (conflict.hasConflict && conflict.localNewer) {
1029
+ return {
1030
+ success: false,
1031
+ epicName,
1032
+ githubNumber: String(githubNumber),
1033
+ conflict
1034
+ };
1035
+ }
1036
+ }
1037
+
1038
+ action = 'updated';
1039
+ } else {
1040
+ // Generate epic name from title
1041
+ epicName = this._generateEpicNameFromTitle(githubIssue.title);
1042
+ action = 'created';
1043
+ }
1044
+
1045
+ // Parse GitHub epic
1046
+ const epicData = this._parseGitHubEpic(githubIssue);
1047
+
1048
+ // Build epic content
1049
+ const epicContent = this._buildEpicContent(epicData, githubNumber);
1050
+
1051
+ // Ensure epic directory exists
1052
+ const epicPath = this.getEpicPath(epicName);
1053
+ await fs.ensureDir(epicPath);
1054
+
1055
+ // Write epic.md
1056
+ const epicFilePath = this.getEpicFilePath(epicName);
1057
+ await fs.writeFile(epicFilePath, epicContent);
1058
+
1059
+ // Update sync-map
1060
+ await this._updateEpicSyncMap(epicName, String(githubNumber));
1061
+
1062
+ return {
1063
+ success: true,
1064
+ epicName,
1065
+ githubNumber: String(githubNumber),
1066
+ action
1067
+ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Bidirectional sync - sync in the direction of newer changes
1072
+ *
1073
+ * @param {string} epicName - Local epic name
1074
+ * @param {Object} [options={}] - Sync options
1075
+ * @param {string} [options.conflictStrategy='detect'] - How to handle conflicts
1076
+ * @returns {Promise<Object>} Result: { success, direction, conflict? }
1077
+ */
1078
+ async syncEpicBidirectional(epicName, options = {}) {
1079
+ const syncMap = await this._loadEpicSyncMap();
1080
+ const githubNumber = syncMap['epic-to-github'][epicName];
1081
+
1082
+ if (!githubNumber) {
1083
+ // No GitHub mapping, push to GitHub
1084
+ const result = await this.syncEpicToGitHub(epicName);
1085
+ return { ...result, direction: 'to-github' };
1086
+ }
1087
+
1088
+ // Get both versions
1089
+ const fs = require('fs-extra');
1090
+ const epicFilePath = this.getEpicFilePath(epicName);
1091
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1092
+ const localMetadata = this.parseFrontmatter(localContent);
1093
+
1094
+ const githubIssue = await this.provider.getIssue(githubNumber);
1095
+
1096
+ const conflict = this._detectEpicConflict(
1097
+ { updated: localMetadata?.updated },
1098
+ githubIssue
1099
+ );
1100
+
1101
+ if (conflict.hasConflict) {
1102
+ if (options.conflictStrategy === 'detect') {
1103
+ return {
1104
+ success: false,
1105
+ direction: 'conflict',
1106
+ conflict
1107
+ };
1108
+ }
1109
+
1110
+ // Auto-resolve based on timestamps
1111
+ if (conflict.localNewer) {
1112
+ const result = await this.syncEpicToGitHub(epicName);
1113
+ return { ...result, direction: 'to-github' };
1114
+ } else if (conflict.remoteNewer) {
1115
+ const result = await this.syncEpicFromGitHub(githubNumber);
1116
+ return { ...result, direction: 'from-github' };
1117
+ }
1118
+ }
1119
+
1120
+ // No conflict - sync local to GitHub
1121
+ const result = await this.syncEpicToGitHub(epicName);
1122
+ return { ...result, direction: 'to-github' };
1123
+ }
1124
+
1125
+ /**
1126
+ * Create new GitHub epic from local data
1127
+ *
1128
+ * @param {Object} epicData - Epic data
1129
+ * @returns {Promise<Object>} Created GitHub issue
1130
+ */
1131
+ async createGitHubEpic(epicData) {
1132
+ const labels = ['epic'];
1133
+
1134
+ if (epicData.priority) {
1135
+ labels.push(`priority:${epicData.priority}`);
1136
+ }
1137
+
1138
+ const body = this._formatEpicForGitHub(epicData);
1139
+
1140
+ const githubData = {
1141
+ title: `Epic: ${epicData.title}`,
1142
+ body,
1143
+ labels,
1144
+ state: 'open'
1145
+ };
1146
+
1147
+ const result = await this.provider.createIssue(githubData);
1148
+
1149
+ // Update sync-map
1150
+ if (epicData.name) {
1151
+ await this._updateEpicSyncMap(epicData.name, String(result.number));
1152
+ }
1153
+
1154
+ return result;
1155
+ }
1156
+
1157
+ /**
1158
+ * Update existing GitHub epic with local data
1159
+ *
1160
+ * @param {number|string} githubNumber - GitHub issue number
1161
+ * @param {Object} epicData - Epic data
1162
+ * @returns {Promise<Object>} Updated GitHub issue
1163
+ */
1164
+ async updateGitHubEpic(githubNumber, epicData) {
1165
+ const updateData = {};
1166
+
1167
+ if (epicData.title) {
1168
+ updateData.title = `Epic: ${epicData.title}`;
1169
+ }
1170
+
1171
+ if (epicData.overview || epicData.tasks) {
1172
+ updateData.body = this._formatEpicForGitHub(epicData);
1173
+ }
1174
+
1175
+ if (epicData.priority) {
1176
+ updateData.labels = ['epic', `priority:${epicData.priority}`];
1177
+ }
1178
+
1179
+ return await this.provider.updateIssue(githubNumber, updateData);
1180
+ }
1181
+
1182
+ /**
1183
+ * Get sync status for an epic
1184
+ *
1185
+ * @param {string} epicName - Local epic name
1186
+ * @returns {Promise<Object>} Status: { synced, epicName, githubNumber, lastSync, status }
1187
+ */
1188
+ async getEpicSyncStatus(epicName) {
1189
+ const syncMap = await this._loadEpicSyncMap();
1190
+ const githubNumber = syncMap['epic-to-github'][epicName];
1191
+
1192
+ if (!githubNumber) {
1193
+ return {
1194
+ synced: false,
1195
+ epicName,
1196
+ githubNumber: null,
1197
+ status: 'not-synced'
1198
+ };
1199
+ }
1200
+
1201
+ const metadata = syncMap.metadata[epicName] || {};
1202
+
1203
+ // Check if out of sync
1204
+ try {
1205
+ const fs = require('fs-extra');
1206
+ const epicFilePath = this.getEpicFilePath(epicName);
1207
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1208
+ const localMetadata = this.parseFrontmatter(localContent);
1209
+
1210
+ const githubIssue = await this.provider.getIssue(githubNumber);
1211
+
1212
+ const localTime = new Date(localMetadata?.updated || localMetadata?.created || 0);
1213
+ const githubTime = new Date(githubIssue.updated_at || githubIssue.created_at || 0);
1214
+ const lastSyncTime = new Date(metadata.lastSync || 0);
1215
+
1216
+ const isOutOfSync = localTime > lastSyncTime || githubTime > lastSyncTime;
1217
+
1218
+ return {
1219
+ synced: !isOutOfSync,
1220
+ epicName,
1221
+ githubNumber: String(githubNumber),
1222
+ lastSync: metadata.lastSync,
1223
+ status: isOutOfSync ? 'out-of-sync' : 'synced'
1224
+ };
1225
+ } catch (error) {
1226
+ // If error checking, assume synced
1227
+ return {
1228
+ synced: true,
1229
+ epicName,
1230
+ githubNumber: String(githubNumber),
1231
+ lastSync: metadata.lastSync,
1232
+ status: 'synced'
1233
+ };
1234
+ }
1235
+ }
1236
+
1237
+ // ==========================================
1238
+ // PRIVATE HELPER METHODS FOR EPIC SYNC
1239
+ // ==========================================
1240
+
1241
+ /**
1242
+ * Load epic-sync-map from file
1243
+ * @private
1244
+ */
1245
+ async _loadEpicSyncMap() {
1246
+ const fs = require('fs-extra');
1247
+ const path = require('path');
1248
+ const syncMapPath = path.join(process.cwd(), '.claude/epic-sync-map.json');
1249
+
1250
+ if (await fs.pathExists(syncMapPath)) {
1251
+ return await fs.readJSON(syncMapPath);
1252
+ }
1253
+
1254
+ return {
1255
+ 'epic-to-github': {},
1256
+ 'github-to-epic': {},
1257
+ 'metadata': {}
1258
+ };
1259
+ }
1260
+
1261
+ /**
1262
+ * Save epic-sync-map to file
1263
+ * @private
1264
+ */
1265
+ async _saveEpicSyncMap(syncMap) {
1266
+ const fs = require('fs-extra');
1267
+ const path = require('path');
1268
+ const syncMapPath = path.join(process.cwd(), '.claude/epic-sync-map.json');
1269
+ await fs.writeJSON(syncMapPath, syncMap, { spaces: 2 });
1270
+ }
1271
+
1272
+ /**
1273
+ * Update epic-sync-map with new mapping
1274
+ * @private
1275
+ */
1276
+ async _updateEpicSyncMap(epicName, githubNumber) {
1277
+ const syncMap = await this._loadEpicSyncMap();
1278
+
1279
+ syncMap['epic-to-github'][epicName] = String(githubNumber);
1280
+ syncMap['github-to-epic'][String(githubNumber)] = epicName;
1281
+ syncMap['metadata'][epicName] = {
1282
+ lastSync: new Date().toISOString(),
1283
+ githubNumber: String(githubNumber)
1284
+ };
1285
+
1286
+ await this._saveEpicSyncMap(syncMap);
1287
+ }
1288
+
1289
+ /**
1290
+ * Format epic data as GitHub issue body
1291
+ * @private
1292
+ */
1293
+ _formatEpicForGitHub(epicData) {
1294
+ let body = '';
1295
+
1296
+ if (epicData.overview) {
1297
+ body += `## Overview\n${epicData.overview}\n\n`;
1298
+ }
1299
+
1300
+ body += '## Task Breakdown\n';
1301
+
1302
+ if (epicData.tasks && epicData.tasks.length > 0) {
1303
+ epicData.tasks.forEach(task => {
1304
+ const checkbox = task.status === 'closed' ? '[x]' : '[ ]';
1305
+ body += `- ${checkbox} ${task.title}\n`;
1306
+ });
1307
+ } else {
1308
+ body += 'No tasks defined yet.\n';
1309
+ }
1310
+
1311
+ return body;
1312
+ }
1313
+
1314
+ /**
1315
+ * Parse GitHub issue to epic format
1316
+ * @private
1317
+ */
1318
+ _parseGitHubEpic(githubIssue) {
1319
+ // Extract epic name from title
1320
+ const titleMatch = githubIssue.title.match(/Epic:\s*(.+)/i);
1321
+ const title = titleMatch ? titleMatch[1].trim() : githubIssue.title;
1322
+ const name = this._generateEpicNameFromTitle(title);
1323
+
1324
+ // Extract overview
1325
+ const overviewMatch = githubIssue.body?.match(/## Overview\s+([\s\S]*?)(?=\n## |$)/);
1326
+ const overview = overviewMatch ? overviewMatch[1].trim() : '';
1327
+
1328
+ // Extract priority from labels
1329
+ const labels = githubIssue.labels || [];
1330
+ let priority = 'P2';
1331
+ labels.forEach(label => {
1332
+ const labelName = typeof label === 'string' ? label : label.name;
1333
+ const priorityMatch = labelName.match(/priority:(P\d)/i);
1334
+ if (priorityMatch) {
1335
+ priority = priorityMatch[1];
1336
+ }
1337
+ });
1338
+
1339
+ // Extract tasks from checkboxes
1340
+ const tasksMatch = githubIssue.body?.match(/## Task Breakdown\s+([\s\S]*?)(?=\n## |$)/);
1341
+ const tasksContent = tasksMatch ? tasksMatch[1].trim() : '';
1342
+ const tasks = this._parseTasksFromContent(tasksContent);
1343
+
1344
+ return {
1345
+ name,
1346
+ title,
1347
+ overview,
1348
+ priority,
1349
+ tasks,
1350
+ created: githubIssue.created_at,
1351
+ updated: githubIssue.updated_at
1352
+ };
1353
+ }
1354
+
1355
+ /**
1356
+ * Parse tasks from markdown checkbox content
1357
+ * @private
1358
+ */
1359
+ _parseTasksFromContent(content) {
1360
+ const tasks = [];
1361
+ const lines = content.split('\n');
1362
+
1363
+ for (const line of lines) {
1364
+ const checkboxMatch = line.match(/^-\s+\[([ x])\]\s+(.+)$/i);
1365
+ if (checkboxMatch) {
1366
+ const status = checkboxMatch[1].toLowerCase() === 'x' ? 'closed' : 'open';
1367
+ const title = checkboxMatch[2].trim();
1368
+ tasks.push({ title, status });
1369
+ }
1370
+ }
1371
+
1372
+ return tasks;
1373
+ }
1374
+
1375
+ /**
1376
+ * Generate epic name from title
1377
+ * @private
1378
+ */
1379
+ _generateEpicNameFromTitle(title) {
1380
+ return title
1381
+ .toLowerCase()
1382
+ .replace(/[^a-z0-9\s-]/g, '')
1383
+ .replace(/\s+/g, '-')
1384
+ .replace(/-+/g, '-')
1385
+ .replace(/^-|-$/g, '');
1386
+ }
1387
+
1388
+ /**
1389
+ * Build epic content from parsed data
1390
+ * @private
1391
+ */
1392
+ _buildEpicContent(epicData, githubNumber) {
1393
+ const frontmatter = `---
1394
+ name: ${epicData.name}
1395
+ status: planning
1396
+ priority: ${epicData.priority}
1397
+ created: ${epicData.created}
1398
+ updated: ${epicData.updated}
1399
+ progress: 0%
1400
+ github: https://github.com/owner/repo/issues/${githubNumber}
1401
+ ---
1402
+
1403
+ # Epic: ${epicData.title}
1404
+
1405
+ ## Overview
1406
+ ${epicData.overview || 'No overview provided.'}
1407
+
1408
+ ## Tasks
1409
+ ${epicData.tasks.map(task => {
1410
+ const checkbox = task.status === 'closed' ? '[x]' : '[ ]';
1411
+ return `- ${checkbox} ${task.title}`;
1412
+ }).join('\n')}
1413
+ `;
1414
+
1415
+ return frontmatter;
1416
+ }
1417
+
1418
+ /**
1419
+ * Detect conflict between local epic and GitHub issue
1420
+ * @private
1421
+ */
1422
+ _detectEpicConflict(localEpic, githubIssue) {
1423
+ const localTime = new Date(localEpic.updated || localEpic.created || 0);
1424
+ const githubTime = new Date(githubIssue.updated_at || githubIssue.created_at || 0);
1425
+
1426
+ const hasConflict = localTime.getTime() !== githubTime.getTime();
1427
+ const localNewer = localTime > githubTime;
1428
+ const remoteNewer = githubTime > localTime;
1429
+
1430
+ return {
1431
+ hasConflict,
1432
+ localNewer,
1433
+ remoteNewer,
1434
+ conflictFields: []
1435
+ };
1436
+ }
1437
+
1438
+ // ==========================================
1439
+ // 8. AZURE DEVOPS EPIC SYNC METHODS (6 METHODS)
1440
+ // ==========================================
1441
+
1442
+ /**
1443
+ * Sync local epic to Azure DevOps (enhanced push with conflict detection)
1444
+ *
1445
+ * @param {string} epicName - Local epic name
1446
+ * @param {Object} [options={}] - Sync options
1447
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
1448
+ * @returns {Promise<Object>} Result: { success, epicName, workItemId, action, conflict? }
1449
+ * @throws {Error} If no provider configured or epic not found
1450
+ */
1451
+ async syncEpicToAzure(epicName, options = {}) {
1452
+ if (!this.provider) {
1453
+ throw new Error('No provider configured for Azure sync');
1454
+ }
1455
+
1456
+ const fs = require('fs-extra');
1457
+ const epicFilePath = this.getEpicFilePath(epicName);
1458
+
1459
+ // Check if epic exists
1460
+ const exists = await fs.pathExists(epicFilePath);
1461
+ if (!exists) {
1462
+ throw new Error(`Epic not found: ${epicName}`);
1463
+ }
1464
+
1465
+ // Read epic content
1466
+ const content = await fs.readFile(epicFilePath, 'utf8');
1467
+ const metadata = this.parseFrontmatter(content);
1468
+
1469
+ // Extract overview and tasks from content
1470
+ const overviewMatch = content.match(/## Overview\s+([\s\S]*?)(?=\n## |$)/);
1471
+ const overview = overviewMatch ? overviewMatch[1].trim() : '';
1472
+
1473
+ const tasksMatch = content.match(/## Tasks?\s+([\s\S]*?)(?=\n## |$)/);
1474
+ const tasksContent = tasksMatch ? tasksMatch[1].trim() : '';
1475
+
1476
+ // Parse tasks from markdown checkboxes
1477
+ const tasks = this._parseTasksFromContent(tasksContent);
1478
+
1479
+ const epicData = {
1480
+ name: epicName,
1481
+ title: metadata?.name || epicName,
1482
+ overview,
1483
+ tasks,
1484
+ priority: metadata?.priority || 'P2',
1485
+ status: metadata?.status || 'planning',
1486
+ updated: metadata?.updated
1487
+ };
1488
+
1489
+ const syncMap = await this._loadAzureEpicSyncMap();
1490
+ const workItemId = syncMap['epic-to-azure'][epicName];
1491
+
1492
+ let result;
1493
+ let action;
1494
+
1495
+ if (workItemId) {
1496
+ // Check for conflicts if enabled
1497
+ if (options.detectConflicts) {
1498
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1499
+ const conflict = this._detectAzureEpicConflict(epicData, azureWorkItem);
1500
+
1501
+ if (conflict.hasConflict && conflict.remoteNewer) {
1502
+ return {
1503
+ success: false,
1504
+ epicName,
1505
+ workItemId: String(workItemId),
1506
+ conflict
1507
+ };
1508
+ }
1509
+ }
1510
+
1511
+ // Update existing Azure epic
1512
+ result = await this.updateAzureEpic(workItemId, epicData);
1513
+ action = 'updated';
1514
+ } else {
1515
+ // Create new Azure epic
1516
+ result = await this.createAzureEpic(epicData);
1517
+ action = 'created';
1518
+ }
1519
+
1520
+ // Update sync-map
1521
+ await this._updateAzureEpicSyncMap(epicName, String(result.id));
1522
+
1523
+ return {
1524
+ success: true,
1525
+ epicName,
1526
+ workItemId: String(result.id),
1527
+ action
1528
+ };
1529
+ }
1530
+
1531
+ /**
1532
+ * Sync Azure DevOps epic to local (enhanced pull with merge)
1533
+ *
1534
+ * @param {number|string} workItemId - Azure work item ID
1535
+ * @param {Object} [options={}] - Sync options
1536
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
1537
+ * @returns {Promise<Object>} Result: { success, epicName, workItemId, action, conflict? }
1538
+ * @throws {Error} If work item is not an Epic
1539
+ */
1540
+ async syncEpicFromAzure(workItemId, options = {}) {
1541
+ const fs = require('fs-extra');
1542
+ const path = require('path');
1543
+
1544
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1545
+
1546
+ // Verify it's an Epic
1547
+ const workItemType = azureWorkItem.fields['System.WorkItemType'];
1548
+ if (workItemType !== 'Epic') {
1549
+ throw new Error(`Azure work item #${workItemId} is not an Epic (type: ${workItemType})`);
1550
+ }
1551
+
1552
+ const syncMap = await this._loadAzureEpicSyncMap();
1553
+ let epicName = syncMap['azure-to-epic'][String(workItemId)];
1554
+
1555
+ let action;
1556
+
1557
+ if (epicName) {
1558
+ // Check for conflicts if enabled
1559
+ if (options.detectConflicts) {
1560
+ const epicFilePath = this.getEpicFilePath(epicName);
1561
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1562
+ const localMetadata = this.parseFrontmatter(localContent);
1563
+
1564
+ const conflict = this._detectAzureEpicConflict(
1565
+ { updated: localMetadata?.updated },
1566
+ azureWorkItem
1567
+ );
1568
+
1569
+ if (conflict.hasConflict && conflict.localNewer) {
1570
+ return {
1571
+ success: false,
1572
+ epicName,
1573
+ workItemId: String(workItemId),
1574
+ conflict
1575
+ };
1576
+ }
1577
+ }
1578
+
1579
+ action = 'updated';
1580
+ } else {
1581
+ // Generate epic name from title
1582
+ const title = azureWorkItem.fields['System.Title'] || 'Untitled';
1583
+ epicName = this._generateEpicNameFromTitle(title);
1584
+ action = 'created';
1585
+ }
1586
+
1587
+ // Parse Azure epic
1588
+ const epicData = this._parseAzureEpic(azureWorkItem);
1589
+
1590
+ // Build epic content
1591
+ const epicContent = this._buildAzureEpicContent(epicData, workItemId);
1592
+
1593
+ // Ensure epic directory exists
1594
+ const epicPath = this.getEpicPath(epicName);
1595
+ await fs.ensureDir(epicPath);
1596
+
1597
+ // Write epic.md
1598
+ const epicFilePath = this.getEpicFilePath(epicName);
1599
+ await fs.writeFile(epicFilePath, epicContent);
1600
+
1601
+ // Update sync-map
1602
+ await this._updateAzureEpicSyncMap(epicName, String(workItemId));
1603
+
1604
+ return {
1605
+ success: true,
1606
+ epicName,
1607
+ workItemId: String(workItemId),
1608
+ action
1609
+ };
1610
+ }
1611
+
1612
+ /**
1613
+ * Bidirectional sync - sync in the direction of newer changes (Azure)
1614
+ *
1615
+ * @param {string} epicName - Local epic name
1616
+ * @param {Object} [options={}] - Sync options
1617
+ * @param {string} [options.conflictStrategy='detect'] - How to handle conflicts
1618
+ * @returns {Promise<Object>} Result: { success, direction, conflict? }
1619
+ */
1620
+ async syncEpicBidirectionalAzure(epicName, options = {}) {
1621
+ const syncMap = await this._loadAzureEpicSyncMap();
1622
+ const workItemId = syncMap['epic-to-azure'][epicName];
1623
+
1624
+ if (!workItemId) {
1625
+ // No Azure mapping, push to Azure
1626
+ const result = await this.syncEpicToAzure(epicName);
1627
+ return { ...result, direction: 'to-azure' };
1628
+ }
1629
+
1630
+ // Get both versions
1631
+ const fs = require('fs-extra');
1632
+ const epicFilePath = this.getEpicFilePath(epicName);
1633
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1634
+ const localMetadata = this.parseFrontmatter(localContent);
1635
+
1636
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1637
+
1638
+ const conflict = this._detectAzureEpicConflict(
1639
+ { updated: localMetadata?.updated },
1640
+ azureWorkItem
1641
+ );
1642
+
1643
+ if (conflict.hasConflict) {
1644
+ if (options.conflictStrategy === 'detect') {
1645
+ return {
1646
+ success: false,
1647
+ direction: 'conflict',
1648
+ conflict
1649
+ };
1650
+ }
1651
+
1652
+ // Auto-resolve based on timestamps
1653
+ if (conflict.localNewer) {
1654
+ const result = await this.syncEpicToAzure(epicName);
1655
+ return { ...result, direction: 'to-azure' };
1656
+ } else if (conflict.remoteNewer) {
1657
+ const result = await this.syncEpicFromAzure(workItemId);
1658
+ return { ...result, direction: 'from-azure' };
1659
+ }
1660
+ }
1661
+
1662
+ // No conflict - sync local to Azure
1663
+ const result = await this.syncEpicToAzure(epicName);
1664
+ return { ...result, direction: 'to-azure' };
1665
+ }
1666
+
1667
+ /**
1668
+ * Create new Azure epic work item from local data
1669
+ *
1670
+ * @param {Object} epicData - Epic data
1671
+ * @returns {Promise<Object>} Created Azure work item
1672
+ */
1673
+ async createAzureEpic(epicData) {
1674
+ const tags = epicData.priority ? `priority:${epicData.priority}` : '';
1675
+ const description = this._formatEpicForAzure(epicData);
1676
+
1677
+ const azureData = {
1678
+ title: epicData.title,
1679
+ description,
1680
+ tags,
1681
+ state: 'New'
1682
+ };
1683
+
1684
+ const result = await this.provider.createWorkItem('Epic', azureData);
1685
+
1686
+ // Update sync-map if epic name exists
1687
+ if (epicData.name) {
1688
+ await this._updateAzureEpicSyncMap(epicData.name, String(result.id));
1689
+ }
1690
+
1691
+ return result;
1692
+ }
1693
+
1694
+ /**
1695
+ * Update existing Azure epic with local data
1696
+ *
1697
+ * @param {number|string} workItemId - Azure work item ID
1698
+ * @param {Object} epicData - Epic data
1699
+ * @returns {Promise<Object>} Updated Azure work item
1700
+ */
1701
+ async updateAzureEpic(workItemId, epicData) {
1702
+ const updateData = {};
1703
+
1704
+ if (epicData.title) {
1705
+ updateData.title = epicData.title;
1706
+ }
1707
+
1708
+ if (epicData.status) {
1709
+ updateData.state = this._mapEpicStatusToAzure(epicData.status);
1710
+ }
1711
+
1712
+ if (epicData.overview || epicData.tasks) {
1713
+ updateData.description = this._formatEpicForAzure(epicData);
1714
+ }
1715
+
1716
+ if (epicData.priority) {
1717
+ updateData.tags = `priority:${epicData.priority}`;
1718
+ }
1719
+
1720
+ return await this.provider.updateWorkItem(workItemId, updateData);
1721
+ }
1722
+
1723
+ /**
1724
+ * Get sync status for an epic (Azure)
1725
+ *
1726
+ * @param {string} epicName - Local epic name
1727
+ * @returns {Promise<Object>} Status: { synced, epicName, workItemId, lastSync, status }
1728
+ */
1729
+ async getEpicAzureSyncStatus(epicName) {
1730
+ const syncMap = await this._loadAzureEpicSyncMap();
1731
+ const workItemId = syncMap['epic-to-azure'][epicName];
1732
+
1733
+ if (!workItemId) {
1734
+ return {
1735
+ synced: false,
1736
+ epicName,
1737
+ workItemId: null,
1738
+ status: 'not-synced'
1739
+ };
1740
+ }
1741
+
1742
+ const metadata = syncMap.metadata[epicName] || {};
1743
+
1744
+ // Check if out of sync
1745
+ try {
1746
+ const fs = require('fs-extra');
1747
+ const epicFilePath = this.getEpicFilePath(epicName);
1748
+ const localContent = await fs.readFile(epicFilePath, 'utf8');
1749
+ const localMetadata = this.parseFrontmatter(localContent);
1750
+
1751
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1752
+
1753
+ const localTime = new Date(localMetadata?.updated || localMetadata?.created || 0);
1754
+ const azureTime = new Date(
1755
+ azureWorkItem.fields['System.ChangedDate'] ||
1756
+ azureWorkItem.fields['System.CreatedDate'] ||
1757
+ 0
1758
+ );
1759
+ const lastSyncTime = new Date(metadata.lastSync || 0);
1760
+
1761
+ const isOutOfSync = localTime > lastSyncTime || azureTime > lastSyncTime;
1762
+
1763
+ return {
1764
+ synced: !isOutOfSync,
1765
+ epicName,
1766
+ workItemId: String(workItemId),
1767
+ lastSync: metadata.lastSync,
1768
+ status: isOutOfSync ? 'out-of-sync' : 'synced'
1769
+ };
1770
+ } catch (error) {
1771
+ // If error checking, assume synced
1772
+ return {
1773
+ synced: true,
1774
+ epicName,
1775
+ workItemId: String(workItemId),
1776
+ lastSync: metadata.lastSync,
1777
+ status: 'synced'
1778
+ };
1779
+ }
1780
+ }
1781
+
1782
+ // ==========================================
1783
+ // PRIVATE HELPER METHODS FOR AZURE EPIC SYNC
1784
+ // ==========================================
1785
+
1786
+ /**
1787
+ * Load epic-azure-sync-map from file
1788
+ * @private
1789
+ */
1790
+ async _loadAzureEpicSyncMap() {
1791
+ const fs = require('fs-extra');
1792
+ const path = require('path');
1793
+ const syncMapPath = path.join(process.cwd(), '.claude/epic-azure-sync-map.json');
1794
+
1795
+ if (await fs.pathExists(syncMapPath)) {
1796
+ return await fs.readJSON(syncMapPath);
1797
+ }
1798
+
1799
+ return {
1800
+ 'epic-to-azure': {},
1801
+ 'azure-to-epic': {},
1802
+ 'metadata': {}
1803
+ };
1804
+ }
1805
+
1806
+ /**
1807
+ * Save epic-azure-sync-map to file
1808
+ * @private
1809
+ */
1810
+ async _saveAzureEpicSyncMap(syncMap) {
1811
+ const fs = require('fs-extra');
1812
+ const path = require('path');
1813
+ const syncMapPath = path.join(process.cwd(), '.claude/epic-azure-sync-map.json');
1814
+ await fs.writeJSON(syncMapPath, syncMap, { spaces: 2 });
1815
+ }
1816
+
1817
+ /**
1818
+ * Update epic-azure-sync-map with new mapping
1819
+ * @private
1820
+ */
1821
+ async _updateAzureEpicSyncMap(epicName, workItemId) {
1822
+ const syncMap = await this._loadAzureEpicSyncMap();
1823
+
1824
+ syncMap['epic-to-azure'][epicName] = String(workItemId);
1825
+ syncMap['azure-to-epic'][String(workItemId)] = epicName;
1826
+ syncMap['metadata'][epicName] = {
1827
+ lastSync: new Date().toISOString(),
1828
+ workItemId: String(workItemId),
1829
+ workItemType: 'Epic'
1830
+ };
1831
+
1832
+ await this._saveAzureEpicSyncMap(syncMap);
1833
+ }
1834
+
1835
+ /**
1836
+ * Format epic data as Azure work item description
1837
+ * @private
1838
+ */
1839
+ _formatEpicForAzure(epicData) {
1840
+ let description = '';
1841
+
1842
+ if (epicData.overview) {
1843
+ description += `${epicData.overview}\n\n`;
1844
+ }
1845
+
1846
+ description += '## Tasks\n';
1847
+
1848
+ if (epicData.tasks && epicData.tasks.length > 0) {
1849
+ epicData.tasks.forEach(task => {
1850
+ const checkbox = task.status === 'closed' ? '[x]' : '[ ]';
1851
+ description += `- ${checkbox} ${task.title}\n`;
1852
+ });
1853
+ } else {
1854
+ description += 'No tasks defined yet.\n';
1855
+ }
1856
+
1857
+ return description.trim();
1858
+ }
1859
+
1860
+ /**
1861
+ * Parse Azure work item to epic format
1862
+ * @private
1863
+ */
1864
+ _parseAzureEpic(azureWorkItem) {
1865
+ const title = azureWorkItem.fields['System.Title'] || '';
1866
+ const name = this._generateEpicNameFromTitle(title);
1867
+ const description = azureWorkItem.fields['System.Description'] || '';
1868
+
1869
+ // Extract overview (text before ## Tasks)
1870
+ const overviewMatch = description.match(/^([\s\S]*?)(?=## Tasks|$)/);
1871
+ const overview = overviewMatch ? overviewMatch[1].trim() : '';
1872
+
1873
+ // Extract priority from tags
1874
+ const tags = azureWorkItem.fields['System.Tags'] || '';
1875
+ const priorityMatch = tags.match(/priority:(P\d)/i);
1876
+ const priority = priorityMatch ? priorityMatch[1] : 'P2';
1877
+
1878
+ // Extract tasks from checkboxes
1879
+ const tasksMatch = description.match(/## Tasks\s+([\s\S]*?)$/);
1880
+ const tasksContent = tasksMatch ? tasksMatch[1].trim() : '';
1881
+ const tasks = this._parseTasksFromContent(tasksContent);
1882
+
1883
+ return {
1884
+ name,
1885
+ title,
1886
+ overview,
1887
+ priority,
1888
+ tasks,
1889
+ created: azureWorkItem.fields['System.CreatedDate'],
1890
+ updated: azureWorkItem.fields['System.ChangedDate']
1891
+ };
1892
+ }
1893
+
1894
+ /**
1895
+ * Detect conflict between local epic and Azure work item
1896
+ * @private
1897
+ */
1898
+ _detectAzureEpicConflict(localEpic, azureWorkItem) {
1899
+ const localTime = new Date(localEpic.updated || localEpic.created || 0);
1900
+ const azureTime = new Date(
1901
+ azureWorkItem.fields['System.ChangedDate'] ||
1902
+ azureWorkItem.fields['System.CreatedDate'] ||
1903
+ 0
1904
+ );
1905
+
1906
+ const hasConflict = localTime.getTime() !== azureTime.getTime();
1907
+ const localNewer = localTime > azureTime;
1908
+ const remoteNewer = azureTime > localTime;
1909
+
1910
+ return {
1911
+ hasConflict,
1912
+ localNewer,
1913
+ remoteNewer,
1914
+ conflictFields: []
1915
+ };
1916
+ }
1917
+
1918
+ /**
1919
+ * Map epic status to Azure DevOps state
1920
+ * @private
1921
+ */
1922
+ _mapEpicStatusToAzure(status) {
1923
+ if (!status) return 'New';
1924
+
1925
+ const lowerStatus = status.toLowerCase();
1926
+ const statusMap = {
1927
+ 'backlog': 'New',
1928
+ 'planning': 'New',
1929
+ 'in-progress': 'Active',
1930
+ 'in_progress': 'Active',
1931
+ 'active': 'Active',
1932
+ 'done': 'Resolved',
1933
+ 'completed': 'Resolved',
1934
+ 'closed': 'Closed'
1935
+ };
1936
+
1937
+ return statusMap[lowerStatus] || 'New';
1938
+ }
1939
+
1940
+ /**
1941
+ * Build Azure epic content from parsed data
1942
+ * @private
1943
+ */
1944
+ _buildAzureEpicContent(epicData, workItemId) {
1945
+ const frontmatter = `---
1946
+ name: ${epicData.name}
1947
+ status: planning
1948
+ priority: ${epicData.priority}
1949
+ created: ${epicData.created}
1950
+ updated: ${epicData.updated}
1951
+ progress: 0%
1952
+ azure_work_item_id: ${workItemId}
1953
+ work_item_type: Epic
1954
+ ---
1955
+
1956
+ # Epic: ${epicData.title}
1957
+
1958
+ ## Overview
1959
+ ${epicData.overview || 'No overview provided.'}
1960
+
1961
+ ## Tasks
1962
+ ${epicData.tasks.map(task => {
1963
+ const checkbox = task.status === 'closed' ? '[x]' : '[ ]';
1964
+ return `- ${checkbox} ${task.title}`;
1965
+ }).join('\n')}
1966
+ `;
1967
+
1968
+ return frontmatter;
1969
+ }
881
1970
  }
882
1971
 
883
1972
  module.exports = EpicService;