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.
- 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 +10 -0
- package/dist/plugins/specweave-ado/lib/ado-client.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-client.js +14 -0
- 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/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/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 +14 -0
- package/plugins/specweave-ado/lib/ado-client.ts +18 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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: `${
|
|
170
|
+
{ type: "text", text: `${ac.completed ? "\u2705" : "\u274C"} ${ac.id}: ${ac.description}` }
|
|
151
171
|
]
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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) {
|