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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
class AdoRateLimiter {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
this.capacity = options?.capacity ?? 200;
|
|
4
|
+
this.windowMs = options?.windowMs ?? 6e4;
|
|
5
|
+
this.tokens = this.capacity;
|
|
6
|
+
this.windowStart = Date.now();
|
|
7
|
+
this.retryAfterUntil = 0;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Try to consume one token. Returns true if allowed, false if exhausted.
|
|
11
|
+
*/
|
|
12
|
+
consume() {
|
|
13
|
+
this.refillIfWindowExpired();
|
|
14
|
+
if (Date.now() < this.retryAfterUntil) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (this.tokens <= 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
this.tokens--;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Number of tokens remaining in the current window.
|
|
25
|
+
*/
|
|
26
|
+
remaining() {
|
|
27
|
+
this.refillIfWindowExpired();
|
|
28
|
+
return this.tokens;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether the bucket is fully exhausted (0 tokens remaining).
|
|
32
|
+
*/
|
|
33
|
+
isExhausted() {
|
|
34
|
+
this.refillIfWindowExpired();
|
|
35
|
+
return this.tokens <= 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Apply a Retry-After delay (from ADO 429 response).
|
|
39
|
+
* Blocks all consumption until the delay expires.
|
|
40
|
+
*
|
|
41
|
+
* @param seconds - Number of seconds to wait
|
|
42
|
+
*/
|
|
43
|
+
applyRetryAfter(seconds) {
|
|
44
|
+
this.retryAfterUntil = Date.now() + seconds * 1e3;
|
|
45
|
+
}
|
|
46
|
+
refillIfWindowExpired() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (now - this.windowStart >= this.windowMs) {
|
|
49
|
+
this.tokens = this.capacity;
|
|
50
|
+
this.windowStart = now;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
AdoRateLimiter
|
|
56
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADO Token Bucket Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Simple token bucket that prevents exceeding Azure DevOps API rate limits.
|
|
5
|
+
* ADO's documented limit is ~200 requests per minute for PAT-based auth.
|
|
6
|
+
*
|
|
7
|
+
* Supports Retry-After header handling: when ADO returns 429, call
|
|
8
|
+
* applyRetryAfter(seconds) to block consumption until the cooldown expires.
|
|
9
|
+
*
|
|
10
|
+
* @module ado-rate-limiter
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface AdoRateLimiterOptions {
|
|
14
|
+
/** Max tokens per window (default: 200) */
|
|
15
|
+
capacity?: number;
|
|
16
|
+
/** Window duration in ms (default: 60000 = 1 minute) */
|
|
17
|
+
windowMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AdoRateLimiter {
|
|
21
|
+
private readonly capacity: number;
|
|
22
|
+
private readonly windowMs: number;
|
|
23
|
+
private tokens: number;
|
|
24
|
+
private windowStart: number;
|
|
25
|
+
private retryAfterUntil: number;
|
|
26
|
+
|
|
27
|
+
constructor(options?: AdoRateLimiterOptions) {
|
|
28
|
+
this.capacity = options?.capacity ?? 200;
|
|
29
|
+
this.windowMs = options?.windowMs ?? 60_000;
|
|
30
|
+
this.tokens = this.capacity;
|
|
31
|
+
this.windowStart = Date.now();
|
|
32
|
+
this.retryAfterUntil = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Try to consume one token. Returns true if allowed, false if exhausted.
|
|
37
|
+
*/
|
|
38
|
+
consume(): boolean {
|
|
39
|
+
this.refillIfWindowExpired();
|
|
40
|
+
|
|
41
|
+
if (Date.now() < this.retryAfterUntil) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.tokens <= 0) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.tokens--;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Number of tokens remaining in the current window.
|
|
55
|
+
*/
|
|
56
|
+
remaining(): number {
|
|
57
|
+
this.refillIfWindowExpired();
|
|
58
|
+
return this.tokens;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the bucket is fully exhausted (0 tokens remaining).
|
|
63
|
+
*/
|
|
64
|
+
isExhausted(): boolean {
|
|
65
|
+
this.refillIfWindowExpired();
|
|
66
|
+
return this.tokens <= 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply a Retry-After delay (from ADO 429 response).
|
|
71
|
+
* Blocks all consumption until the delay expires.
|
|
72
|
+
*
|
|
73
|
+
* @param seconds - Number of seconds to wait
|
|
74
|
+
*/
|
|
75
|
+
applyRetryAfter(seconds: number): void {
|
|
76
|
+
this.retryAfterUntil = Date.now() + seconds * 1000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private refillIfWindowExpired(): void {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (now - this.windowStart >= this.windowMs) {
|
|
82
|
+
this.tokens = this.capacity;
|
|
83
|
+
this.windowStart = now;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { SpecMetadataManager } from "../../../src/core/specs/spec-metadata-manager.js";
|
|
2
2
|
import { SpecParser } from "../../../src/core/specs/spec-parser.js";
|
|
3
|
+
import { SyncCircuitBreaker } from "../../../src/core/increment/sync-circuit-breaker.js";
|
|
3
4
|
import axios from "axios";
|
|
4
5
|
import { promises as fsPromises, existsSync } from "fs";
|
|
5
6
|
import path from "path";
|
|
6
7
|
import yaml from "yaml";
|
|
7
8
|
class AdoSpecSync {
|
|
8
|
-
constructor(config, projectRoot = process.cwd(), projectId) {
|
|
9
|
+
constructor(config, projectRoot = process.cwd(), projectId, breaker) {
|
|
9
10
|
this.availableTypes = null;
|
|
10
11
|
/**
|
|
11
12
|
* Resolve a work item state name for the given type.
|
|
@@ -18,6 +19,7 @@ class AdoSpecSync {
|
|
|
18
19
|
this.projectRoot = projectRoot;
|
|
19
20
|
this.specManager = new SpecMetadataManager(projectRoot, projectId);
|
|
20
21
|
this.config = config;
|
|
22
|
+
this.breaker = breaker ?? new SyncCircuitBreaker();
|
|
21
23
|
this.client = axios.create({
|
|
22
24
|
baseURL: `https://dev.azure.com/${config.organization}/${config.project}/_apis`,
|
|
23
25
|
auth: {
|
|
@@ -93,6 +95,14 @@ class AdoSpecSync {
|
|
|
93
95
|
async syncSpecToAdo(specId) {
|
|
94
96
|
console.log(`
|
|
95
97
|
\u{1F504} Syncing spec ${specId} to ADO Feature...`);
|
|
98
|
+
if (!this.breaker.canSync()) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
specId,
|
|
102
|
+
provider: "ado",
|
|
103
|
+
error: "Circuit breaker open \u2014 sync blocked"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
96
106
|
try {
|
|
97
107
|
const spec = await this.specManager.loadSpec(specId);
|
|
98
108
|
if (!spec) {
|
|
@@ -144,6 +154,14 @@ class AdoSpecSync {
|
|
|
144
154
|
async syncFromAdo(specId) {
|
|
145
155
|
console.log(`
|
|
146
156
|
\u{1F504} Syncing FROM ADO to spec ${specId}...`);
|
|
157
|
+
if (!this.breaker.canSync()) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
specId,
|
|
161
|
+
provider: "ado",
|
|
162
|
+
error: "Circuit breaker open \u2014 sync blocked"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
147
165
|
try {
|
|
148
166
|
const spec = await this.specManager.loadSpec(specId);
|
|
149
167
|
if (!spec) {
|
|
@@ -651,6 +669,12 @@ ${acList}
|
|
|
651
669
|
};
|
|
652
670
|
return map[normalizedType] || defaultType;
|
|
653
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* Check whether the circuit is closed (sync allowed).
|
|
674
|
+
*/
|
|
675
|
+
canSync() {
|
|
676
|
+
return this.breaker.canSync();
|
|
677
|
+
}
|
|
654
678
|
}
|
|
655
679
|
export {
|
|
656
680
|
AdoSpecSync
|
|
@@ -22,7 +22,10 @@ import {
|
|
|
22
22
|
SpecSyncConflict
|
|
23
23
|
} from '../../../src/core/types/spec-metadata.js';
|
|
24
24
|
import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
|
|
25
|
-
import
|
|
25
|
+
import { SyncCircuitBreaker } from '../../../src/core/increment/sync-circuit-breaker.js';
|
|
26
|
+
import { withRetry } from '../../../src/core/sync/retry-wrapper.js';
|
|
27
|
+
import { SyncError } from '../../../src/core/errors/sync-error.js';
|
|
28
|
+
import axios, { AxiosInstance, AxiosError } from 'axios';
|
|
26
29
|
import { promises as fsPromises, existsSync } from 'fs';
|
|
27
30
|
import path from 'path';
|
|
28
31
|
import yaml from 'yaml';
|
|
@@ -62,11 +65,13 @@ export class AdoSpecSync {
|
|
|
62
65
|
private config: AdoConfig;
|
|
63
66
|
private projectRoot: string;
|
|
64
67
|
private availableTypes: Set<string> | null = null;
|
|
68
|
+
private breaker: SyncCircuitBreaker;
|
|
65
69
|
|
|
66
|
-
constructor(config: AdoConfig, projectRoot: string = process.cwd(), projectId?: string) {
|
|
70
|
+
constructor(config: AdoConfig, projectRoot: string = process.cwd(), projectId?: string, breaker?: SyncCircuitBreaker) {
|
|
67
71
|
this.projectRoot = projectRoot;
|
|
68
72
|
this.specManager = new SpecMetadataManager(projectRoot, projectId);
|
|
69
73
|
this.config = config;
|
|
74
|
+
this.breaker = breaker ?? new SyncCircuitBreaker();
|
|
70
75
|
|
|
71
76
|
// Create ADO API client
|
|
72
77
|
// NOTE: Do NOT set a default Content-Type here. Work item create/update
|
|
@@ -166,6 +171,15 @@ export class AdoSpecSync {
|
|
|
166
171
|
async syncSpecToAdo(specId: string): Promise<SpecSyncResult> {
|
|
167
172
|
console.log(`\n🔄 Syncing spec ${specId} to ADO Feature...`);
|
|
168
173
|
|
|
174
|
+
if (!this.breaker.canSync()) {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
specId,
|
|
178
|
+
provider: 'ado',
|
|
179
|
+
error: 'Circuit breaker open — sync blocked',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
try {
|
|
170
184
|
// 1. Load spec
|
|
171
185
|
const spec = await this.specManager.loadSpec(specId);
|
|
@@ -232,6 +246,15 @@ export class AdoSpecSync {
|
|
|
232
246
|
async syncFromAdo(specId: string): Promise<SpecSyncResult> {
|
|
233
247
|
console.log(`\n🔄 Syncing FROM ADO to spec ${specId}...`);
|
|
234
248
|
|
|
249
|
+
if (!this.breaker.canSync()) {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
specId,
|
|
253
|
+
provider: 'ado',
|
|
254
|
+
error: 'Circuit breaker open — sync blocked',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
try {
|
|
236
259
|
// 1. Load spec
|
|
237
260
|
const spec = await this.specManager.loadSpec(specId);
|
|
@@ -860,4 +883,11 @@ ${acList}
|
|
|
860
883
|
|
|
861
884
|
return map[normalizedType] || defaultType;
|
|
862
885
|
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Check whether the circuit is closed (sync allowed).
|
|
889
|
+
*/
|
|
890
|
+
canSync(): boolean {
|
|
891
|
+
return this.breaker.canSync();
|
|
892
|
+
}
|
|
863
893
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
+
import { SyncCircuitBreaker } from "../../../src/core/increment/sync-circuit-breaker.js";
|
|
3
|
+
import { withRetry } from "../../../src/core/sync/retry-wrapper.js";
|
|
4
|
+
import { SyncError } from "../../../src/core/errors/sync-error.js";
|
|
2
5
|
class AdoStatusSync {
|
|
3
|
-
constructor(organization, project, personalAccessToken) {
|
|
6
|
+
constructor(organization, project, personalAccessToken, breaker) {
|
|
4
7
|
this.organization = organization;
|
|
5
8
|
this.project = project;
|
|
9
|
+
this.breaker = breaker ?? new SyncCircuitBreaker();
|
|
6
10
|
this.client = axios.create({
|
|
7
11
|
baseURL: `https://dev.azure.com/${organization}/${project}/_apis`,
|
|
8
12
|
auth: {
|
|
@@ -23,12 +27,12 @@ class AdoStatusSync {
|
|
|
23
27
|
* @returns Current work item state
|
|
24
28
|
*/
|
|
25
29
|
async getStatus(workItemId) {
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
this.assertCircuitClosed();
|
|
31
|
+
return this.withResilienceWrapper(
|
|
32
|
+
() => this.client.get(`/wit/workitems/${workItemId}?api-version=7.0`).then((response) => ({
|
|
33
|
+
state: response.data.fields["System.State"]
|
|
34
|
+
}))
|
|
28
35
|
);
|
|
29
|
-
return {
|
|
30
|
-
state: response.data.fields["System.State"]
|
|
31
|
-
};
|
|
32
36
|
}
|
|
33
37
|
/**
|
|
34
38
|
* Update ADO work item state and tags
|
|
@@ -40,6 +44,7 @@ class AdoStatusSync {
|
|
|
40
44
|
* @param status - Desired status with state and optional tags
|
|
41
45
|
*/
|
|
42
46
|
async updateStatus(workItemId, status) {
|
|
47
|
+
this.assertCircuitClosed();
|
|
43
48
|
const patch = [
|
|
44
49
|
{
|
|
45
50
|
op: "add",
|
|
@@ -60,11 +65,16 @@ class AdoStatusSync {
|
|
|
60
65
|
value: allTags.join("; ")
|
|
61
66
|
});
|
|
62
67
|
}
|
|
63
|
-
await this.
|
|
64
|
-
`/wit/workitems/${workItemId}?api-version=7.0`,
|
|
65
|
-
patch
|
|
68
|
+
await this.withResilienceWrapper(
|
|
69
|
+
() => this.client.patch(`/wit/workitems/${workItemId}?api-version=7.0`, patch)
|
|
66
70
|
);
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Check whether the circuit is closed (sync allowed).
|
|
74
|
+
*/
|
|
75
|
+
canSync() {
|
|
76
|
+
return this.breaker.canSync();
|
|
77
|
+
}
|
|
68
78
|
/**
|
|
69
79
|
* Get current tags from ADO work item
|
|
70
80
|
*
|
|
@@ -91,6 +101,7 @@ class AdoStatusSync {
|
|
|
91
101
|
* @param newStatus - New SpecWeave status
|
|
92
102
|
*/
|
|
93
103
|
async postStatusComment(workItemId, oldStatus, newStatus) {
|
|
104
|
+
this.assertCircuitClosed();
|
|
94
105
|
const text = `\u{1F504} Status Update
|
|
95
106
|
|
|
96
107
|
SpecWeave status changed:
|
|
@@ -99,13 +110,40 @@ SpecWeave status changed:
|
|
|
99
110
|
\u2022 When: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
100
111
|
|
|
101
112
|
Synced from SpecWeave`;
|
|
102
|
-
await this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
text
|
|
106
|
-
|
|
113
|
+
await this.withResilienceWrapper(
|
|
114
|
+
() => this.client.post(
|
|
115
|
+
`/wit/workitems/${workItemId}/comments?api-version=7.0-preview.3`,
|
|
116
|
+
{ text }
|
|
117
|
+
)
|
|
107
118
|
);
|
|
108
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Assert the circuit is closed. Throws CircuitOpenError if open.
|
|
122
|
+
*/
|
|
123
|
+
assertCircuitClosed() {
|
|
124
|
+
if (!this.breaker.canSync()) {
|
|
125
|
+
throw new SyncError("ado", 503, "", "Circuit breaker open \u2014 sync blocked");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Wrap an async operation with retry + circuit breaker recording.
|
|
130
|
+
*/
|
|
131
|
+
async withResilienceWrapper(fn) {
|
|
132
|
+
try {
|
|
133
|
+
const result = await withRetry(fn, { maxRetries: 3, baseMs: 500, maxMs: 5e3 });
|
|
134
|
+
this.breaker.recordSuccess();
|
|
135
|
+
return result;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.breaker.recordFailure();
|
|
138
|
+
if (error?.response?.status) {
|
|
139
|
+
const status = error.response.status;
|
|
140
|
+
const body = typeof error.response.data === "string" ? error.response.data : JSON.stringify(error.response.data ?? "");
|
|
141
|
+
const detail = error.response.statusText || "Unknown";
|
|
142
|
+
throw new SyncError("ado", status, body, detail);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
109
147
|
}
|
|
110
148
|
export {
|
|
111
149
|
AdoStatusSync
|
|
@@ -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);
|