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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import https from 'https';
|
|
11
|
+
import { AdoRateLimiter } from './ado-rate-limiter.js';
|
|
11
12
|
|
|
12
13
|
// ============================================================================
|
|
13
14
|
// Types
|
|
@@ -20,6 +21,8 @@ export interface AdoConfig {
|
|
|
20
21
|
workItemType?: 'Epic' | 'Feature' | 'User Story';
|
|
21
22
|
areaPath?: string;
|
|
22
23
|
iterationPath?: string;
|
|
24
|
+
/** Optional rate limiter instance (shared across client instances) */
|
|
25
|
+
rateLimiter?: AdoRateLimiter;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export interface WorkItem {
|
|
@@ -70,11 +73,13 @@ export class AdoClient {
|
|
|
70
73
|
private config: AdoConfig;
|
|
71
74
|
private baseUrl: string;
|
|
72
75
|
private authHeader: string;
|
|
76
|
+
private rateLimiter?: AdoRateLimiter;
|
|
73
77
|
|
|
74
78
|
constructor(config: AdoConfig) {
|
|
75
79
|
this.config = config;
|
|
76
80
|
this.baseUrl = `https://dev.azure.com/${config.organization}/${config.project}`;
|
|
77
|
-
|
|
81
|
+
this.rateLimiter = config.rateLimiter;
|
|
82
|
+
|
|
78
83
|
// Basic Auth: base64(":PAT")
|
|
79
84
|
this.authHeader = 'Basic ' + Buffer.from(`:${config.personalAccessToken}`).toString('base64');
|
|
80
85
|
}
|
|
@@ -207,6 +212,21 @@ export class AdoClient {
|
|
|
207
212
|
await this.request('DELETE', url);
|
|
208
213
|
}
|
|
209
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Pull the current state of a work item for bidirectional sync.
|
|
217
|
+
*
|
|
218
|
+
* @param id - ADO work item ID
|
|
219
|
+
* @returns state and last-modified timestamp
|
|
220
|
+
*/
|
|
221
|
+
async pullWorkItemState(id: number): Promise<{ state: string; modifiedAt: Date }> {
|
|
222
|
+
const url = `${this.baseUrl}/_apis/wit/workitems/${id}?$select=System.State,System.ChangedDate&api-version=7.1`;
|
|
223
|
+
const item = await this.request<WorkItem>('GET', url);
|
|
224
|
+
return {
|
|
225
|
+
state: item.fields['System.State'],
|
|
226
|
+
modifiedAt: new Date(item.fields['System.ChangedDate']),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
210
230
|
// ==========================================================================
|
|
211
231
|
// Comment Operations
|
|
212
232
|
// ==========================================================================
|
|
@@ -275,6 +295,11 @@ export class AdoClient {
|
|
|
275
295
|
body?: any,
|
|
276
296
|
additionalHeaders?: Record<string, string>
|
|
277
297
|
): Promise<T> {
|
|
298
|
+
// Check rate limiter before making request
|
|
299
|
+
if (this.rateLimiter && !this.rateLimiter.consume()) {
|
|
300
|
+
throw new Error('ADO rate limit exceeded ā try again later');
|
|
301
|
+
}
|
|
302
|
+
|
|
278
303
|
return new Promise((resolve, reject) => {
|
|
279
304
|
const urlObj = new URL(url);
|
|
280
305
|
|
|
@@ -308,6 +333,14 @@ export class AdoClient {
|
|
|
308
333
|
reject(new Error(`Failed to parse JSON response: ${error}`));
|
|
309
334
|
}
|
|
310
335
|
} else {
|
|
336
|
+
// Handle 429 Too Many Requests ā apply Retry-After
|
|
337
|
+
if (res.statusCode === 429 && this.rateLimiter) {
|
|
338
|
+
const retryAfter = parseInt(res.headers['retry-after'] as string, 10);
|
|
339
|
+
if (retryAfter > 0) {
|
|
340
|
+
this.rateLimiter.applyRetryAfter(retryAfter);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
311
344
|
let errorMessage = `ADO API error: ${res.statusCode} ${res.statusMessage}`;
|
|
312
345
|
try {
|
|
313
346
|
const errorData = JSON.parse(data);
|
|
@@ -317,7 +350,9 @@ export class AdoClient {
|
|
|
317
350
|
} catch {
|
|
318
351
|
// Ignore JSON parse errors for error responses
|
|
319
352
|
}
|
|
320
|
-
|
|
353
|
+
const err: any = new Error(errorMessage);
|
|
354
|
+
err.status = res.statusCode;
|
|
355
|
+
reject(err);
|
|
321
356
|
}
|
|
322
357
|
});
|
|
323
358
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function mapAdoStateToSpecweave(adoState) {
|
|
2
|
+
const map = {
|
|
3
|
+
"New": "planned",
|
|
4
|
+
"To Do": "planned",
|
|
5
|
+
"Active": "active",
|
|
6
|
+
"Doing": "active",
|
|
7
|
+
"In Progress": "active",
|
|
8
|
+
"Resolved": "completed",
|
|
9
|
+
"Closed": "completed",
|
|
10
|
+
"Done": "completed",
|
|
11
|
+
"Removed": "abandoned"
|
|
12
|
+
};
|
|
13
|
+
return map[adoState] ?? "active";
|
|
14
|
+
}
|
|
15
|
+
function pullAdoChanges(ctx) {
|
|
16
|
+
if (!ctx.canUpsertInternalItems) {
|
|
17
|
+
return { changed: false, reason: "canUpsertInternalItems is false" };
|
|
18
|
+
}
|
|
19
|
+
const mappedStatus = mapAdoStateToSpecweave(ctx.adoState);
|
|
20
|
+
if (mappedStatus === ctx.localStatus) {
|
|
21
|
+
return { changed: false, reason: "states are equivalent" };
|
|
22
|
+
}
|
|
23
|
+
if (ctx.adoModifiedAt.getTime() <= ctx.localUpdatedAt.getTime()) {
|
|
24
|
+
return { changed: false, reason: "local is newer" };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
changed: true,
|
|
28
|
+
newStatus: mappedStatus,
|
|
29
|
+
reason: `ADO state "${ctx.adoState}" is newer \u2014 updating to "${mappedStatus}"`
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
mapAdoStateToSpecweave,
|
|
34
|
+
pullAdoChanges
|
|
35
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADO Pull Sync ā Bidirectional state synchronization
|
|
3
|
+
*
|
|
4
|
+
* Implements last-write-wins conflict resolution for pulling
|
|
5
|
+
* ADO work item state changes back into SpecWeave metadata.
|
|
6
|
+
*
|
|
7
|
+
* @module ado-pull-sync
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PullSyncContext {
|
|
11
|
+
localStatus: string;
|
|
12
|
+
localUpdatedAt: Date;
|
|
13
|
+
adoState: string;
|
|
14
|
+
adoModifiedAt: Date;
|
|
15
|
+
workItemId: number;
|
|
16
|
+
canUpsertInternalItems: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PullSyncResult {
|
|
20
|
+
changed: boolean;
|
|
21
|
+
newStatus?: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map ADO work item states to SpecWeave statuses.
|
|
27
|
+
*
|
|
28
|
+
* Covers Agile/Scrum/Basic process templates.
|
|
29
|
+
*/
|
|
30
|
+
export function mapAdoStateToSpecweave(adoState: string): string {
|
|
31
|
+
const map: Record<string, string> = {
|
|
32
|
+
'New': 'planned',
|
|
33
|
+
'To Do': 'planned',
|
|
34
|
+
'Active': 'active',
|
|
35
|
+
'Doing': 'active',
|
|
36
|
+
'In Progress': 'active',
|
|
37
|
+
'Resolved': 'completed',
|
|
38
|
+
'Closed': 'completed',
|
|
39
|
+
'Done': 'completed',
|
|
40
|
+
'Removed': 'abandoned',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return map[adoState] ?? 'active';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Determine if ADO changes should override local state.
|
|
48
|
+
*
|
|
49
|
+
* Uses last-write-wins: if ADO was modified more recently than local,
|
|
50
|
+
* and the mapped state differs, update local metadata.
|
|
51
|
+
*/
|
|
52
|
+
export function pullAdoChanges(ctx: PullSyncContext): PullSyncResult {
|
|
53
|
+
if (!ctx.canUpsertInternalItems) {
|
|
54
|
+
return { changed: false, reason: 'canUpsertInternalItems is false' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mappedStatus = mapAdoStateToSpecweave(ctx.adoState);
|
|
58
|
+
|
|
59
|
+
// Same effective status ā no change needed
|
|
60
|
+
if (mappedStatus === ctx.localStatus) {
|
|
61
|
+
return { changed: false, reason: 'states are equivalent' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Last-write-wins: ADO must be newer
|
|
65
|
+
if (ctx.adoModifiedAt.getTime() <= ctx.localUpdatedAt.getTime()) {
|
|
66
|
+
return { changed: false, reason: 'local is newer' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
changed: true,
|
|
71
|
+
newStatus: mappedStatus,
|
|
72
|
+
reason: `ADO state "${ctx.adoState}" is newer ā updating to "${mappedStatus}"`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -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
|