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.
- package/README.md +1 -1
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts +13 -5
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +28 -26
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-client.d.ts +14 -0
- package/dist/plugins/specweave-ado/lib/ado-client.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-client.js +29 -1
- package/dist/plugins/specweave-ado/lib/ado-client.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts +35 -0
- package/dist/plugins/specweave-ado/lib/ado-pull-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-pull-sync.js +53 -0
- package/dist/plugins/specweave-ado/lib/ado-pull-sync.js.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts +46 -0
- package/dist/plugins/specweave-ado/lib/ado-rate-limiter.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js +65 -0
- package/dist/plugins/specweave-ado/lib/ado-rate-limiter.js.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +7 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +17 -1
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js +51 -9
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-client-v2.js +1 -1
- package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-push-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-push-sync.js +15 -3
- package/dist/plugins/specweave-github/lib/github-push-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +31 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +170 -97
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +36 -1
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js +185 -82
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
- package/dist/src/adapters/adapter-loader.d.ts.map +1 -1
- package/dist/src/adapters/adapter-loader.js +8 -2
- package/dist/src/adapters/adapter-loader.js.map +1 -1
- package/dist/src/adapters/codex/adapter.d.ts.map +1 -1
- package/dist/src/adapters/codex/adapter.js +1 -0
- package/dist/src/adapters/codex/adapter.js.map +1 -1
- package/dist/src/adapters/cursor/adapter.d.ts.map +1 -1
- package/dist/src/adapters/cursor/adapter.js +1 -0
- package/dist/src/adapters/cursor/adapter.js.map +1 -1
- package/dist/src/adapters/generic/adapter.d.ts +6 -3
- package/dist/src/adapters/generic/adapter.d.ts.map +1 -1
- package/dist/src/adapters/generic/adapter.js +53 -47
- package/dist/src/adapters/generic/adapter.js.map +1 -1
- package/dist/src/adapters/kimi/adapter.d.ts +21 -0
- package/dist/src/adapters/kimi/adapter.d.ts.map +1 -0
- package/dist/src/adapters/kimi/adapter.js +57 -0
- package/dist/src/adapters/kimi/adapter.js.map +1 -0
- package/dist/src/adapters/opencode/adapter.d.ts +24 -0
- package/dist/src/adapters/opencode/adapter.d.ts.map +1 -0
- package/dist/src/adapters/opencode/adapter.js +71 -0
- package/dist/src/adapters/opencode/adapter.js.map +1 -0
- package/dist/src/adapters/registry.yaml +59 -0
- package/dist/src/adapters/trae/adapter.d.ts +21 -0
- package/dist/src/adapters/trae/adapter.d.ts.map +1 -0
- package/dist/src/adapters/trae/adapter.js +64 -0
- package/dist/src/adapters/trae/adapter.js.map +1 -0
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +156 -5
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/commands/update-instructions.d.ts.map +1 -1
- package/dist/src/cli/commands/update-instructions.js +10 -0
- package/dist/src/cli/commands/update-instructions.js.map +1 -1
- package/dist/src/cli/helpers/init/index.d.ts +1 -0
- package/dist/src/cli/helpers/init/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/index.js +2 -0
- package/dist/src/cli/helpers/init/index.js.map +1 -1
- package/dist/src/cli/helpers/init/next-steps.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/next-steps.js +52 -0
- package/dist/src/cli/helpers/init/next-steps.js.map +1 -1
- package/dist/src/cli/helpers/init/skill-creator-installer.d.ts +24 -0
- package/dist/src/cli/helpers/init/skill-creator-installer.d.ts.map +1 -0
- package/dist/src/cli/helpers/init/skill-creator-installer.js +54 -0
- package/dist/src/cli/helpers/init/skill-creator-installer.js.map +1 -0
- package/dist/src/core/ado-description-updater.d.ts +22 -0
- package/dist/src/core/ado-description-updater.d.ts.map +1 -0
- package/dist/src/core/ado-description-updater.js +46 -0
- package/dist/src/core/ado-description-updater.js.map +1 -0
- package/dist/src/core/closure-dispatcher.d.ts +96 -0
- package/dist/src/core/closure-dispatcher.d.ts.map +1 -0
- package/dist/src/core/closure-dispatcher.js +116 -0
- package/dist/src/core/closure-dispatcher.js.map +1 -0
- package/dist/src/core/config/types.d.ts +2 -0
- package/dist/src/core/config/types.d.ts.map +1 -1
- package/dist/src/core/config/types.js.map +1 -1
- package/dist/src/core/errors/sync-error.d.ts +12 -0
- package/dist/src/core/errors/sync-error.d.ts.map +1 -0
- package/dist/src/core/errors/sync-error.js +19 -0
- package/dist/src/core/errors/sync-error.js.map +1 -0
- package/dist/src/core/skill-gen/rule-collector.d.ts +28 -0
- package/dist/src/core/skill-gen/rule-collector.d.ts.map +1 -0
- package/dist/src/core/skill-gen/rule-collector.js +112 -0
- package/dist/src/core/skill-gen/rule-collector.js.map +1 -0
- package/dist/src/core/skill-gen/signal-collector.d.ts +2 -1
- package/dist/src/core/skill-gen/signal-collector.d.ts.map +1 -1
- package/dist/src/core/skill-gen/signal-collector.js +21 -5
- package/dist/src/core/skill-gen/signal-collector.js.map +1 -1
- package/dist/src/core/sync/persistent-circuit-breaker.d.ts +22 -0
- package/dist/src/core/sync/persistent-circuit-breaker.d.ts.map +1 -0
- package/dist/src/core/sync/persistent-circuit-breaker.js +65 -0
- package/dist/src/core/sync/persistent-circuit-breaker.js.map +1 -0
- package/dist/src/core/sync/retry-wrapper.d.ts +13 -0
- package/dist/src/core/sync/retry-wrapper.d.ts.map +1 -0
- package/dist/src/core/sync/retry-wrapper.js +37 -0
- package/dist/src/core/sync/retry-wrapper.js.map +1 -0
- package/dist/src/importers/ac-parser.d.ts +27 -0
- package/dist/src/importers/ac-parser.d.ts.map +1 -0
- package/dist/src/importers/ac-parser.js +47 -0
- package/dist/src/importers/ac-parser.js.map +1 -0
- package/dist/src/sync/types.d.ts +8 -0
- package/dist/src/sync/types.d.ts.map +1 -1
- package/dist/src/sync/types.js +12 -0
- package/dist/src/sync/types.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/PLUGIN.md +1 -0
- package/plugins/specweave/hooks/v2/guards/increment-existence-guard.sh +9 -3
- package/plugins/specweave/skills/code-reviewer/SKILL.md +401 -0
- package/plugins/specweave/skills/code-reviewer/agents/reviewer-silent-failures.md +65 -0
- package/plugins/specweave/skills/code-reviewer/agents/reviewer-spec-compliance.md +83 -0
- package/plugins/specweave/skills/code-reviewer/agents/reviewer-types.md +68 -0
- package/plugins/specweave/skills/skill-gen/SKILL.md +20 -3
- package/plugins/specweave/skills/team-lead/SKILL.md +155 -4
- package/plugins/specweave/skills/team-lead/agents/architect.md +52 -0
- package/plugins/specweave/skills/team-lead/agents/pm.md +50 -0
- package/plugins/specweave/skills/team-lead/agents/researcher.md +64 -0
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +23 -21
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +37 -29
- package/plugins/specweave-ado/lib/ado-client.js +27 -1
- package/plugins/specweave-ado/lib/ado-client.ts +37 -2
- package/plugins/specweave-ado/lib/ado-pull-sync.js +35 -0
- package/plugins/specweave-ado/lib/ado-pull-sync.ts +74 -0
- package/plugins/specweave-ado/lib/ado-rate-limiter.js +56 -0
- package/plugins/specweave-ado/lib/ado-rate-limiter.ts +86 -0
- package/plugins/specweave-ado/lib/ado-spec-sync.js +25 -1
- package/plugins/specweave-ado/lib/ado-spec-sync.ts +32 -2
- package/plugins/specweave-ado/lib/ado-status-sync.js +52 -14
- package/plugins/specweave-ado/lib/ado-status-sync.ts +64 -16
- package/plugins/specweave-github/lib/github-client-v2.ts +1 -1
- package/plugins/specweave-github/lib/github-push-sync.js +11 -3
- package/plugins/specweave-github/lib/github-push-sync.ts +16 -3
- package/plugins/specweave-jira/lib/jira-spec-sync.js +60 -1
- package/plugins/specweave-jira/lib/jira-spec-sync.ts +93 -1
- package/plugins/specweave-jira/lib/jira-status-sync.js +151 -109
- package/plugins/specweave-jira/lib/jira-status-sync.ts +161 -39
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* @module ado-status-sync
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import axios, { AxiosInstance } from 'axios';
|
|
14
|
+
import axios, { AxiosInstance, AxiosError } from 'axios';
|
|
15
|
+
import { SyncCircuitBreaker } from '../../../src/core/increment/sync-circuit-breaker.js';
|
|
16
|
+
import { withRetry } from '../../../src/core/sync/retry-wrapper.js';
|
|
17
|
+
import { SyncError } from '../../../src/core/errors/sync-error.js';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* External status representation (ADO-specific)
|
|
@@ -25,19 +28,24 @@ export interface ExternalStatus {
|
|
|
25
28
|
* Azure DevOps Status Sync
|
|
26
29
|
*
|
|
27
30
|
* Handles status synchronization with ADO work items.
|
|
31
|
+
* Integrates circuit breaker (opens after 3 consecutive failures)
|
|
32
|
+
* and retry with exponential backoff for transient errors.
|
|
28
33
|
*/
|
|
29
34
|
export class AdoStatusSync {
|
|
30
35
|
private client: AxiosInstance;
|
|
31
36
|
private organization: string;
|
|
32
37
|
private project: string;
|
|
38
|
+
private breaker: SyncCircuitBreaker;
|
|
33
39
|
|
|
34
40
|
constructor(
|
|
35
41
|
organization: string,
|
|
36
42
|
project: string,
|
|
37
|
-
personalAccessToken: string
|
|
43
|
+
personalAccessToken: string,
|
|
44
|
+
breaker?: SyncCircuitBreaker
|
|
38
45
|
) {
|
|
39
46
|
this.organization = organization;
|
|
40
47
|
this.project = project;
|
|
48
|
+
this.breaker = breaker ?? new SyncCircuitBreaker();
|
|
41
49
|
|
|
42
50
|
// Create ADO API client
|
|
43
51
|
this.client = axios.create({
|
|
@@ -60,13 +68,13 @@ export class AdoStatusSync {
|
|
|
60
68
|
* @returns Current work item state
|
|
61
69
|
*/
|
|
62
70
|
async getStatus(workItemId: number): Promise<ExternalStatus> {
|
|
63
|
-
|
|
64
|
-
`/wit/workitems/${workItemId}?api-version=7.0`
|
|
65
|
-
);
|
|
71
|
+
this.assertCircuitClosed();
|
|
66
72
|
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
return this.withResilienceWrapper(() =>
|
|
74
|
+
this.client.get(`/wit/workitems/${workItemId}?api-version=7.0`).then(response => ({
|
|
75
|
+
state: response.data.fields['System.State'] as string,
|
|
76
|
+
}))
|
|
77
|
+
);
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
/**
|
|
@@ -79,6 +87,7 @@ export class AdoStatusSync {
|
|
|
79
87
|
* @param status - Desired status with state and optional tags
|
|
80
88
|
*/
|
|
81
89
|
async updateStatus(workItemId: number, status: ExternalStatus): Promise<void> {
|
|
90
|
+
this.assertCircuitClosed();
|
|
82
91
|
// ADO uses JSON Patch format for updates
|
|
83
92
|
const patch: Array<{ op: string; path: string; value: string }> = [
|
|
84
93
|
{
|
|
@@ -109,12 +118,18 @@ export class AdoStatusSync {
|
|
|
109
118
|
});
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
await this.
|
|
113
|
-
`/wit/workitems/${workItemId}?api-version=7.0`,
|
|
114
|
-
patch
|
|
121
|
+
await this.withResilienceWrapper(() =>
|
|
122
|
+
this.client.patch(`/wit/workitems/${workItemId}?api-version=7.0`, patch)
|
|
115
123
|
);
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Check whether the circuit is closed (sync allowed).
|
|
128
|
+
*/
|
|
129
|
+
canSync(): boolean {
|
|
130
|
+
return this.breaker.canSync();
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
/**
|
|
119
134
|
* Get current tags from ADO work item
|
|
120
135
|
*
|
|
@@ -148,6 +163,8 @@ export class AdoStatusSync {
|
|
|
148
163
|
oldStatus: string,
|
|
149
164
|
newStatus: string
|
|
150
165
|
): Promise<void> {
|
|
166
|
+
this.assertCircuitClosed();
|
|
167
|
+
|
|
151
168
|
const text = `š Status Update\n\n` +
|
|
152
169
|
`SpecWeave status changed:\n` +
|
|
153
170
|
`⢠From: ${oldStatus}\n` +
|
|
@@ -155,11 +172,42 @@ export class AdoStatusSync {
|
|
|
155
172
|
`⢠When: ${new Date().toISOString()}\n\n` +
|
|
156
173
|
`Synced from SpecWeave`;
|
|
157
174
|
|
|
158
|
-
await this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
text
|
|
162
|
-
|
|
175
|
+
await this.withResilienceWrapper(() =>
|
|
176
|
+
this.client.post(
|
|
177
|
+
`/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
|
|
178
|
+
{ text }
|
|
179
|
+
)
|
|
163
180
|
);
|
|
164
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Assert the circuit is closed. Throws CircuitOpenError if open.
|
|
185
|
+
*/
|
|
186
|
+
private assertCircuitClosed(): void {
|
|
187
|
+
if (!this.breaker.canSync()) {
|
|
188
|
+
throw new SyncError('ado', 503, '', 'Circuit breaker open ā sync blocked');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Wrap an async operation with retry + circuit breaker recording.
|
|
194
|
+
*/
|
|
195
|
+
private async withResilienceWrapper<T>(fn: () => Promise<T>): Promise<T> {
|
|
196
|
+
try {
|
|
197
|
+
const result = await withRetry(fn, { maxRetries: 3, baseMs: 500, maxMs: 5000 });
|
|
198
|
+
this.breaker.recordSuccess();
|
|
199
|
+
return result;
|
|
200
|
+
} catch (error: any) {
|
|
201
|
+
this.breaker.recordFailure();
|
|
202
|
+
if (error?.response?.status) {
|
|
203
|
+
const status = error.response.status;
|
|
204
|
+
const body = typeof error.response.data === 'string'
|
|
205
|
+
? error.response.data
|
|
206
|
+
: JSON.stringify(error.response.data ?? '');
|
|
207
|
+
const detail = error.response.statusText || 'Unknown';
|
|
208
|
+
throw new SyncError('ado', status, body, detail);
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
165
213
|
}
|
|
@@ -256,7 +256,7 @@ export class GitHubClientV2 {
|
|
|
256
256
|
// Derive proper FS-ID from the increment number
|
|
257
257
|
const num = parseInt(incrementId.replace('E', ''), 10);
|
|
258
258
|
const isExternal = incrementId.endsWith('E');
|
|
259
|
-
const properFsId = `FS-${String(num).padStart(3, '0')}${isExternal ? 'E' : ''}`;
|
|
259
|
+
const properFsId = `FS-${String(num).padStart(3, '0')}${isExternal ? 'E' : ''}`; // Legacy E suffix ā error message only
|
|
260
260
|
throw new Error(
|
|
261
261
|
`ā INVALID TITLE FORMAT: "${title}"\n\n` +
|
|
262
262
|
`Plain increment IDs like [${incrementId}] are NOT allowed!\n\n` +
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
|
|
2
2
|
import { generateIssueBody } from "./github-issue-body-generator.js";
|
|
3
|
+
import { SyncError } from "../../../src/core/errors/sync-error.js";
|
|
4
|
+
function parseHttpStatus(stderr) {
|
|
5
|
+
const match = stderr.match(/HTTP\s+(\d{3})/);
|
|
6
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
7
|
+
}
|
|
3
8
|
async function pushSyncUserStories(userStories, options) {
|
|
4
9
|
const result = { created: [], updated: [], errors: [] };
|
|
5
10
|
if (options.dryRun) {
|
|
@@ -60,7 +65,8 @@ async function searchIssueByPrefix(usId, repoSlug, env) {
|
|
|
60
65
|
"1"
|
|
61
66
|
], { env });
|
|
62
67
|
if (!res.success) {
|
|
63
|
-
|
|
68
|
+
const status = parseHttpStatus(res.stderr);
|
|
69
|
+
throw new SyncError("github", status, res.stderr, `Search failed: ${res.stderr}`);
|
|
64
70
|
}
|
|
65
71
|
const issues = JSON.parse(res.stdout);
|
|
66
72
|
return issues.length > 0 ? issues[0] : null;
|
|
@@ -86,7 +92,8 @@ async function createIssue(title, body, us, repoSlug, env) {
|
|
|
86
92
|
];
|
|
87
93
|
const res = await execFileNoThrow("gh", args, { env });
|
|
88
94
|
if (!res.success) {
|
|
89
|
-
|
|
95
|
+
const status = parseHttpStatus(res.stderr);
|
|
96
|
+
throw new SyncError("github", status, res.stderr, `Create failed: ${res.stderr}`);
|
|
90
97
|
}
|
|
91
98
|
return JSON.parse(res.stdout);
|
|
92
99
|
}
|
|
@@ -106,7 +113,8 @@ async function updateIssue(issueNumber, title, body, repoSlug, env) {
|
|
|
106
113
|
];
|
|
107
114
|
const res = await execFileNoThrow("gh", args, { env });
|
|
108
115
|
if (!res.success) {
|
|
109
|
-
|
|
116
|
+
const status = parseHttpStatus(res.stderr);
|
|
117
|
+
throw new SyncError("github", status, res.stderr, `Update failed: ${res.stderr}`);
|
|
110
118
|
}
|
|
111
119
|
return JSON.parse(res.stdout);
|
|
112
120
|
}
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
|
|
11
11
|
import { generateIssueBody } from './github-issue-body-generator.js';
|
|
12
|
+
import { SyncError } from '../../../src/core/errors/sync-error.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract HTTP status code from gh CLI stderr output.
|
|
16
|
+
* gh CLI typically outputs "HTTP 401: Bad credentials" or similar.
|
|
17
|
+
*/
|
|
18
|
+
function parseHttpStatus(stderr: string): number {
|
|
19
|
+
const match = stderr.match(/HTTP\s+(\d{3})/);
|
|
20
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
export interface UserStoryForSync {
|
|
14
24
|
id: string;
|
|
@@ -109,7 +119,8 @@ async function searchIssueByPrefix(
|
|
|
109
119
|
], { env });
|
|
110
120
|
|
|
111
121
|
if (!res.success) {
|
|
112
|
-
|
|
122
|
+
const status = parseHttpStatus(res.stderr);
|
|
123
|
+
throw new SyncError('github', status, res.stderr, `Search failed: ${res.stderr}`);
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
const issues = JSON.parse(res.stdout);
|
|
@@ -137,7 +148,8 @@ async function createIssue(
|
|
|
137
148
|
const res = await execFileNoThrow('gh', args, { env });
|
|
138
149
|
|
|
139
150
|
if (!res.success) {
|
|
140
|
-
|
|
151
|
+
const status = parseHttpStatus(res.stderr);
|
|
152
|
+
throw new SyncError('github', status, res.stderr, `Create failed: ${res.stderr}`);
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
return JSON.parse(res.stdout);
|
|
@@ -161,7 +173,8 @@ async function updateIssue(
|
|
|
161
173
|
const res = await execFileNoThrow('gh', args, { env });
|
|
162
174
|
|
|
163
175
|
if (!res.success) {
|
|
164
|
-
|
|
176
|
+
const status = parseHttpStatus(res.stderr);
|
|
177
|
+
throw new SyncError('github', status, res.stderr, `Update failed: ${res.stderr}`);
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
return JSON.parse(res.stdout);
|
|
@@ -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
|
/**
|