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.
@@ -31,12 +31,24 @@
31
31
  * - getIssuePath: Get file path for issue
32
32
  * - formatIssueDuration: Format time duration
33
33
  *
34
+ * 6. GitHub Sync Methods (8 methods):
35
+ * - syncToGitHub: Enhanced push with conflict detection
36
+ * - syncFromGitHub: Enhanced pull with merge
37
+ * - syncBidirectional: Full bidirectional sync
38
+ * - createGitHubIssue: Create new issue on GitHub
39
+ * - updateGitHubIssue: Update existing GitHub issue
40
+ * - detectConflict: Detect sync conflicts
41
+ * - resolveConflict: Resolve sync conflict with strategy
42
+ * - getSyncStatus: Get sync status for issue
43
+ *
34
44
  * Documentation Queries:
35
45
  * - GitHub Issues API v3 best practices (2025)
36
46
  * - Azure DevOps work items REST API patterns
37
47
  * - Agile issue tracking workflow best practices
38
48
  * - mcp://context7/project-management/issue-tracking - Issue lifecycle management
39
49
  * - mcp://context7/markdown/frontmatter - YAML frontmatter patterns
50
+ * - mcp://context7/conflict-resolution/sync - Conflict resolution strategies
51
+ * - mcp://context7/github/sync-patterns - GitHub synchronization patterns
40
52
  */
41
53
 
42
54
  class IssueService {
@@ -586,6 +598,985 @@ ${issueData.description || ''}
586
598
  return `${minutes} minute${minutes > 1 ? 's' : ''}`;
587
599
  }
588
600
  }
601
+
602
+ // ==========================================
603
+ // 6. GITHUB SYNC METHODS (8 NEW METHODS)
604
+ // ==========================================
605
+
606
+ /**
607
+ * Sync local issue to GitHub (enhanced push with conflict detection)
608
+ *
609
+ * @param {number|string} issueNumber - Local issue number
610
+ * @param {Object} [options={}] - Sync options
611
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
612
+ * @returns {Promise<Object>} Result: { success, issueNumber, githubNumber, action, conflict? }
613
+ */
614
+ async syncToGitHub(issueNumber, options = {}) {
615
+ const syncMap = await this._loadSyncMap();
616
+ const localIssue = await this.getLocalIssue(issueNumber);
617
+ const githubNumber = syncMap['local-to-github'][String(issueNumber)];
618
+
619
+ let result;
620
+ let action;
621
+
622
+ if (githubNumber) {
623
+ // Check for conflicts if enabled
624
+ if (options.detectConflicts) {
625
+ const githubIssue = await this.provider.getIssue(githubNumber);
626
+ const conflict = this.detectConflict(localIssue, githubIssue);
627
+
628
+ if (conflict.hasConflict && conflict.remoteNewer) {
629
+ return {
630
+ success: false,
631
+ issueNumber: String(issueNumber),
632
+ githubNumber: String(githubNumber),
633
+ conflict
634
+ };
635
+ }
636
+ }
637
+
638
+ // Update existing GitHub issue
639
+ result = await this.updateGitHubIssue(githubNumber, localIssue);
640
+ action = 'updated';
641
+ } else {
642
+ // Create new GitHub issue
643
+ result = await this.createGitHubIssue(localIssue);
644
+ action = 'created';
645
+ }
646
+
647
+ // Update sync-map
648
+ await this._updateSyncMap(String(issueNumber), String(result.number));
649
+
650
+ return {
651
+ success: true,
652
+ issueNumber: String(issueNumber),
653
+ githubNumber: String(result.number),
654
+ action
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Sync GitHub issue to local (enhanced pull with merge)
660
+ *
661
+ * @param {number|string} githubNumber - GitHub issue number
662
+ * @param {Object} [options={}] - Sync options
663
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
664
+ * @param {string} [options.conflictStrategy='newest'] - Conflict resolution strategy
665
+ * @returns {Promise<Object>} Result: { success, localNumber, githubNumber, action, conflict? }
666
+ */
667
+ async syncFromGitHub(githubNumber, options = {}) {
668
+ const fs = require('fs-extra');
669
+
670
+ const githubIssue = await this.provider.getIssue(githubNumber);
671
+ const syncMap = await this._loadSyncMap();
672
+ const localNumber = syncMap['github-to-local'][String(githubNumber)];
673
+
674
+ let action;
675
+ let finalLocalNumber = localNumber;
676
+
677
+ if (localNumber) {
678
+ // Check for conflicts if enabled
679
+ if (options.detectConflicts) {
680
+ const localIssue = await this.getLocalIssue(localNumber);
681
+ const conflict = this.detectConflict(localIssue, githubIssue);
682
+
683
+ if (conflict.hasConflict && conflict.localNewer) {
684
+ return {
685
+ success: false,
686
+ localNumber: String(localNumber),
687
+ githubNumber: String(githubNumber),
688
+ conflict
689
+ };
690
+ }
691
+ }
692
+
693
+ // Update existing local issue
694
+ action = 'updated';
695
+ } else {
696
+ // Create new local issue - find next available number
697
+ const issues = await this.listIssues();
698
+ const maxNumber = issues.reduce((max, issue) => {
699
+ const num = parseInt(issue.id || '0');
700
+ return num > max ? num : max;
701
+ }, 0);
702
+ finalLocalNumber = String(maxNumber + 1);
703
+ action = 'created';
704
+ }
705
+
706
+ // Build issue content with frontmatter
707
+ const labels = githubIssue.labels ? githubIssue.labels.map(l => l.name || l).join(', ') : '';
708
+ const assignee = githubIssue.assignees && githubIssue.assignees.length > 0
709
+ ? githubIssue.assignees[0].login || githubIssue.assignees[0]
710
+ : '';
711
+
712
+ const frontmatter = `---
713
+ id: ${finalLocalNumber}
714
+ title: ${githubIssue.title}
715
+ status: ${githubIssue.state === 'closed' ? 'closed' : 'open'}
716
+ ${assignee ? `assignee: ${assignee}` : ''}
717
+ ${labels ? `labels: ${labels}` : ''}
718
+ created: ${githubIssue.created_at}
719
+ updated: ${githubIssue.updated_at}
720
+ github_number: ${githubNumber}
721
+ ---
722
+
723
+ # ${githubIssue.title}
724
+
725
+ ${githubIssue.body || ''}
726
+ `;
727
+
728
+ // Write to local file
729
+ const issuePath = this.getIssuePath(finalLocalNumber);
730
+ await fs.writeFile(issuePath, frontmatter);
731
+
732
+ // Update sync-map
733
+ await this._updateSyncMap(String(finalLocalNumber), String(githubNumber));
734
+
735
+ return {
736
+ success: true,
737
+ localNumber: String(finalLocalNumber),
738
+ githubNumber: String(githubNumber),
739
+ action
740
+ };
741
+ }
742
+
743
+ /**
744
+ * Bidirectional sync - sync in the direction of newer changes
745
+ *
746
+ * @param {number|string} issueNumber - Local issue number
747
+ * @param {Object} [options={}] - Sync options
748
+ * @param {string} [options.conflictStrategy='detect'] - How to handle conflicts
749
+ * @returns {Promise<Object>} Result: { success, direction, conflict? }
750
+ */
751
+ async syncBidirectional(issueNumber, options = {}) {
752
+ const syncMap = await this._loadSyncMap();
753
+ const githubNumber = syncMap['local-to-github'][String(issueNumber)];
754
+
755
+ if (!githubNumber) {
756
+ // No GitHub mapping, push to GitHub
757
+ const result = await this.syncToGitHub(issueNumber);
758
+ return { ...result, direction: 'to-github' };
759
+ }
760
+
761
+ const localIssue = await this.getLocalIssue(issueNumber);
762
+ const githubIssue = await this.provider.getIssue(githubNumber);
763
+
764
+ const conflict = this.detectConflict(localIssue, githubIssue);
765
+
766
+ if (conflict.hasConflict) {
767
+ if (options.conflictStrategy === 'detect') {
768
+ return {
769
+ success: false,
770
+ direction: 'conflict',
771
+ conflict
772
+ };
773
+ }
774
+
775
+ // Auto-resolve based on timestamps
776
+ if (conflict.localNewer) {
777
+ const result = await this.syncToGitHub(issueNumber);
778
+ return { ...result, direction: 'to-github' };
779
+ } else if (conflict.remoteNewer) {
780
+ const result = await this.syncFromGitHub(githubNumber);
781
+ return { ...result, direction: 'from-github' };
782
+ }
783
+ }
784
+
785
+ // No conflict or same timestamp - sync local to GitHub
786
+ const result = await this.syncToGitHub(issueNumber);
787
+ return { ...result, direction: 'to-github' };
788
+ }
789
+
790
+ /**
791
+ * Create new GitHub issue from local data
792
+ *
793
+ * @param {Object} issueData - Local issue data
794
+ * @returns {Promise<Object>} Created GitHub issue
795
+ */
796
+ async createGitHubIssue(issueData) {
797
+ const labels = issueData.labels ? issueData.labels.split(',').map(l => l.trim()) : [];
798
+ const assignees = issueData.assignee ? [issueData.assignee] : [];
799
+
800
+ const githubData = {
801
+ title: issueData.title,
802
+ body: issueData.content || '',
803
+ state: this._mapStatusToGitHub(issueData.status),
804
+ labels,
805
+ assignees
806
+ };
807
+
808
+ // Remove empty arrays
809
+ if (githubData.labels.length === 0) delete githubData.labels;
810
+ if (githubData.assignees.length === 0) delete githubData.assignees;
811
+
812
+ const result = await this.provider.createIssue(githubData);
813
+
814
+ // Update sync-map if we have an ID
815
+ if (issueData.id) {
816
+ await this._updateSyncMap(String(issueData.id), String(result.number));
817
+ }
818
+
819
+ return result;
820
+ }
821
+
822
+ /**
823
+ * Update existing GitHub issue with local data
824
+ *
825
+ * @param {number|string} githubNumber - GitHub issue number
826
+ * @param {Object} issueData - Local issue data
827
+ * @returns {Promise<Object>} Updated GitHub issue
828
+ */
829
+ async updateGitHubIssue(githubNumber, issueData) {
830
+ const updateData = {};
831
+
832
+ if (issueData.title) {
833
+ updateData.title = issueData.title;
834
+ }
835
+
836
+ if (issueData.content) {
837
+ updateData.body = issueData.content;
838
+ }
839
+
840
+ if (issueData.status) {
841
+ updateData.state = this._mapStatusToGitHub(issueData.status);
842
+ }
843
+
844
+ if (issueData.labels) {
845
+ updateData.labels = issueData.labels.split(',').map(l => l.trim());
846
+ }
847
+
848
+ if (issueData.assignee) {
849
+ updateData.assignees = [issueData.assignee];
850
+ }
851
+
852
+ return await this.provider.updateIssue(githubNumber, updateData);
853
+ }
854
+
855
+ /**
856
+ * Detect sync conflicts between local and GitHub issues
857
+ *
858
+ * @param {Object} localIssue - Local issue data
859
+ * @param {Object} githubIssue - GitHub issue data
860
+ * @returns {Object} Conflict info: { hasConflict, localNewer, remoteNewer, conflictFields }
861
+ */
862
+ detectConflict(localIssue, githubIssue) {
863
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
864
+ const githubTime = new Date(githubIssue.updated_at || githubIssue.created_at || 0);
865
+
866
+ const conflictFields = [];
867
+
868
+ // Check field differences
869
+ if (localIssue.title !== githubIssue.title) {
870
+ conflictFields.push('title');
871
+ }
872
+
873
+ const localStatus = this._mapStatusToGitHub(localIssue.status);
874
+ if (localStatus !== githubIssue.state) {
875
+ conflictFields.push('status');
876
+ }
877
+
878
+ const hasConflict = localTime.getTime() !== githubTime.getTime();
879
+ const localNewer = localTime > githubTime;
880
+ const remoteNewer = githubTime > localTime;
881
+
882
+ return {
883
+ hasConflict,
884
+ localNewer,
885
+ remoteNewer,
886
+ conflictFields
887
+ };
888
+ }
889
+
890
+ /**
891
+ * Resolve sync conflict using specified strategy
892
+ *
893
+ * @param {number|string} issueNumber - Local issue number
894
+ * @param {string} strategy - Resolution strategy (local|remote|newest|manual|merge)
895
+ * @returns {Promise<Object>} Resolution result: { resolved, appliedStrategy, result }
896
+ * @throws {Error} If strategy is invalid
897
+ */
898
+ async resolveConflict(issueNumber, strategy) {
899
+ const validStrategies = ['local', 'remote', 'newest', 'manual', 'merge'];
900
+
901
+ if (!validStrategies.includes(strategy)) {
902
+ throw new Error('Invalid conflict resolution strategy');
903
+ }
904
+
905
+ if (strategy === 'manual') {
906
+ return {
907
+ resolved: false,
908
+ appliedStrategy: 'manual',
909
+ requiresManualResolution: true
910
+ };
911
+ }
912
+
913
+ const syncMap = await this._loadSyncMap();
914
+ const githubNumber = syncMap['local-to-github'][String(issueNumber)];
915
+
916
+ if (strategy === 'local') {
917
+ // Use local version
918
+ const result = await this.syncToGitHub(issueNumber);
919
+ return {
920
+ resolved: true,
921
+ appliedStrategy: 'local',
922
+ result
923
+ };
924
+ }
925
+
926
+ if (strategy === 'remote') {
927
+ // Use remote version
928
+ const result = await this.syncFromGitHub(githubNumber);
929
+ return {
930
+ resolved: true,
931
+ appliedStrategy: 'remote',
932
+ result
933
+ };
934
+ }
935
+
936
+ if (strategy === 'newest') {
937
+ // Use newest version based on timestamp
938
+ const localIssue = await this.getLocalIssue(issueNumber);
939
+ const githubIssue = await this.provider.getIssue(githubNumber);
940
+
941
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
942
+ const githubTime = new Date(githubIssue.updated_at || githubIssue.created_at || 0);
943
+
944
+ if (localTime >= githubTime) {
945
+ const result = await this.syncToGitHub(issueNumber);
946
+ return {
947
+ resolved: true,
948
+ appliedStrategy: 'newest',
949
+ result
950
+ };
951
+ } else {
952
+ const result = await this.syncFromGitHub(githubNumber);
953
+ return {
954
+ resolved: true,
955
+ appliedStrategy: 'newest',
956
+ result
957
+ };
958
+ }
959
+ }
960
+
961
+ // merge strategy would go here (future enhancement)
962
+ return {
963
+ resolved: false,
964
+ appliedStrategy: strategy,
965
+ message: 'Strategy not yet implemented'
966
+ };
967
+ }
968
+
969
+ /**
970
+ * Get sync status for an issue
971
+ *
972
+ * @param {number|string} issueNumber - Local issue number
973
+ * @returns {Promise<Object>} Status: { synced, localNumber, githubNumber, lastSync, status }
974
+ */
975
+ async getSyncStatus(issueNumber) {
976
+ const syncMap = await this._loadSyncMap();
977
+ const githubNumber = syncMap['local-to-github'][String(issueNumber)];
978
+
979
+ if (!githubNumber) {
980
+ return {
981
+ synced: false,
982
+ localNumber: String(issueNumber),
983
+ githubNumber: null,
984
+ status: 'not-synced'
985
+ };
986
+ }
987
+
988
+ const metadata = syncMap.metadata[String(issueNumber)] || {};
989
+
990
+ // Check if out of sync
991
+ try {
992
+ const localIssue = await this.getLocalIssue(issueNumber);
993
+ const githubIssue = await this.provider.getIssue(githubNumber);
994
+
995
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
996
+ const githubTime = new Date(githubIssue.updated_at || githubIssue.created_at || 0);
997
+ const lastSyncTime = new Date(metadata.lastSync || 0);
998
+
999
+ const isOutOfSync = localTime > lastSyncTime || githubTime > lastSyncTime;
1000
+
1001
+ return {
1002
+ synced: !isOutOfSync,
1003
+ localNumber: String(issueNumber),
1004
+ githubNumber: String(githubNumber),
1005
+ lastSync: metadata.lastSync,
1006
+ status: isOutOfSync ? 'out-of-sync' : 'synced'
1007
+ };
1008
+ } catch (error) {
1009
+ return {
1010
+ synced: true,
1011
+ localNumber: String(issueNumber),
1012
+ githubNumber: String(githubNumber),
1013
+ lastSync: metadata.lastSync,
1014
+ status: 'synced'
1015
+ };
1016
+ }
1017
+ }
1018
+
1019
+ // ==========================================
1020
+ // PRIVATE HELPER METHODS FOR SYNC
1021
+ // ==========================================
1022
+
1023
+ /**
1024
+ * Load sync-map from file
1025
+ * @private
1026
+ */
1027
+ async _loadSyncMap() {
1028
+ const fs = require('fs-extra');
1029
+ const path = require('path');
1030
+ const syncMapPath = path.join(process.cwd(), '.claude/sync-map.json');
1031
+
1032
+ if (await fs.pathExists(syncMapPath)) {
1033
+ return await fs.readJSON(syncMapPath);
1034
+ }
1035
+
1036
+ return {
1037
+ 'local-to-github': {},
1038
+ 'github-to-local': {},
1039
+ 'metadata': {}
1040
+ };
1041
+ }
1042
+
1043
+ /**
1044
+ * Save sync-map to file
1045
+ * @private
1046
+ */
1047
+ async _saveSyncMap(syncMap) {
1048
+ const fs = require('fs-extra');
1049
+ const path = require('path');
1050
+ const syncMapPath = path.join(process.cwd(), '.claude/sync-map.json');
1051
+ await fs.writeJSON(syncMapPath, syncMap, { spaces: 2 });
1052
+ }
1053
+
1054
+ /**
1055
+ * Update sync-map with new mapping
1056
+ * @private
1057
+ */
1058
+ async _updateSyncMap(localNumber, githubNumber) {
1059
+ const syncMap = await this._loadSyncMap();
1060
+
1061
+ syncMap['local-to-github'][String(localNumber)] = String(githubNumber);
1062
+ syncMap['github-to-local'][String(githubNumber)] = String(localNumber);
1063
+ syncMap['metadata'][String(localNumber)] = {
1064
+ lastSync: new Date().toISOString(),
1065
+ githubNumber: String(githubNumber)
1066
+ };
1067
+
1068
+ await this._saveSyncMap(syncMap);
1069
+ }
1070
+
1071
+ /**
1072
+ * Map local status to GitHub state
1073
+ * @private
1074
+ */
1075
+ _mapStatusToGitHub(status) {
1076
+ if (!status) return 'open';
1077
+
1078
+ const lowerStatus = status.toLowerCase();
1079
+
1080
+ if (['closed', 'completed', 'done', 'resolved'].includes(lowerStatus)) {
1081
+ return 'closed';
1082
+ }
1083
+
1084
+ return 'open';
1085
+ }
1086
+
1087
+ // ==========================================
1088
+ // 7. AZURE DEVOPS SYNC METHODS (8 NEW METHODS)
1089
+ // ==========================================
1090
+
1091
+ /**
1092
+ * Sync local issue to Azure DevOps (enhanced push with conflict detection)
1093
+ *
1094
+ * @param {number|string} issueNumber - Local issue number
1095
+ * @param {Object} [options={}] - Sync options
1096
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
1097
+ * @returns {Promise<Object>} Result: { success, issueNumber, workItemId, action, conflict? }
1098
+ */
1099
+ async syncToAzure(issueNumber, options = {}) {
1100
+ const syncMap = await this._loadAzureSyncMap();
1101
+ const localIssue = await this.getLocalIssue(issueNumber);
1102
+ const workItemId = syncMap['local-to-azure'][String(issueNumber)];
1103
+
1104
+ let result;
1105
+ let action;
1106
+
1107
+ if (workItemId) {
1108
+ // Check for conflicts if enabled
1109
+ if (options.detectConflicts) {
1110
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1111
+ const conflict = this.detectAzureConflict(localIssue, azureWorkItem);
1112
+
1113
+ if (conflict.hasConflict && conflict.remoteNewer) {
1114
+ return {
1115
+ success: false,
1116
+ issueNumber: String(issueNumber),
1117
+ workItemId: String(workItemId),
1118
+ conflict
1119
+ };
1120
+ }
1121
+ }
1122
+
1123
+ // Update existing Azure work item
1124
+ result = await this.updateAzureWorkItem(workItemId, localIssue);
1125
+ action = 'updated';
1126
+ } else {
1127
+ // Create new Azure work item
1128
+ result = await this.createAzureWorkItem(localIssue);
1129
+ action = 'created';
1130
+ }
1131
+
1132
+ // Update azure-sync-map
1133
+ const workItemType = result.fields['System.WorkItemType'] || 'User Story';
1134
+ await this._updateAzureSyncMap(String(issueNumber), String(result.id), workItemType);
1135
+
1136
+ return {
1137
+ success: true,
1138
+ issueNumber: String(issueNumber),
1139
+ workItemId: String(result.id),
1140
+ action
1141
+ };
1142
+ }
1143
+
1144
+ /**
1145
+ * Sync Azure work item to local (enhanced pull with merge)
1146
+ *
1147
+ * @param {number|string} workItemId - Azure work item ID
1148
+ * @param {Object} [options={}] - Sync options
1149
+ * @param {boolean} [options.detectConflicts=false] - Enable conflict detection
1150
+ * @returns {Promise<Object>} Result: { success, localNumber, workItemId, action, conflict? }
1151
+ */
1152
+ async syncFromAzure(workItemId, options = {}) {
1153
+ const fs = require('fs-extra');
1154
+
1155
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1156
+ const syncMap = await this._loadAzureSyncMap();
1157
+ const localNumber = syncMap['azure-to-local'][String(workItemId)];
1158
+
1159
+ let action;
1160
+ let finalLocalNumber = localNumber;
1161
+
1162
+ if (localNumber) {
1163
+ // Check for conflicts if enabled
1164
+ if (options.detectConflicts) {
1165
+ const localIssue = await this.getLocalIssue(localNumber);
1166
+ const conflict = this.detectAzureConflict(localIssue, azureWorkItem);
1167
+
1168
+ if (conflict.hasConflict && conflict.localNewer) {
1169
+ return {
1170
+ success: false,
1171
+ localNumber: String(localNumber),
1172
+ workItemId: String(workItemId),
1173
+ conflict
1174
+ };
1175
+ }
1176
+ }
1177
+
1178
+ // Update existing local issue
1179
+ action = 'updated';
1180
+ } else {
1181
+ // Create new local issue - find next available number
1182
+ const issues = await this.listIssues();
1183
+ const maxNumber = issues.reduce((max, issue) => {
1184
+ const num = parseInt(issue.id || '0');
1185
+ return num > max ? num : max;
1186
+ }, 0);
1187
+ finalLocalNumber = String(maxNumber + 1);
1188
+ action = 'created';
1189
+ }
1190
+
1191
+ // Build issue content with frontmatter
1192
+ const tags = azureWorkItem.fields['System.Tags'] || '';
1193
+ const assignee = azureWorkItem.fields['System.AssignedTo']
1194
+ ? azureWorkItem.fields['System.AssignedTo'].displayName
1195
+ : '';
1196
+
1197
+ const frontmatter = `---
1198
+ id: ${finalLocalNumber}
1199
+ title: ${azureWorkItem.fields['System.Title']}
1200
+ status: ${this._mapAzureStateToLocal(azureWorkItem.fields['System.State'])}
1201
+ ${assignee ? `assignee: ${assignee}` : ''}
1202
+ ${tags ? `labels: ${tags}` : ''}
1203
+ created: ${azureWorkItem.fields['System.CreatedDate']}
1204
+ updated: ${azureWorkItem.fields['System.ChangedDate']}
1205
+ azure_work_item_id: ${workItemId}
1206
+ work_item_type: ${azureWorkItem.fields['System.WorkItemType']}
1207
+ ---
1208
+
1209
+ # ${azureWorkItem.fields['System.Title']}
1210
+
1211
+ ${azureWorkItem.fields['System.Description'] || ''}
1212
+ `;
1213
+
1214
+ // Write to local file
1215
+ const issuePath = this.getIssuePath(finalLocalNumber);
1216
+ await fs.writeFile(issuePath, frontmatter);
1217
+
1218
+ // Update azure-sync-map
1219
+ const workItemType = azureWorkItem.fields['System.WorkItemType'] || 'User Story';
1220
+ await this._updateAzureSyncMap(String(finalLocalNumber), String(workItemId), workItemType);
1221
+
1222
+ return {
1223
+ success: true,
1224
+ localNumber: String(finalLocalNumber),
1225
+ workItemId: String(workItemId),
1226
+ action
1227
+ };
1228
+ }
1229
+
1230
+ /**
1231
+ * Bidirectional Azure sync - sync in the direction of newer changes
1232
+ *
1233
+ * @param {number|string} issueNumber - Local issue number
1234
+ * @param {Object} [options={}] - Sync options
1235
+ * @param {string} [options.conflictStrategy='detect'] - How to handle conflicts
1236
+ * @returns {Promise<Object>} Result: { success, direction, conflict? }
1237
+ */
1238
+ async syncBidirectionalAzure(issueNumber, options = {}) {
1239
+ const syncMap = await this._loadAzureSyncMap();
1240
+ const workItemId = syncMap['local-to-azure'][String(issueNumber)];
1241
+
1242
+ if (!workItemId) {
1243
+ // No Azure mapping, push to Azure
1244
+ const result = await this.syncToAzure(issueNumber);
1245
+ return { ...result, direction: 'to-azure' };
1246
+ }
1247
+
1248
+ const localIssue = await this.getLocalIssue(issueNumber);
1249
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1250
+
1251
+ const conflict = this.detectAzureConflict(localIssue, azureWorkItem);
1252
+
1253
+ if (conflict.hasConflict) {
1254
+ if (options.conflictStrategy === 'detect') {
1255
+ return {
1256
+ success: false,
1257
+ direction: 'conflict',
1258
+ conflict
1259
+ };
1260
+ }
1261
+
1262
+ // Auto-resolve based on timestamps
1263
+ if (conflict.localNewer) {
1264
+ const result = await this.syncToAzure(issueNumber);
1265
+ return { ...result, direction: 'to-azure' };
1266
+ } else if (conflict.remoteNewer) {
1267
+ const result = await this.syncFromAzure(workItemId);
1268
+ return { ...result, direction: 'from-azure' };
1269
+ }
1270
+ }
1271
+
1272
+ // No conflict or same timestamp - sync local to Azure
1273
+ const result = await this.syncToAzure(issueNumber);
1274
+ return { ...result, direction: 'to-azure' };
1275
+ }
1276
+
1277
+ /**
1278
+ * Create new Azure work item from local data
1279
+ *
1280
+ * @param {Object} issueData - Local issue data
1281
+ * @returns {Promise<Object>} Created Azure work item
1282
+ */
1283
+ async createAzureWorkItem(issueData) {
1284
+ const workItemType = issueData.work_item_type || 'User Story';
1285
+ const tags = issueData.labels || '';
1286
+
1287
+ const azureData = {
1288
+ title: issueData.title,
1289
+ description: issueData.content || '',
1290
+ state: this._mapStatusToAzure(issueData.status),
1291
+ tags
1292
+ };
1293
+
1294
+ const result = await this.provider.createWorkItem(workItemType, azureData);
1295
+
1296
+ // Update azure-sync-map if we have an ID
1297
+ if (issueData.id) {
1298
+ const resultWorkItemType = result.fields['System.WorkItemType'] || workItemType;
1299
+ await this._updateAzureSyncMap(String(issueData.id), String(result.id), resultWorkItemType);
1300
+ }
1301
+
1302
+ return result;
1303
+ }
1304
+
1305
+ /**
1306
+ * Update existing Azure work item with local data
1307
+ *
1308
+ * @param {number|string} workItemId - Azure work item ID
1309
+ * @param {Object} issueData - Local issue data
1310
+ * @returns {Promise<Object>} Updated Azure work item
1311
+ */
1312
+ async updateAzureWorkItem(workItemId, issueData) {
1313
+ const updateData = {};
1314
+
1315
+ if (issueData.title) {
1316
+ updateData.title = issueData.title;
1317
+ }
1318
+
1319
+ if (issueData.content) {
1320
+ updateData.description = issueData.content;
1321
+ }
1322
+
1323
+ if (issueData.status) {
1324
+ updateData.state = this._mapStatusToAzure(issueData.status);
1325
+ }
1326
+
1327
+ return await this.provider.updateWorkItem(workItemId, updateData);
1328
+ }
1329
+
1330
+ /**
1331
+ * Detect sync conflicts between local and Azure work items
1332
+ *
1333
+ * @param {Object} localIssue - Local issue data
1334
+ * @param {Object} azureWorkItem - Azure work item data
1335
+ * @returns {Object} Conflict info: { hasConflict, localNewer, remoteNewer, conflictFields }
1336
+ */
1337
+ detectAzureConflict(localIssue, azureWorkItem) {
1338
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
1339
+ const azureTime = new Date(azureWorkItem.fields['System.ChangedDate'] || azureWorkItem.fields['System.CreatedDate'] || 0);
1340
+
1341
+ const conflictFields = [];
1342
+
1343
+ // Check field differences
1344
+ if (localIssue.title !== azureWorkItem.fields['System.Title']) {
1345
+ conflictFields.push('title');
1346
+ }
1347
+
1348
+ const localStatus = this._mapStatusToAzure(localIssue.status);
1349
+ if (localStatus !== azureWorkItem.fields['System.State']) {
1350
+ conflictFields.push('status');
1351
+ }
1352
+
1353
+ const hasConflict = localTime.getTime() !== azureTime.getTime();
1354
+ const localNewer = localTime > azureTime;
1355
+ const remoteNewer = azureTime > localTime;
1356
+
1357
+ return {
1358
+ hasConflict,
1359
+ localNewer,
1360
+ remoteNewer,
1361
+ conflictFields
1362
+ };
1363
+ }
1364
+
1365
+ /**
1366
+ * Resolve Azure sync conflict using specified strategy
1367
+ *
1368
+ * @param {number|string} issueNumber - Local issue number
1369
+ * @param {string} strategy - Resolution strategy (local|remote|newest|manual|merge)
1370
+ * @returns {Promise<Object>} Resolution result: { resolved, appliedStrategy, result }
1371
+ * @throws {Error} If strategy is invalid
1372
+ */
1373
+ async resolveAzureConflict(issueNumber, strategy) {
1374
+ const validStrategies = ['local', 'remote', 'newest', 'manual', 'merge'];
1375
+
1376
+ if (!validStrategies.includes(strategy)) {
1377
+ throw new Error('Invalid conflict resolution strategy');
1378
+ }
1379
+
1380
+ if (strategy === 'manual') {
1381
+ return {
1382
+ resolved: false,
1383
+ appliedStrategy: 'manual',
1384
+ requiresManualResolution: true
1385
+ };
1386
+ }
1387
+
1388
+ const syncMap = await this._loadAzureSyncMap();
1389
+ const workItemId = syncMap['local-to-azure'][String(issueNumber)];
1390
+
1391
+ if (strategy === 'local') {
1392
+ // Use local version
1393
+ const result = await this.syncToAzure(issueNumber);
1394
+ return {
1395
+ resolved: true,
1396
+ appliedStrategy: 'local',
1397
+ result
1398
+ };
1399
+ }
1400
+
1401
+ if (strategy === 'remote') {
1402
+ // Use remote version
1403
+ const result = await this.syncFromAzure(workItemId);
1404
+ return {
1405
+ resolved: true,
1406
+ appliedStrategy: 'remote',
1407
+ result
1408
+ };
1409
+ }
1410
+
1411
+ if (strategy === 'newest') {
1412
+ // Use newest version based on timestamp
1413
+ const localIssue = await this.getLocalIssue(issueNumber);
1414
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1415
+
1416
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
1417
+ const azureTime = new Date(azureWorkItem.fields['System.ChangedDate'] || azureWorkItem.fields['System.CreatedDate'] || 0);
1418
+
1419
+ if (localTime >= azureTime) {
1420
+ const result = await this.syncToAzure(issueNumber);
1421
+ return {
1422
+ resolved: true,
1423
+ appliedStrategy: 'newest',
1424
+ result
1425
+ };
1426
+ } else {
1427
+ const result = await this.syncFromAzure(workItemId);
1428
+ return {
1429
+ resolved: true,
1430
+ appliedStrategy: 'newest',
1431
+ result
1432
+ };
1433
+ }
1434
+ }
1435
+
1436
+ // merge strategy would go here (future enhancement)
1437
+ return {
1438
+ resolved: false,
1439
+ appliedStrategy: strategy,
1440
+ message: 'Strategy not yet implemented'
1441
+ };
1442
+ }
1443
+
1444
+ /**
1445
+ * Get Azure sync status for an issue
1446
+ *
1447
+ * @param {number|string} issueNumber - Local issue number
1448
+ * @returns {Promise<Object>} Status: { synced, localNumber, workItemId, lastSync, status }
1449
+ */
1450
+ async getAzureSyncStatus(issueNumber) {
1451
+ const syncMap = await this._loadAzureSyncMap();
1452
+ const workItemId = syncMap['local-to-azure'][String(issueNumber)];
1453
+
1454
+ if (!workItemId) {
1455
+ return {
1456
+ synced: false,
1457
+ localNumber: String(issueNumber),
1458
+ workItemId: null,
1459
+ status: 'not-synced'
1460
+ };
1461
+ }
1462
+
1463
+ const metadata = syncMap.metadata[String(issueNumber)] || {};
1464
+
1465
+ // Check if out of sync
1466
+ try {
1467
+ const localIssue = await this.getLocalIssue(issueNumber);
1468
+ const azureWorkItem = await this.provider.getWorkItem(workItemId);
1469
+
1470
+ const localTime = new Date(localIssue.updated || localIssue.created || 0);
1471
+ const azureTime = new Date(azureWorkItem.fields['System.ChangedDate'] || azureWorkItem.fields['System.CreatedDate'] || 0);
1472
+ const lastSyncTime = new Date(metadata.lastSync || 0);
1473
+
1474
+ const isOutOfSync = localTime > lastSyncTime || azureTime > lastSyncTime;
1475
+
1476
+ return {
1477
+ synced: !isOutOfSync,
1478
+ localNumber: String(issueNumber),
1479
+ workItemId: String(workItemId),
1480
+ lastSync: metadata.lastSync,
1481
+ status: isOutOfSync ? 'out-of-sync' : 'synced'
1482
+ };
1483
+ } catch (error) {
1484
+ return {
1485
+ synced: true,
1486
+ localNumber: String(issueNumber),
1487
+ workItemId: String(workItemId),
1488
+ lastSync: metadata.lastSync,
1489
+ status: 'synced'
1490
+ };
1491
+ }
1492
+ }
1493
+
1494
+ // ==========================================
1495
+ // PRIVATE HELPER METHODS FOR AZURE SYNC
1496
+ // ==========================================
1497
+
1498
+ /**
1499
+ * Load azure-sync-map from file
1500
+ * @private
1501
+ */
1502
+ async _loadAzureSyncMap() {
1503
+ const fs = require('fs-extra');
1504
+ const path = require('path');
1505
+ const syncMapPath = path.join(process.cwd(), '.claude/azure-sync-map.json');
1506
+
1507
+ if (await fs.pathExists(syncMapPath)) {
1508
+ return await fs.readJSON(syncMapPath);
1509
+ }
1510
+
1511
+ return {
1512
+ 'local-to-azure': {},
1513
+ 'azure-to-local': {},
1514
+ 'metadata': {}
1515
+ };
1516
+ }
1517
+
1518
+ /**
1519
+ * Save azure-sync-map to file
1520
+ * @private
1521
+ */
1522
+ async _saveAzureSyncMap(syncMap) {
1523
+ const fs = require('fs-extra');
1524
+ const path = require('path');
1525
+ const syncMapPath = path.join(process.cwd(), '.claude/azure-sync-map.json');
1526
+ await fs.writeJSON(syncMapPath, syncMap, { spaces: 2 });
1527
+ }
1528
+
1529
+ /**
1530
+ * Update azure-sync-map with new mapping
1531
+ * @private
1532
+ */
1533
+ async _updateAzureSyncMap(localNumber, workItemId, workItemType = 'User Story') {
1534
+ const syncMap = await this._loadAzureSyncMap();
1535
+
1536
+ syncMap['local-to-azure'][String(localNumber)] = String(workItemId);
1537
+ syncMap['azure-to-local'][String(workItemId)] = String(localNumber);
1538
+ syncMap['metadata'][String(localNumber)] = {
1539
+ lastSync: new Date().toISOString(),
1540
+ workItemId: String(workItemId),
1541
+ workItemType
1542
+ };
1543
+
1544
+ await this._saveAzureSyncMap(syncMap);
1545
+ }
1546
+
1547
+ /**
1548
+ * Map local status to Azure state
1549
+ * @private
1550
+ */
1551
+ _mapStatusToAzure(status) {
1552
+ if (!status) return 'New';
1553
+
1554
+ const lowerStatus = status.toLowerCase();
1555
+ const statusMap = {
1556
+ 'open': 'New',
1557
+ 'in-progress': 'Active',
1558
+ 'done': 'Resolved',
1559
+ 'completed': 'Resolved',
1560
+ 'closed': 'Closed'
1561
+ };
1562
+
1563
+ return statusMap[lowerStatus] || 'New';
1564
+ }
1565
+
1566
+ /**
1567
+ * Map Azure state to local status
1568
+ * @private
1569
+ */
1570
+ _mapAzureStateToLocal(state) {
1571
+ const stateMap = {
1572
+ 'New': 'open',
1573
+ 'Active': 'in-progress',
1574
+ 'Resolved': 'done',
1575
+ 'Closed': 'closed'
1576
+ };
1577
+
1578
+ return stateMap[state] || 'open';
1579
+ }
589
1580
  }
590
1581
 
591
1582
  module.exports = IssueService;