specweave 1.0.488 → 1.0.489

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.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts +13 -5
  3. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts.map +1 -1
  4. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +28 -26
  5. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
  6. package/dist/plugins/specweave-ado/lib/ado-client.d.ts +10 -0
  7. package/dist/plugins/specweave-ado/lib/ado-client.d.ts.map +1 -1
  8. package/dist/plugins/specweave-ado/lib/ado-client.js +14 -0
  9. package/dist/plugins/specweave-ado/lib/ado-client.js.map +1 -1
  10. package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts +35 -0
  11. package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts.map +1 -0
  12. package/dist/plugins/specweave-ado/lib/ado-pull-sync.js +53 -0
  13. package/dist/plugins/specweave-ado/lib/ado-pull-sync.js.map +1 -0
  14. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts +46 -0
  15. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts.map +1 -0
  16. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js +65 -0
  17. package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js.map +1 -0
  18. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +7 -1
  19. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
  20. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  21. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  22. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +17 -1
  23. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -1
  24. package/dist/plugins/specweave-ado/lib/ado-status-sync.js +51 -9
  25. package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -1
  26. package/dist/plugins/specweave-github/lib/github-client-v2.js +1 -1
  27. package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
  28. package/dist/plugins/specweave-github/lib/github-push-sync.d.ts.map +1 -1
  29. package/dist/plugins/specweave-github/lib/github-push-sync.js +15 -3
  30. package/dist/plugins/specweave-github/lib/github-push-sync.js.map +1 -1
  31. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +31 -1
  32. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
  33. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +170 -97
  34. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  35. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +36 -1
  36. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
  37. package/dist/plugins/specweave-jira/lib/jira-status-sync.js +185 -82
  38. package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
  39. package/dist/src/adapters/adapter-loader.d.ts.map +1 -1
  40. package/dist/src/adapters/adapter-loader.js +8 -2
  41. package/dist/src/adapters/adapter-loader.js.map +1 -1
  42. package/dist/src/adapters/codex/adapter.d.ts.map +1 -1
  43. package/dist/src/adapters/codex/adapter.js +1 -0
  44. package/dist/src/adapters/codex/adapter.js.map +1 -1
  45. package/dist/src/adapters/cursor/adapter.d.ts.map +1 -1
  46. package/dist/src/adapters/cursor/adapter.js +1 -0
  47. package/dist/src/adapters/cursor/adapter.js.map +1 -1
  48. package/dist/src/adapters/generic/adapter.d.ts +6 -3
  49. package/dist/src/adapters/generic/adapter.d.ts.map +1 -1
  50. package/dist/src/adapters/generic/adapter.js +53 -47
  51. package/dist/src/adapters/generic/adapter.js.map +1 -1
  52. package/dist/src/adapters/kimi/adapter.d.ts +21 -0
  53. package/dist/src/adapters/kimi/adapter.d.ts.map +1 -0
  54. package/dist/src/adapters/kimi/adapter.js +57 -0
  55. package/dist/src/adapters/kimi/adapter.js.map +1 -0
  56. package/dist/src/adapters/opencode/adapter.d.ts +24 -0
  57. package/dist/src/adapters/opencode/adapter.d.ts.map +1 -0
  58. package/dist/src/adapters/opencode/adapter.js +71 -0
  59. package/dist/src/adapters/opencode/adapter.js.map +1 -0
  60. package/dist/src/adapters/registry.yaml +59 -0
  61. package/dist/src/adapters/trae/adapter.d.ts +21 -0
  62. package/dist/src/adapters/trae/adapter.d.ts.map +1 -0
  63. package/dist/src/adapters/trae/adapter.js +64 -0
  64. package/dist/src/adapters/trae/adapter.js.map +1 -0
  65. package/dist/src/cli/commands/init.d.ts.map +1 -1
  66. package/dist/src/cli/commands/init.js +156 -5
  67. package/dist/src/cli/commands/init.js.map +1 -1
  68. package/dist/src/cli/commands/update-instructions.d.ts.map +1 -1
  69. package/dist/src/cli/commands/update-instructions.js +10 -0
  70. package/dist/src/cli/commands/update-instructions.js.map +1 -1
  71. package/dist/src/cli/helpers/init/index.d.ts +1 -0
  72. package/dist/src/cli/helpers/init/index.d.ts.map +1 -1
  73. package/dist/src/cli/helpers/init/index.js +2 -0
  74. package/dist/src/cli/helpers/init/index.js.map +1 -1
  75. package/dist/src/cli/helpers/init/next-steps.d.ts.map +1 -1
  76. package/dist/src/cli/helpers/init/next-steps.js +52 -0
  77. package/dist/src/cli/helpers/init/next-steps.js.map +1 -1
  78. package/dist/src/cli/helpers/init/skill-creator-installer.d.ts +24 -0
  79. package/dist/src/cli/helpers/init/skill-creator-installer.d.ts.map +1 -0
  80. package/dist/src/cli/helpers/init/skill-creator-installer.js +54 -0
  81. package/dist/src/cli/helpers/init/skill-creator-installer.js.map +1 -0
  82. package/dist/src/core/ado-description-updater.d.ts +22 -0
  83. package/dist/src/core/ado-description-updater.d.ts.map +1 -0
  84. package/dist/src/core/ado-description-updater.js +46 -0
  85. package/dist/src/core/ado-description-updater.js.map +1 -0
  86. package/dist/src/core/closure-dispatcher.d.ts +96 -0
  87. package/dist/src/core/closure-dispatcher.d.ts.map +1 -0
  88. package/dist/src/core/closure-dispatcher.js +116 -0
  89. package/dist/src/core/closure-dispatcher.js.map +1 -0
  90. package/dist/src/core/config/types.d.ts +2 -0
  91. package/dist/src/core/config/types.d.ts.map +1 -1
  92. package/dist/src/core/config/types.js.map +1 -1
  93. package/dist/src/core/errors/sync-error.d.ts +12 -0
  94. package/dist/src/core/errors/sync-error.d.ts.map +1 -0
  95. package/dist/src/core/errors/sync-error.js +19 -0
  96. package/dist/src/core/errors/sync-error.js.map +1 -0
  97. package/dist/src/core/skill-gen/rule-collector.d.ts +28 -0
  98. package/dist/src/core/skill-gen/rule-collector.d.ts.map +1 -0
  99. package/dist/src/core/skill-gen/rule-collector.js +112 -0
  100. package/dist/src/core/skill-gen/rule-collector.js.map +1 -0
  101. package/dist/src/core/skill-gen/signal-collector.d.ts +2 -1
  102. package/dist/src/core/skill-gen/signal-collector.d.ts.map +1 -1
  103. package/dist/src/core/skill-gen/signal-collector.js +21 -5
  104. package/dist/src/core/skill-gen/signal-collector.js.map +1 -1
  105. package/dist/src/core/sync/persistent-circuit-breaker.d.ts +22 -0
  106. package/dist/src/core/sync/persistent-circuit-breaker.d.ts.map +1 -0
  107. package/dist/src/core/sync/persistent-circuit-breaker.js +65 -0
  108. package/dist/src/core/sync/persistent-circuit-breaker.js.map +1 -0
  109. package/dist/src/core/sync/retry-wrapper.d.ts +13 -0
  110. package/dist/src/core/sync/retry-wrapper.d.ts.map +1 -0
  111. package/dist/src/core/sync/retry-wrapper.js +37 -0
  112. package/dist/src/core/sync/retry-wrapper.js.map +1 -0
  113. package/dist/src/importers/ac-parser.d.ts +27 -0
  114. package/dist/src/importers/ac-parser.d.ts.map +1 -0
  115. package/dist/src/importers/ac-parser.js +47 -0
  116. package/dist/src/importers/ac-parser.js.map +1 -0
  117. package/dist/src/sync/types.d.ts +8 -0
  118. package/dist/src/sync/types.d.ts.map +1 -1
  119. package/dist/src/sync/types.js +12 -0
  120. package/dist/src/sync/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/plugins/specweave/skills/code-reviewer/agents/reviewer-silent-failures.md +65 -0
  123. package/plugins/specweave/skills/code-reviewer/agents/reviewer-spec-compliance.md +83 -0
  124. package/plugins/specweave/skills/code-reviewer/agents/reviewer-types.md +68 -0
  125. package/plugins/specweave/skills/skill-gen/SKILL.md +20 -3
  126. package/plugins/specweave/skills/team-lead/agents/architect.md +52 -0
  127. package/plugins/specweave/skills/team-lead/agents/pm.md +50 -0
  128. package/plugins/specweave/skills/team-lead/agents/researcher.md +64 -0
  129. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +23 -21
  130. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +37 -29
  131. package/plugins/specweave-ado/lib/ado-client.js +14 -0
  132. package/plugins/specweave-ado/lib/ado-client.ts +18 -0
  133. package/plugins/specweave-ado/lib/ado-pull-sync.js +35 -0
  134. package/plugins/specweave-ado/lib/ado-pull-sync.ts +74 -0
  135. package/plugins/specweave-ado/lib/ado-rate-limiter.js +56 -0
  136. package/plugins/specweave-ado/lib/ado-rate-limiter.ts +86 -0
  137. package/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  138. package/plugins/specweave-ado/lib/ado-spec-sync.ts +32 -2
  139. package/plugins/specweave-ado/lib/ado-status-sync.js +52 -14
  140. package/plugins/specweave-ado/lib/ado-status-sync.ts +64 -16
  141. package/plugins/specweave-github/lib/github-client-v2.ts +1 -1
  142. package/plugins/specweave-github/lib/github-push-sync.js +11 -3
  143. package/plugins/specweave-github/lib/github-push-sync.ts +16 -3
  144. package/plugins/specweave-jira/lib/jira-spec-sync.js +60 -1
  145. package/plugins/specweave-jira/lib/jira-spec-sync.ts +93 -1
  146. package/plugins/specweave-jira/lib/jira-status-sync.js +151 -109
  147. package/plugins/specweave-jira/lib/jira-status-sync.ts +161 -39
@@ -9,6 +9,10 @@ import { toDescription } from "./content-format-adapter.js";
9
9
  import { getEpicLinkFieldForProject } from "./jira-field-discovery.js";
10
10
  import { searchAllIssues } from "./jira-paginated-search.js";
11
11
  import axios from "axios";
12
+ import { CircuitBreakerRegistry } from "../../../src/core/sync/circuit-breaker-registry.js";
13
+ import { SyncRetryQueue } from "../../../src/core/sync/sync-retry-queue.js";
14
+ import { SyncError } from "../../../src/core/errors/sync-error.js";
15
+ import { LockManager } from "../../../src/utils/lock-manager.js";
12
16
  function buildStoryDescription(us) {
13
17
  const acList = us.acceptanceCriteria.map((ac) => `${ac.status === "done" ? "[x]" : "[ ]"} ${ac.id}: ${ac.description}`).join("\n");
14
18
  return `
@@ -28,10 +32,17 @@ ${acList}
28
32
  `.trim();
29
33
  }
30
34
  class JiraSpecSync {
31
- constructor(config, projectRoot = process.cwd(), projectId) {
35
+ constructor(config, projectRoot = process.cwd(), projectId, options) {
32
36
  this.projectRoot = projectRoot;
33
37
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
34
38
  this.config = config;
39
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
40
+ this.retryQueue = options?.retryQueue;
41
+ this.incrementId = options?.incrementId ?? "";
42
+ this.featureId = options?.featureId ?? "";
43
+ if (options?.lockDir) {
44
+ this.lockManager = new LockManager(options.lockDir);
45
+ }
35
46
  this.client = axios.create({
36
47
  baseURL: getApiBaseUrl(config.domain),
37
48
  auth: {
@@ -44,6 +55,45 @@ class JiraSpecSync {
44
55
  }
45
56
  });
46
57
  }
58
+ async withLock(fn) {
59
+ if (!this.lockManager) return fn();
60
+ const acquired = await this.lockManager.acquire();
61
+ if (!acquired) {
62
+ throw new SyncError("jira", 0, "", "Failed to acquire JIRA sync lock");
63
+ }
64
+ try {
65
+ return await fn();
66
+ } finally {
67
+ await this.lockManager.release();
68
+ }
69
+ }
70
+ checkCircuitBreaker() {
71
+ if (!this.circuitBreakerRegistry) return;
72
+ const breaker = this.circuitBreakerRegistry.get("jira");
73
+ if (!breaker.canSync()) {
74
+ throw new SyncError("jira", 0, "", "Circuit breaker open for jira");
75
+ }
76
+ }
77
+ recordApiSuccess() {
78
+ if (!this.circuitBreakerRegistry) return;
79
+ this.circuitBreakerRegistry.get("jira").recordSuccess();
80
+ }
81
+ async recordApiFailure(error, operation) {
82
+ const httpStatus = error?.response?.status ?? 0;
83
+ if (this.circuitBreakerRegistry) {
84
+ this.circuitBreakerRegistry.get("jira").recordFailure();
85
+ }
86
+ if (this.retryQueue && httpStatus >= 500) {
87
+ await this.retryQueue.enqueue({
88
+ incrementId: this.incrementId,
89
+ provider: "jira",
90
+ featureId: this.featureId,
91
+ projectPath: this.projectRoot,
92
+ projectName: this.config.projectKey,
93
+ error: `${httpStatus} ${operation}: ${error?.message ?? String(error)}`
94
+ });
95
+ }
96
+ }
47
97
  /**
48
98
  * Initialize: detect deployment type and update client baseURL
49
99
  */
@@ -60,6 +110,8 @@ class JiraSpecSync {
60
110
  async syncSpecToJira(specId) {
61
111
  console.log(`
62
112
  \u{1F504} Syncing spec ${specId} to Jira Epic...`);
113
+ return this.withLock(async () => {
114
+ this.checkCircuitBreaker();
63
115
  try {
64
116
  const spec = await this.specManager.loadSpec(specId);
65
117
  if (!spec) {
@@ -86,6 +138,7 @@ class JiraSpecSync {
86
138
  });
87
139
  }
88
140
  const changes = await this.syncUserStories(epic.key, spec);
141
+ this.recordApiSuccess();
89
142
  console.log("\u2705 Sync complete!");
90
143
  return {
91
144
  success: true,
@@ -96,6 +149,7 @@ class JiraSpecSync {
96
149
  changes
97
150
  };
98
151
  } catch (error) {
152
+ await this.recordApiFailure(error, "syncSpecToJira");
99
153
  const axiosData = error?.response?.data;
100
154
  const detail = axiosData ? JSON.stringify(axiosData) : "";
101
155
  console.error("\u274C Error syncing to Jira:", error?.message || error, detail ? `
@@ -107,6 +161,7 @@ class JiraSpecSync {
107
161
  error: error instanceof Error ? error.message : "Unknown error"
108
162
  };
109
163
  }
164
+ });
110
165
  }
111
166
  /**
112
167
  * Sync FROM Jira Epic to spec (bidirectional)
@@ -114,6 +169,8 @@ class JiraSpecSync {
114
169
  async syncFromJira(specId) {
115
170
  console.log(`
116
171
  \u{1F504} Syncing FROM Jira to spec ${specId}...`);
172
+ return this.withLock(async () => {
173
+ this.checkCircuitBreaker();
117
174
  try {
118
175
  const spec = await this.specManager.loadSpec(specId);
119
176
  if (!spec) {
@@ -158,6 +215,7 @@ class JiraSpecSync {
158
215
  conflicts
159
216
  };
160
217
  } catch (error) {
218
+ await this.recordApiFailure(error, "syncFromJira");
161
219
  console.error("\u274C Error syncing FROM Jira:", error);
162
220
  return {
163
221
  success: false,
@@ -166,6 +224,7 @@ class JiraSpecSync {
166
224
  error: error instanceof Error ? error.message : "Unknown error"
167
225
  };
168
226
  }
227
+ });
169
228
  }
170
229
  /**
171
230
  * Create new Jira Epic for spec
@@ -31,6 +31,10 @@ import { toDescription, AdfDocument } from './content-format-adapter.js';
31
31
  import { getEpicLinkFieldForProject } from './jira-field-discovery.js';
32
32
  import { searchAllIssues } from './jira-paginated-search.js';
33
33
  import axios, { AxiosInstance } from 'axios';
34
+ import { CircuitBreakerRegistry } from '../../../src/core/sync/circuit-breaker-registry.js';
35
+ import { SyncRetryQueue } from '../../../src/core/sync/sync-retry-queue.js';
36
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
37
+ import { LockManager } from '../../../src/utils/lock-manager.js';
34
38
 
35
39
  /**
36
40
  * Build a JIRA story description from a UserStory.
@@ -88,16 +92,36 @@ export interface JiraConfig {
88
92
  projectKey: string; // e.g., SPEC
89
93
  }
90
94
 
95
+ export interface JiraSpecSyncOptions {
96
+ circuitBreakerRegistry?: CircuitBreakerRegistry;
97
+ retryQueue?: SyncRetryQueue;
98
+ lockDir?: string;
99
+ incrementId?: string;
100
+ featureId?: string;
101
+ }
102
+
91
103
  export class JiraSpecSync {
92
104
  private specManager: SpecMetadataManager;
93
105
  private client: AxiosInstance;
94
106
  private config: JiraConfig;
95
107
  private projectRoot: string;
108
+ private circuitBreakerRegistry?: CircuitBreakerRegistry;
109
+ private retryQueue?: SyncRetryQueue;
110
+ private lockManager?: LockManager;
111
+ private incrementId: string;
112
+ private featureId: string;
96
113
 
97
- constructor(config: JiraConfig, projectRoot: string = process.cwd(), projectId?: string) {
114
+ constructor(config: JiraConfig, projectRoot: string = process.cwd(), projectId?: string, options?: JiraSpecSyncOptions) {
98
115
  this.projectRoot = projectRoot;
99
116
  this.specManager = new SpecMetadataManager(projectRoot, projectId);
100
117
  this.config = config;
118
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
119
+ this.retryQueue = options?.retryQueue;
120
+ this.incrementId = options?.incrementId ?? '';
121
+ this.featureId = options?.featureId ?? '';
122
+ if (options?.lockDir) {
123
+ this.lockManager = new LockManager(options.lockDir);
124
+ }
101
125
 
102
126
  // Create Jira API client — baseURL set dynamically via init()
103
127
  this.client = axios.create({
@@ -113,6 +137,63 @@ export class JiraSpecSync {
113
137
  });
114
138
  }
115
139
 
140
+ /**
141
+ * Execute fn under file lock (if lockManager configured).
142
+ */
143
+ private async withLock<T>(fn: () => Promise<T>): Promise<T> {
144
+ if (!this.lockManager) return fn();
145
+ const acquired = await this.lockManager.acquire();
146
+ if (!acquired) {
147
+ throw new SyncError('jira', 0, '', 'Failed to acquire JIRA sync lock');
148
+ }
149
+ try {
150
+ return await fn();
151
+ } finally {
152
+ await this.lockManager.release();
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Check circuit breaker before making API calls.
158
+ */
159
+ private checkCircuitBreaker(): void {
160
+ if (!this.circuitBreakerRegistry) return;
161
+ const breaker = this.circuitBreakerRegistry.get('jira');
162
+ if (!breaker.canSync()) {
163
+ throw new SyncError('jira', 0, '', 'Circuit breaker open for jira');
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Record success on circuit breaker.
169
+ */
170
+ private recordApiSuccess(): void {
171
+ if (!this.circuitBreakerRegistry) return;
172
+ this.circuitBreakerRegistry.get('jira').recordSuccess();
173
+ }
174
+
175
+ /**
176
+ * Record failure on circuit breaker and optionally enqueue retry.
177
+ */
178
+ private async recordApiFailure(error: unknown, operation: string): Promise<void> {
179
+ const httpStatus = (error as any)?.response?.status ?? 0;
180
+
181
+ if (this.circuitBreakerRegistry) {
182
+ this.circuitBreakerRegistry.get('jira').recordFailure();
183
+ }
184
+
185
+ if (this.retryQueue && httpStatus >= 500) {
186
+ await this.retryQueue.enqueue({
187
+ incrementId: this.incrementId,
188
+ provider: 'jira',
189
+ featureId: this.featureId,
190
+ projectPath: this.projectRoot,
191
+ projectName: this.config.projectKey,
192
+ error: `${httpStatus} ${operation}: ${(error as Error)?.message ?? String(error)}`,
193
+ });
194
+ }
195
+ }
196
+
116
197
  /**
117
198
  * Initialize: detect deployment type and update client baseURL
118
199
  */
@@ -130,6 +211,9 @@ export class JiraSpecSync {
130
211
  async syncSpecToJira(specId: string): Promise<SpecSyncResult> {
131
212
  console.log(`\n🔄 Syncing spec ${specId} to Jira Epic...`);
132
213
 
214
+ return this.withLock(async () => {
215
+ this.checkCircuitBreaker();
216
+
133
217
  try {
134
218
  // 1. Load spec
135
219
  const spec = await this.specManager.loadSpec(specId);
@@ -168,6 +252,7 @@ export class JiraSpecSync {
168
252
  // 3. Sync user stories as Jira Stories
169
253
  const changes = await this.syncUserStories(epic.key, spec);
170
254
 
255
+ this.recordApiSuccess();
171
256
  console.log('✅ Sync complete!');
172
257
 
173
258
  return {
@@ -180,6 +265,7 @@ export class JiraSpecSync {
180
265
  };
181
266
 
182
267
  } catch (error: any) {
268
+ await this.recordApiFailure(error, 'syncSpecToJira');
183
269
  const axiosData = error?.response?.data;
184
270
  const detail = axiosData ? JSON.stringify(axiosData) : '';
185
271
  console.error('❌ Error syncing to Jira:', error?.message || error, detail ? `\n Response: ${detail}` : '');
@@ -190,6 +276,7 @@ export class JiraSpecSync {
190
276
  error: error instanceof Error ? error.message : 'Unknown error'
191
277
  };
192
278
  }
279
+ });
193
280
  }
194
281
 
195
282
  /**
@@ -198,6 +285,9 @@ export class JiraSpecSync {
198
285
  async syncFromJira(specId: string): Promise<SpecSyncResult> {
199
286
  console.log(`\n🔄 Syncing FROM Jira to spec ${specId}...`);
200
287
 
288
+ return this.withLock(async () => {
289
+ this.checkCircuitBreaker();
290
+
201
291
  try {
202
292
  // 1. Load spec
203
293
  const spec = await this.specManager.loadSpec(specId);
@@ -258,6 +348,7 @@ export class JiraSpecSync {
258
348
  };
259
349
 
260
350
  } catch (error) {
351
+ await this.recordApiFailure(error, 'syncFromJira');
261
352
  console.error('❌ Error syncing FROM Jira:', error);
262
353
  return {
263
354
  success: false,
@@ -266,6 +357,7 @@ export class JiraSpecSync {
266
357
  error: error instanceof Error ? error.message : 'Unknown error'
267
358
  };
268
359
  }
360
+ });
269
361
  }
270
362
 
271
363
  /**
@@ -1,10 +1,23 @@
1
1
  import axios from "axios";
2
2
  import { detectDeploymentType, getApiBaseUrl } from "./jira-deployment-detector.js";
3
3
  import { toCommentBody } from "./content-format-adapter.js";
4
+ import { CircuitBreakerRegistry } from "../../../src/core/sync/circuit-breaker-registry.js";
5
+ import { SyncRetryQueue } from "../../../src/core/sync/sync-retry-queue.js";
6
+ import { SyncError } from "../../../src/core/errors/sync-error.js";
7
+ import { LockManager } from "../../../src/utils/lock-manager.js";
4
8
  class JiraStatusSync {
5
- constructor(domain, email, apiToken, projectKey) {
9
+ constructor(domain, email, apiToken, projectKey, options) {
6
10
  this.domain = domain;
7
11
  this.projectKey = projectKey;
12
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
13
+ this.retryQueue = options?.retryQueue;
14
+ this.incrementId = options?.incrementId ?? "";
15
+ this.featureId = options?.featureId ?? "";
16
+ this.projectPath = options?.projectPath ?? "";
17
+ this.projectName = options?.projectName ?? "";
18
+ if (options?.lockDir) {
19
+ this.lockManager = new LockManager(options.lockDir);
20
+ }
8
21
  this.client = axios.create({
9
22
  baseURL: getApiBaseUrl(domain),
10
23
  auth: {
@@ -17,9 +30,48 @@ class JiraStatusSync {
17
30
  }
18
31
  });
19
32
  }
20
- /**
21
- * Initialize: detect deployment type and update client baseURL
22
- */
33
+ async withLock(fn) {
34
+ if (!this.lockManager) return fn();
35
+ const acquired = await this.lockManager.acquire();
36
+ if (!acquired) {
37
+ throw new SyncError("jira", 0, "", "Failed to acquire JIRA sync lock");
38
+ }
39
+ try {
40
+ return await fn();
41
+ } finally {
42
+ await this.lockManager.release();
43
+ }
44
+ }
45
+ checkCircuitBreaker() {
46
+ if (!this.circuitBreakerRegistry) return;
47
+ const breaker = this.circuitBreakerRegistry.get("jira");
48
+ if (!breaker.canSync()) {
49
+ throw new SyncError("jira", 0, "", "Circuit breaker open for jira");
50
+ }
51
+ }
52
+ recordSuccess() {
53
+ if (!this.circuitBreakerRegistry) return;
54
+ this.circuitBreakerRegistry.get("jira").recordSuccess();
55
+ }
56
+ async handleApiError(error, operation) {
57
+ const httpStatus = error?.response?.status ?? 0;
58
+ const responseBody = JSON.stringify(error?.response?.data ?? "");
59
+ const detail = error?.message ?? String(error);
60
+ if (this.circuitBreakerRegistry) {
61
+ this.circuitBreakerRegistry.get("jira").recordFailure();
62
+ }
63
+ if (this.retryQueue && httpStatus >= 500) {
64
+ await this.retryQueue.enqueue({
65
+ incrementId: this.incrementId,
66
+ provider: "jira",
67
+ featureId: this.featureId,
68
+ projectPath: this.projectPath,
69
+ projectName: this.projectName,
70
+ error: `${httpStatus} ${operation}: ${detail}`
71
+ });
72
+ }
73
+ throw new SyncError("jira", httpStatus, responseBody, detail);
74
+ }
23
75
  async init() {
24
76
  const deployment = await detectDeploymentType(this.domain, {
25
77
  email: this.client.defaults.auth?.username || "",
@@ -27,59 +79,52 @@ class JiraStatusSync {
27
79
  });
28
80
  this.client.defaults.baseURL = deployment.baseUrl;
29
81
  }
30
- /**
31
- * Get current status from JIRA issue
32
- *
33
- * @param issueKey - JIRA issue key (e.g., PROJ-123)
34
- * @returns Current issue status
35
- */
36
82
  async getStatus(issueKey) {
37
- const response = await this.client.get(`/issue/${issueKey}`);
38
- return {
39
- state: response.data.fields.status.name
40
- };
83
+ return this.withLock(async () => {
84
+ this.checkCircuitBreaker();
85
+ try {
86
+ const response = await this.client.get(`/issue/${issueKey}`);
87
+ this.recordSuccess();
88
+ return {
89
+ state: response.data.fields.status.name
90
+ };
91
+ } catch (error) {
92
+ return this.handleApiError(error, "getStatus");
93
+ }
94
+ });
41
95
  }
42
- /**
43
- * Update JIRA issue status via transitions
44
- *
45
- * JIRA requires using transitions to change status.
46
- * Cannot directly set status field.
47
- *
48
- * Handles missing transitions gracefully by logging a warning
49
- * instead of throwing an error.
50
- *
51
- * @param issueKey - JIRA issue key (e.g., PROJ-123)
52
- * @param status - Desired status
53
- * @returns true if transition succeeded, false if not available
54
- */
55
96
  async updateStatus(issueKey, status) {
56
- const transitionsResponse = await this.client.get(`/issue/${issueKey}/transitions`);
57
- const transitions = transitionsResponse.data.transitions;
58
- const targetTransition = transitions.find(
59
- (t) => t.to.name.toLowerCase() === status.state.toLowerCase()
60
- );
61
- if (!targetTransition) {
62
- console.warn(
63
- `\u26A0\uFE0F Cannot transition ${issueKey} to "${status.state}". Available transitions: ${transitions.map((t) => t.to.name).join(", ")}. This may be expected if your JIRA workflow doesn't support this status.`
64
- );
65
- return false;
66
- }
67
- await this.client.post(`/issue/${issueKey}/transitions`, {
68
- transition: {
69
- id: targetTransition.id
97
+ return this.withLock(async () => {
98
+ this.checkCircuitBreaker();
99
+ try {
100
+ const transitionsResponse = await this.client.get(`/issue/${issueKey}/transitions`);
101
+ const transitions = transitionsResponse.data.transitions;
102
+ const targetTransition = transitions.find(
103
+ (t) => t.to.name.toLowerCase() === status.state.toLowerCase()
104
+ );
105
+ if (!targetTransition) {
106
+ console.warn(
107
+ `\u26A0\uFE0F Cannot transition ${issueKey} to "${status.state}". Available transitions: ${transitions.map((t) => t.to.name).join(", ")}. This may be expected if your JIRA workflow doesn't support this status.`
108
+ );
109
+ return false;
110
+ }
111
+ await this.client.post(`/issue/${issueKey}/transitions`, {
112
+ transition: {
113
+ id: targetTransition.id
114
+ }
115
+ });
116
+ this.recordSuccess();
117
+ return true;
118
+ } catch (error) {
119
+ return this.handleApiError(error, "updateStatus");
70
120
  }
71
121
  });
72
- return true;
73
122
  }
74
- /**
75
- * Post comment about status change to JIRA issue
76
- *
77
- * @param issueKey - JIRA issue key (e.g., PROJ-123)
78
- * @param oldStatus - Previous SpecWeave status
79
- * @param newStatus - New SpecWeave status
80
- */
81
123
  async postStatusComment(issueKey, oldStatus, newStatus) {
82
- const rawBody = `*Status Update*
124
+ return this.withLock(async () => {
125
+ this.checkCircuitBreaker();
126
+ try {
127
+ const rawBody = `*Status Update*
83
128
 
84
129
  SpecWeave status changed:
85
130
  * *From*: ${oldStatus}
@@ -87,73 +132,70 @@ SpecWeave status changed:
87
132
  * *When*: ${(/* @__PURE__ */ new Date()).toISOString()}
88
133
 
89
134
  _Synced from SpecWeave_`;
90
- const body = toCommentBody(rawBody, this.domain);
91
- await this.client.post(`/issue/${issueKey}/comment`, {
92
- body
135
+ const body = toCommentBody(rawBody, this.domain);
136
+ await this.client.post(`/issue/${issueKey}/comment`, {
137
+ body
138
+ });
139
+ this.recordSuccess();
140
+ } catch (error) {
141
+ return this.handleApiError(error, "postStatusComment");
142
+ }
93
143
  });
94
144
  }
95
- /**
96
- * Post AC progress comment with proper ADF formatting and dedup.
97
- *
98
- * Builds native ADF with:
99
- * - Bold header showing completion percentage
100
- * - Bullet list with checkmark/cross emojis per AC
101
- * - Fingerprint marker to prevent duplicate comments
102
- *
103
- * @param issueKey - JIRA issue key (e.g., PROJ-123)
104
- * @param acStates - Array of AC states with id, description, completed
105
- * @returns true if comment was posted, false if skipped (duplicate)
106
- */
107
145
  async postProgressComment(issueKey, acStates) {
108
- const total = acStates.length;
109
- const completed = acStates.filter((ac) => ac.completed).length;
110
- const percentage = Math.round(completed / total * 100);
111
- const fingerprint = `sw-progress:${completed}/${total}`;
112
- try {
113
- const commentsResp = await this.client.get(`/issue/${issueKey}/comment`, {
114
- params: { orderBy: "-created", maxResults: 1 }
115
- });
116
- const lastComment = commentsResp.data?.comments?.[0];
117
- if (lastComment) {
118
- const lastText = extractAdfText(lastComment.body);
119
- if (lastText.includes(fingerprint)) {
120
- return false;
146
+ return this.withLock(async () => {
147
+ this.checkCircuitBreaker();
148
+ const total = acStates.length;
149
+ const completed = acStates.filter((ac) => ac.completed).length;
150
+ const percentage = Math.round(completed / total * 100);
151
+ const fingerprint = `sw-progress:${completed}/${total}`;
152
+ try {
153
+ const commentsResp = await this.client.get(`/issue/${issueKey}/comment`, {
154
+ params: { orderBy: "-created", maxResults: 1 }
155
+ });
156
+ const lastComment = commentsResp.data?.comments?.[0];
157
+ if (lastComment) {
158
+ const lastText = extractAdfText(lastComment.body);
159
+ if (lastText.includes(fingerprint)) {
160
+ return false;
161
+ }
121
162
  }
163
+ } catch {
122
164
  }
123
- } catch {
124
- }
125
- const listItems = acStates.map((ac) => ({
126
- type: "listItem",
127
- content: [{
128
- type: "paragraph",
129
- content: [
130
- { type: "text", text: `${ac.completed ? "\u2705" : "\u274C"} ${ac.id}: ${ac.description}` }
131
- ]
132
- }]
133
- }));
134
- const body = {
135
- type: "doc",
136
- version: 1,
137
- content: [
138
- {
139
- type: "heading",
140
- attrs: { level: 3 },
141
- content: [{ type: "text", text: `Progress: ${completed}/${total} ACs (${percentage}%)` }]
142
- },
143
- {
144
- type: "bulletList",
145
- content: listItems
146
- },
147
- {
165
+ const listItems = acStates.map((ac) => ({
166
+ type: "listItem",
167
+ content: [{
148
168
  type: "paragraph",
149
169
  content: [
150
- { type: "text", text: `${fingerprint} | Synced from SpecWeave`, marks: [{ type: "em" }] }
170
+ { type: "text", text: `${ac.completed ? "\u2705" : "\u274C"} ${ac.id}: ${ac.description}` }
151
171
  ]
152
- }
153
- ]
154
- };
155
- await this.client.post(`/issue/${issueKey}/comment`, { body });
156
- return true;
172
+ }]
173
+ }));
174
+ const body = {
175
+ type: "doc",
176
+ version: 1,
177
+ content: [
178
+ {
179
+ type: "heading",
180
+ attrs: { level: 3 },
181
+ content: [{ type: "text", text: `Progress: ${completed}/${total} ACs (${percentage}%)` }]
182
+ },
183
+ {
184
+ type: "bulletList",
185
+ content: listItems
186
+ },
187
+ {
188
+ type: "paragraph",
189
+ content: [
190
+ { type: "text", text: `${fingerprint} | Synced from SpecWeave`, marks: [{ type: "em" }] }
191
+ ]
192
+ }
193
+ ]
194
+ };
195
+ await this.client.post(`/issue/${issueKey}/comment`, { body });
196
+ this.recordSuccess();
197
+ return true;
198
+ });
157
199
  }
158
200
  }
159
201
  function extractAdfText(adf) {