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.
- package/README.md +191 -48
- package/autopm/.claude/.env +158 -0
- package/autopm/.claude/settings.local.json +9 -0
- package/bin/autopm.js +9 -2
- package/bin/commands/epic.js +23 -3
- package/lib/cli/commands/issue.js +360 -20
- package/lib/providers/AzureDevOpsProvider.js +575 -0
- package/lib/providers/GitHubProvider.js +475 -0
- package/lib/services/EpicService.js +1092 -3
- package/lib/services/IssueService.js +991 -0
- package/package.json +6 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EpicService - Epic Management Service
|
|
3
3
|
*
|
|
4
|
-
* Pure service layer for epic operations
|
|
5
|
-
* Follows 3-layer architecture: Service (logic) ->
|
|
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
|
|
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;
|