specweave 1.0.488 → 1.0.490

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 (151) 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 +14 -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 +29 -1
  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/PLUGIN.md +1 -0
  123. package/plugins/specweave/hooks/v2/guards/increment-existence-guard.sh +9 -3
  124. package/plugins/specweave/skills/code-reviewer/SKILL.md +401 -0
  125. package/plugins/specweave/skills/code-reviewer/agents/reviewer-silent-failures.md +65 -0
  126. package/plugins/specweave/skills/code-reviewer/agents/reviewer-spec-compliance.md +83 -0
  127. package/plugins/specweave/skills/code-reviewer/agents/reviewer-types.md +68 -0
  128. package/plugins/specweave/skills/skill-gen/SKILL.md +20 -3
  129. package/plugins/specweave/skills/team-lead/SKILL.md +155 -4
  130. package/plugins/specweave/skills/team-lead/agents/architect.md +52 -0
  131. package/plugins/specweave/skills/team-lead/agents/pm.md +50 -0
  132. package/plugins/specweave/skills/team-lead/agents/researcher.md +64 -0
  133. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +23 -21
  134. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +37 -29
  135. package/plugins/specweave-ado/lib/ado-client.js +27 -1
  136. package/plugins/specweave-ado/lib/ado-client.ts +37 -2
  137. package/plugins/specweave-ado/lib/ado-pull-sync.js +35 -0
  138. package/plugins/specweave-ado/lib/ado-pull-sync.ts +74 -0
  139. package/plugins/specweave-ado/lib/ado-rate-limiter.js +56 -0
  140. package/plugins/specweave-ado/lib/ado-rate-limiter.ts +86 -0
  141. package/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
  142. package/plugins/specweave-ado/lib/ado-spec-sync.ts +32 -2
  143. package/plugins/specweave-ado/lib/ado-status-sync.js +52 -14
  144. package/plugins/specweave-ado/lib/ado-status-sync.ts +64 -16
  145. package/plugins/specweave-github/lib/github-client-v2.ts +1 -1
  146. package/plugins/specweave-github/lib/github-push-sync.js +11 -3
  147. package/plugins/specweave-github/lib/github-push-sync.ts +16 -3
  148. package/plugins/specweave-jira/lib/jira-spec-sync.js +60 -1
  149. package/plugins/specweave-jira/lib/jira-spec-sync.ts +93 -1
  150. package/plugins/specweave-jira/lib/jira-status-sync.js +151 -109
  151. package/plugins/specweave-jira/lib/jira-status-sync.ts +161 -39
@@ -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) {
@@ -14,6 +14,10 @@
14
14
  import axios, { AxiosInstance } from 'axios';
15
15
  import { detectDeploymentType, getApiBaseUrl } from './jira-deployment-detector.js';
16
16
  import { toCommentBody, type AdfDocument, type AdfNode } from './content-format-adapter.js';
17
+ import { CircuitBreakerRegistry } from '../../../src/core/sync/circuit-breaker-registry.js';
18
+ import { SyncRetryQueue } from '../../../src/core/sync/sync-retry-queue.js';
19
+ import { SyncError } from '../../../src/core/errors/sync-error.js';
20
+ import { LockManager } from '../../../src/utils/lock-manager.js';
17
21
 
18
22
  /**
19
23
  * External status representation (JIRA-specific)
@@ -34,6 +38,16 @@ interface JiraTransition {
34
38
  };
35
39
  }
36
40
 
41
+ export interface JiraStatusSyncOptions {
42
+ circuitBreakerRegistry?: CircuitBreakerRegistry;
43
+ retryQueue?: SyncRetryQueue;
44
+ incrementId?: string;
45
+ featureId?: string;
46
+ projectPath?: string;
47
+ projectName?: string;
48
+ lockDir?: string;
49
+ }
50
+
37
51
  /**
38
52
  * JIRA Status Sync
39
53
  *
@@ -43,15 +57,32 @@ export class JiraStatusSync {
43
57
  private client: AxiosInstance;
44
58
  private domain: string;
45
59
  private projectKey: string;
60
+ private circuitBreakerRegistry?: CircuitBreakerRegistry;
61
+ private retryQueue?: SyncRetryQueue;
62
+ private lockManager?: LockManager;
63
+ private incrementId: string;
64
+ private featureId: string;
65
+ private projectPath: string;
66
+ private projectName: string;
46
67
 
47
68
  constructor(
48
69
  domain: string,
49
70
  email: string,
50
71
  apiToken: string,
51
- projectKey: string
72
+ projectKey: string,
73
+ options?: JiraStatusSyncOptions,
52
74
  ) {
53
75
  this.domain = domain;
54
76
  this.projectKey = projectKey;
77
+ this.circuitBreakerRegistry = options?.circuitBreakerRegistry;
78
+ this.retryQueue = options?.retryQueue;
79
+ this.incrementId = options?.incrementId ?? '';
80
+ this.featureId = options?.featureId ?? '';
81
+ this.projectPath = options?.projectPath ?? '';
82
+ this.projectName = options?.projectName ?? '';
83
+ if (options?.lockDir) {
84
+ this.lockManager = new LockManager(options.lockDir);
85
+ }
55
86
 
56
87
  // Create JIRA API client — baseURL set dynamically via init()
57
88
  this.client = axios.create({
@@ -67,6 +98,70 @@ export class JiraStatusSync {
67
98
  });
68
99
  }
69
100
 
101
+ /**
102
+ * Execute fn under file lock (if lockManager configured).
103
+ */
104
+ private async withLock<T>(fn: () => Promise<T>): Promise<T> {
105
+ if (!this.lockManager) return fn();
106
+ const acquired = await this.lockManager.acquire();
107
+ if (!acquired) {
108
+ throw new SyncError('jira', 0, '', 'Failed to acquire JIRA sync lock');
109
+ }
110
+ try {
111
+ return await fn();
112
+ } finally {
113
+ await this.lockManager.release();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Check circuit breaker before making API calls.
119
+ * Throws if breaker is open.
120
+ */
121
+ private checkCircuitBreaker(): void {
122
+ if (!this.circuitBreakerRegistry) return;
123
+ const breaker = this.circuitBreakerRegistry.get('jira');
124
+ if (!breaker.canSync()) {
125
+ throw new SyncError('jira', 0, '', 'Circuit breaker open for jira');
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Record success on circuit breaker.
131
+ */
132
+ private recordSuccess(): void {
133
+ if (!this.circuitBreakerRegistry) return;
134
+ this.circuitBreakerRegistry.get('jira').recordSuccess();
135
+ }
136
+
137
+ /**
138
+ * Handle API error: record failure on circuit breaker, enqueue retry, throw SyncError.
139
+ */
140
+ private async handleApiError(error: unknown, operation: string): Promise<never> {
141
+ const httpStatus = (error as any)?.response?.status ?? 0;
142
+ const responseBody = JSON.stringify((error as any)?.response?.data ?? '');
143
+ const detail = (error as Error)?.message ?? String(error);
144
+
145
+ // Record failure on circuit breaker
146
+ if (this.circuitBreakerRegistry) {
147
+ this.circuitBreakerRegistry.get('jira').recordFailure();
148
+ }
149
+
150
+ // Enqueue for retry on server errors (5xx)
151
+ if (this.retryQueue && httpStatus >= 500) {
152
+ await this.retryQueue.enqueue({
153
+ incrementId: this.incrementId,
154
+ provider: 'jira',
155
+ featureId: this.featureId,
156
+ projectPath: this.projectPath,
157
+ projectName: this.projectName,
158
+ error: `${httpStatus} ${operation}: ${detail}`,
159
+ });
160
+ }
161
+
162
+ throw new SyncError('jira', httpStatus, responseBody, detail);
163
+ }
164
+
70
165
  /**
71
166
  * Initialize: detect deployment type and update client baseURL
72
167
  */
@@ -85,11 +180,18 @@ export class JiraStatusSync {
85
180
  * @returns Current issue status
86
181
  */
87
182
  async getStatus(issueKey: string): Promise<ExternalStatus> {
88
- const response = await this.client.get(`/issue/${issueKey}`);
89
-
90
- return {
91
- state: response.data.fields.status.name
92
- };
183
+ return this.withLock(async () => {
184
+ this.checkCircuitBreaker();
185
+ try {
186
+ const response = await this.client.get(`/issue/${issueKey}`);
187
+ this.recordSuccess();
188
+ return {
189
+ state: response.data.fields.status.name
190
+ };
191
+ } catch (error) {
192
+ return this.handleApiError(error, 'getStatus');
193
+ }
194
+ });
93
195
  }
94
196
 
95
197
  /**
@@ -106,33 +208,41 @@ export class JiraStatusSync {
106
208
  * @returns true if transition succeeded, false if not available
107
209
  */
108
210
  async updateStatus(issueKey: string, status: ExternalStatus): Promise<boolean> {
109
- // 1. Get available transitions for this issue
110
- const transitionsResponse = await this.client.get(`/issue/${issueKey}/transitions`);
111
- const transitions: JiraTransition[] = transitionsResponse.data.transitions;
112
-
113
- // 2. Find transition that leads to desired status (case-insensitive)
114
- const targetTransition = transitions.find(
115
- (t) => t.to.name.toLowerCase() === status.state.toLowerCase()
116
- );
117
-
118
- if (!targetTransition) {
119
- // Log warning instead of throwing - workflow may not support this transition
120
- console.warn(
121
- `⚠️ Cannot transition ${issueKey} to "${status.state}". ` +
122
- `Available transitions: ${transitions.map(t => t.to.name).join(', ')}. ` +
123
- `This may be expected if your JIRA workflow doesn't support this status.`
124
- );
125
- return false;
126
- }
211
+ return this.withLock(async () => {
212
+ this.checkCircuitBreaker();
213
+ try {
214
+ // 1. Get available transitions for this issue
215
+ const transitionsResponse = await this.client.get(`/issue/${issueKey}/transitions`);
216
+ const transitions: JiraTransition[] = transitionsResponse.data.transitions;
217
+
218
+ // 2. Find transition that leads to desired status (case-insensitive)
219
+ const targetTransition = transitions.find(
220
+ (t) => t.to.name.toLowerCase() === status.state.toLowerCase()
221
+ );
222
+
223
+ if (!targetTransition) {
224
+ // Log warning instead of throwing - workflow may not support this transition
225
+ console.warn(
226
+ `⚠️ Cannot transition ${issueKey} to "${status.state}". ` +
227
+ `Available transitions: ${transitions.map(t => t.to.name).join(', ')}. ` +
228
+ `This may be expected if your JIRA workflow doesn't support this status.`
229
+ );
230
+ return false;
231
+ }
127
232
 
128
- // 3. Execute transition
129
- await this.client.post(`/issue/${issueKey}/transitions`, {
130
- transition: {
131
- id: targetTransition.id
233
+ // 3. Execute transition
234
+ await this.client.post(`/issue/${issueKey}/transitions`, {
235
+ transition: {
236
+ id: targetTransition.id
237
+ }
238
+ });
239
+
240
+ this.recordSuccess();
241
+ return true;
242
+ } catch (error) {
243
+ return this.handleApiError(error, 'updateStatus');
132
244
  }
133
245
  });
134
-
135
- return true;
136
246
  }
137
247
 
138
248
  /**
@@ -147,17 +257,25 @@ export class JiraStatusSync {
147
257
  oldStatus: string,
148
258
  newStatus: string
149
259
  ): Promise<void> {
150
- const rawBody = `*Status Update*\n\n` +
151
- `SpecWeave status changed:\n` +
152
- `* *From*: ${oldStatus}\n` +
153
- `* *To*: ${newStatus}\n` +
154
- `* *When*: ${new Date().toISOString()}\n\n` +
155
- `_Synced from SpecWeave_`;
260
+ return this.withLock(async () => {
261
+ this.checkCircuitBreaker();
262
+ try {
263
+ const rawBody = `*Status Update*\n\n` +
264
+ `SpecWeave status changed:\n` +
265
+ `* *From*: ${oldStatus}\n` +
266
+ `* *To*: ${newStatus}\n` +
267
+ `* *When*: ${new Date().toISOString()}\n\n` +
268
+ `_Synced from SpecWeave_`;
156
269
 
157
- const body = toCommentBody(rawBody, this.domain);
270
+ const body = toCommentBody(rawBody, this.domain);
158
271
 
159
- await this.client.post(`/issue/${issueKey}/comment`, {
160
- body
272
+ await this.client.post(`/issue/${issueKey}/comment`, {
273
+ body
274
+ });
275
+ this.recordSuccess();
276
+ } catch (error) {
277
+ return this.handleApiError(error, 'postStatusComment');
278
+ }
161
279
  });
162
280
  }
163
281
 
@@ -177,6 +295,8 @@ export class JiraStatusSync {
177
295
  issueKey: string,
178
296
  acStates: Array<{ id: string; description: string; completed: boolean }>,
179
297
  ): Promise<boolean> {
298
+ return this.withLock(async () => {
299
+ this.checkCircuitBreaker();
180
300
  const total = acStates.length;
181
301
  const completed = acStates.filter(ac => ac.completed).length;
182
302
  const percentage = Math.round((completed / total) * 100);
@@ -232,7 +352,9 @@ export class JiraStatusSync {
232
352
  };
233
353
 
234
354
  await this.client.post(`/issue/${issueKey}/comment`, { body });
355
+ this.recordSuccess();
235
356
  return true;
357
+ });
236
358
  }
237
359
  }
238
360