@supaku/agentfactory-linear 0.7.18 → 0.7.20
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/dist/src/agent-client-project-repo.test.js +2 -0
- package/dist/src/agent-client.d.ts +16 -4
- package/dist/src/agent-client.d.ts.map +1 -1
- package/dist/src/agent-client.js +280 -109
- package/dist/src/circuit-breaker.d.ts +76 -0
- package/dist/src/circuit-breaker.d.ts.map +1 -0
- package/dist/src/circuit-breaker.js +229 -0
- package/dist/src/circuit-breaker.test.d.ts +2 -0
- package/dist/src/circuit-breaker.test.d.ts.map +1 -0
- package/dist/src/circuit-breaker.test.js +292 -0
- package/dist/src/errors.d.ts +12 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +18 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -1
- package/dist/src/issue-tracker-proxy.d.ts +140 -0
- package/dist/src/issue-tracker-proxy.d.ts.map +1 -0
- package/dist/src/issue-tracker-proxy.js +10 -0
- package/dist/src/proxy-client.d.ts +103 -0
- package/dist/src/proxy-client.d.ts.map +1 -0
- package/dist/src/proxy-client.js +191 -0
- package/dist/src/types.d.ts +50 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { LinearAgentClient } from './agent-client.js';
|
|
3
3
|
import { TokenBucket } from './rate-limiter.js';
|
|
4
|
+
import { CircuitBreaker } from './circuit-breaker.js';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Mock helpers
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +31,7 @@ function createClientWithProject(project) {
|
|
|
30
31
|
writable: false,
|
|
31
32
|
});
|
|
32
33
|
Object.defineProperty(client, 'rateLimiter', { value: new TokenBucket(), writable: false });
|
|
34
|
+
Object.defineProperty(client, 'circuitBreaker', { value: new CircuitBreaker(), writable: false });
|
|
33
35
|
Object.defineProperty(client, 'statusCache', { value: new Map(), writable: false });
|
|
34
36
|
return client;
|
|
35
37
|
}
|
|
@@ -9,6 +9,7 @@ export declare class LinearAgentClient {
|
|
|
9
9
|
private readonly client;
|
|
10
10
|
private readonly retryConfig;
|
|
11
11
|
private readonly rateLimiter;
|
|
12
|
+
private readonly circuitBreaker;
|
|
12
13
|
private statusCache;
|
|
13
14
|
constructor(config: LinearAgentClientConfig);
|
|
14
15
|
/**
|
|
@@ -16,11 +17,15 @@ export declare class LinearAgentClient {
|
|
|
16
17
|
*/
|
|
17
18
|
get linearClient(): LinearClient;
|
|
18
19
|
/**
|
|
19
|
-
* Execute an operation with
|
|
20
|
+
* Execute an operation with circuit breaker, rate limiting, and retry logic.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Order of operations:
|
|
23
|
+
* 1. Check circuit breaker — if open, throw CircuitOpenError (zero quota consumed)
|
|
24
|
+
* 2. Acquire rate limit token
|
|
25
|
+
* 3. Execute the operation
|
|
26
|
+
* 4. On success: record success on circuit breaker
|
|
27
|
+
* 5. On auth error: record failure on circuit breaker (may trip it)
|
|
28
|
+
* 6. On retryable error: retry with exponential backoff
|
|
24
29
|
*/
|
|
25
30
|
private withRetry;
|
|
26
31
|
/**
|
|
@@ -136,6 +141,8 @@ export declare class LinearAgentClient {
|
|
|
136
141
|
/**
|
|
137
142
|
* Get all relations for an issue (both outgoing and incoming)
|
|
138
143
|
*
|
|
144
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
145
|
+
*
|
|
139
146
|
* @param issueId - The issue ID or identifier (e.g., "SUP-123")
|
|
140
147
|
* @returns Relations result with both directions of relationships
|
|
141
148
|
*/
|
|
@@ -194,6 +201,7 @@ export declare class LinearAgentClient {
|
|
|
194
201
|
/**
|
|
195
202
|
* Get lightweight sub-issue statuses (no blocking relations)
|
|
196
203
|
*
|
|
204
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
197
205
|
* Returns identifier, title, and status for each sub-issue.
|
|
198
206
|
* Used by QA and acceptance agents to validate sub-issue completion
|
|
199
207
|
* without the overhead of fetching the full dependency graph.
|
|
@@ -216,6 +224,10 @@ export declare class LinearAgentClient {
|
|
|
216
224
|
/**
|
|
217
225
|
* Get sub-issues with their blocking relations for dependency graph building
|
|
218
226
|
*
|
|
227
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
228
|
+
* Previous implementation made 2 + 4N + M API calls (where N = children,
|
|
229
|
+
* M = total relations). This version makes exactly 1 API call.
|
|
230
|
+
*
|
|
219
231
|
* Builds a complete dependency graph of a parent issue's children, including
|
|
220
232
|
* which sub-issues block which other sub-issues. This is used by the coordinator
|
|
221
233
|
* agent to determine execution order.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent-client.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAGb,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,KAAK,EACV,uBAAuB,EACvB,oBAAoB,EACpB,aAAa,EAEb,wBAAwB,EACxB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,8BAA8B,EAC9B,wBAAwB,EACxB,wBAAwB,EACxB,mBAAmB,EACnB,wBAAwB,EAExB,oBAAoB,EACpB,iBAAiB,EAEjB,aAAa,EACb,cAAc,
|
|
1
|
+
{"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent-client.ts"],"names":[],"mappings":"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"}
|
package/dist/src/agent-client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { LinearClient, AgentActivitySignal as LinearAgentActivitySignal, IssueRe
|
|
|
2
2
|
import { LinearApiError, LinearStatusTransitionError } from './errors.js';
|
|
3
3
|
import { withRetry, DEFAULT_RETRY_CONFIG } from './retry.js';
|
|
4
4
|
import { TokenBucket, extractRetryAfterMs } from './rate-limiter.js';
|
|
5
|
+
import { CircuitBreaker } from './circuit-breaker.js';
|
|
5
6
|
/**
|
|
6
7
|
* Core Linear Agent Client
|
|
7
8
|
* Wraps @linear/sdk with retry logic and helper methods
|
|
@@ -10,6 +11,7 @@ export class LinearAgentClient {
|
|
|
10
11
|
client;
|
|
11
12
|
retryConfig;
|
|
12
13
|
rateLimiter;
|
|
14
|
+
circuitBreaker;
|
|
13
15
|
statusCache = new Map();
|
|
14
16
|
constructor(config) {
|
|
15
17
|
this.client = new LinearClient({
|
|
@@ -20,7 +22,8 @@ export class LinearAgentClient {
|
|
|
20
22
|
...DEFAULT_RETRY_CONFIG,
|
|
21
23
|
...config.retry,
|
|
22
24
|
};
|
|
23
|
-
this.rateLimiter = new TokenBucket(config.rateLimit);
|
|
25
|
+
this.rateLimiter = config.rateLimiterStrategy ?? new TokenBucket(config.rateLimit);
|
|
26
|
+
this.circuitBreaker = config.circuitBreakerStrategy ?? new CircuitBreaker(config.circuitBreaker);
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
29
|
* Get the underlying LinearClient instance
|
|
@@ -29,16 +32,45 @@ export class LinearAgentClient {
|
|
|
29
32
|
return this.client;
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
|
-
* Execute an operation with
|
|
35
|
+
* Execute an operation with circuit breaker, rate limiting, and retry logic.
|
|
33
36
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
+
* Order of operations:
|
|
38
|
+
* 1. Check circuit breaker — if open, throw CircuitOpenError (zero quota consumed)
|
|
39
|
+
* 2. Acquire rate limit token
|
|
40
|
+
* 3. Execute the operation
|
|
41
|
+
* 4. On success: record success on circuit breaker
|
|
42
|
+
* 5. On auth error: record failure on circuit breaker (may trip it)
|
|
43
|
+
* 6. On retryable error: retry with exponential backoff
|
|
37
44
|
*/
|
|
38
45
|
async withRetry(fn) {
|
|
39
46
|
return withRetry(async () => {
|
|
47
|
+
// Check circuit breaker BEFORE acquiring a rate limit token
|
|
48
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
49
|
+
if (!canProceed) {
|
|
50
|
+
// Create a descriptive error; if the breaker is a CircuitBreaker instance, use its helper
|
|
51
|
+
const breaker = this.circuitBreaker;
|
|
52
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
53
|
+
throw breaker.createOpenError();
|
|
54
|
+
}
|
|
55
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
56
|
+
}
|
|
40
57
|
await this.rateLimiter.acquire();
|
|
41
|
-
|
|
58
|
+
try {
|
|
59
|
+
const result = await fn();
|
|
60
|
+
// Record success to close/reset the circuit
|
|
61
|
+
await this.circuitBreaker.recordSuccess();
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// Check if this is an auth error that should trip the circuit
|
|
66
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
67
|
+
const statusCode = extractAuthStatusCode(error);
|
|
68
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
69
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
70
|
+
console.warn(`[LinearAgentClient] Auth error detected (status ${statusCode}), circuit breaker notified: ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
42
74
|
}, {
|
|
43
75
|
config: this.retryConfig,
|
|
44
76
|
getRetryAfterMs: extractRetryAfterMs,
|
|
@@ -346,47 +378,79 @@ export class LinearAgentClient {
|
|
|
346
378
|
/**
|
|
347
379
|
* Get all relations for an issue (both outgoing and incoming)
|
|
348
380
|
*
|
|
381
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
382
|
+
*
|
|
349
383
|
* @param issueId - The issue ID or identifier (e.g., "SUP-123")
|
|
350
384
|
* @returns Relations result with both directions of relationships
|
|
351
385
|
*/
|
|
352
386
|
async getIssueRelations(issueId) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
387
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
388
|
+
if (!canProceed) {
|
|
389
|
+
const breaker = this.circuitBreaker;
|
|
390
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
391
|
+
throw breaker.createOpenError();
|
|
357
392
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
393
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
394
|
+
}
|
|
395
|
+
await this.rateLimiter.acquire();
|
|
396
|
+
const query = `
|
|
397
|
+
query IssueRelations($id: String!) {
|
|
398
|
+
issue(id: $id) {
|
|
399
|
+
id
|
|
400
|
+
identifier
|
|
401
|
+
relations(first: 50) {
|
|
402
|
+
nodes {
|
|
403
|
+
id
|
|
404
|
+
type
|
|
405
|
+
createdAt
|
|
406
|
+
relatedIssue { id identifier }
|
|
372
407
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
408
|
+
}
|
|
409
|
+
inverseRelations(first: 50) {
|
|
410
|
+
nodes {
|
|
411
|
+
id
|
|
412
|
+
type
|
|
413
|
+
createdAt
|
|
414
|
+
issue { id identifier }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
`;
|
|
420
|
+
try {
|
|
421
|
+
const result = await this.client.client.rawRequest(query, { id: issueId });
|
|
422
|
+
await this.circuitBreaker.recordSuccess();
|
|
423
|
+
const data = result.data;
|
|
424
|
+
if (!data.issue) {
|
|
425
|
+
throw new LinearApiError(`Issue not found: ${issueId}`, 404);
|
|
387
426
|
}
|
|
427
|
+
const relations = data.issue.relations.nodes.map((rel) => ({
|
|
428
|
+
id: rel.id,
|
|
429
|
+
type: rel.type,
|
|
430
|
+
issueId: data.issue.id,
|
|
431
|
+
issueIdentifier: data.issue.identifier,
|
|
432
|
+
relatedIssueId: rel.relatedIssue?.id ?? '',
|
|
433
|
+
relatedIssueIdentifier: rel.relatedIssue?.identifier,
|
|
434
|
+
createdAt: new Date(rel.createdAt),
|
|
435
|
+
}));
|
|
436
|
+
const inverseRelations = data.issue.inverseRelations.nodes.map((rel) => ({
|
|
437
|
+
id: rel.id,
|
|
438
|
+
type: rel.type,
|
|
439
|
+
issueId: rel.issue?.id ?? '',
|
|
440
|
+
issueIdentifier: rel.issue?.identifier,
|
|
441
|
+
relatedIssueId: data.issue.id,
|
|
442
|
+
relatedIssueIdentifier: data.issue.identifier,
|
|
443
|
+
createdAt: new Date(rel.createdAt),
|
|
444
|
+
}));
|
|
388
445
|
return { relations, inverseRelations };
|
|
389
|
-
}
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
449
|
+
const statusCode = extractAuthStatusCode(error);
|
|
450
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
390
454
|
}
|
|
391
455
|
/**
|
|
392
456
|
* Delete an issue relation
|
|
@@ -448,6 +512,15 @@ export class LinearAgentClient {
|
|
|
448
512
|
* @returns Array of issue data with childCount for parent detection
|
|
449
513
|
*/
|
|
450
514
|
async listProjectIssues(project) {
|
|
515
|
+
// Check circuit breaker before consuming rate limit token
|
|
516
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
517
|
+
if (!canProceed) {
|
|
518
|
+
const breaker = this.circuitBreaker;
|
|
519
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
520
|
+
throw breaker.createOpenError();
|
|
521
|
+
}
|
|
522
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
523
|
+
}
|
|
451
524
|
await this.rateLimiter.acquire();
|
|
452
525
|
const query = `
|
|
453
526
|
query ListProjectIssues($filter: IssueFilter!) {
|
|
@@ -468,25 +541,36 @@ export class LinearAgentClient {
|
|
|
468
541
|
}
|
|
469
542
|
`;
|
|
470
543
|
const terminalStatuses = ['Accepted', 'Canceled', 'Duplicate'];
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
544
|
+
try {
|
|
545
|
+
const result = await this.client.client.rawRequest(query, {
|
|
546
|
+
filter: {
|
|
547
|
+
project: { name: { eq: project } },
|
|
548
|
+
state: { name: { nin: terminalStatuses } },
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
// Record success on circuit breaker
|
|
552
|
+
await this.circuitBreaker.recordSuccess();
|
|
553
|
+
const data = result.data;
|
|
554
|
+
return data.issues.nodes.map((node) => ({
|
|
555
|
+
id: node.id,
|
|
556
|
+
identifier: node.identifier,
|
|
557
|
+
title: node.title,
|
|
558
|
+
description: node.description ?? undefined,
|
|
559
|
+
status: node.state?.name ?? 'Backlog',
|
|
560
|
+
labels: node.labels.nodes.map((l) => l.name),
|
|
561
|
+
createdAt: new Date(node.createdAt).getTime(),
|
|
562
|
+
parentId: node.parent?.id ?? undefined,
|
|
563
|
+
project: node.project?.name ?? undefined,
|
|
564
|
+
childCount: node.children.nodes.length,
|
|
565
|
+
}));
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
569
|
+
const statusCode = extractAuthStatusCode(error);
|
|
570
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
490
574
|
}
|
|
491
575
|
/**
|
|
492
576
|
* Check if an issue has child issues (is a parent issue)
|
|
@@ -507,6 +591,7 @@ export class LinearAgentClient {
|
|
|
507
591
|
/**
|
|
508
592
|
* Get lightweight sub-issue statuses (no blocking relations)
|
|
509
593
|
*
|
|
594
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
510
595
|
* Returns identifier, title, and status for each sub-issue.
|
|
511
596
|
* Used by QA and acceptance agents to validate sub-issue completion
|
|
512
597
|
* without the overhead of fetching the full dependency graph.
|
|
@@ -515,23 +600,48 @@ export class LinearAgentClient {
|
|
|
515
600
|
* @returns Array of sub-issue statuses
|
|
516
601
|
*/
|
|
517
602
|
async getSubIssueStatuses(issueIdOrIdentifier) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
603
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
604
|
+
if (!canProceed) {
|
|
605
|
+
const breaker = this.circuitBreaker;
|
|
606
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
607
|
+
throw breaker.createOpenError();
|
|
608
|
+
}
|
|
609
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
610
|
+
}
|
|
611
|
+
await this.rateLimiter.acquire();
|
|
612
|
+
const query = `
|
|
613
|
+
query SubIssueStatuses($id: String!) {
|
|
614
|
+
issue(id: $id) {
|
|
615
|
+
children(first: 50) {
|
|
616
|
+
nodes {
|
|
617
|
+
identifier
|
|
618
|
+
title
|
|
619
|
+
state { name }
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
`;
|
|
625
|
+
try {
|
|
626
|
+
const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
|
|
627
|
+
await this.circuitBreaker.recordSuccess();
|
|
628
|
+
const data = result.data;
|
|
629
|
+
if (!data.issue) {
|
|
521
630
|
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
522
631
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
632
|
+
return data.issue.children.nodes.map((child) => ({
|
|
633
|
+
identifier: child.identifier,
|
|
634
|
+
title: child.title,
|
|
635
|
+
status: child.state?.name ?? 'Unknown',
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
640
|
+
const statusCode = extractAuthStatusCode(error);
|
|
641
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
532
642
|
}
|
|
533
|
-
|
|
534
|
-
}
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
535
645
|
}
|
|
536
646
|
/**
|
|
537
647
|
* Get the repository URL associated with a project via its links or description
|
|
@@ -566,6 +676,10 @@ export class LinearAgentClient {
|
|
|
566
676
|
/**
|
|
567
677
|
* Get sub-issues with their blocking relations for dependency graph building
|
|
568
678
|
*
|
|
679
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
680
|
+
* Previous implementation made 2 + 4N + M API calls (where N = children,
|
|
681
|
+
* M = total relations). This version makes exactly 1 API call.
|
|
682
|
+
*
|
|
569
683
|
* Builds a complete dependency graph of a parent issue's children, including
|
|
570
684
|
* which sub-issues block which other sub-issues. This is used by the coordinator
|
|
571
685
|
* agent to determine execution order.
|
|
@@ -574,66 +688,99 @@ export class LinearAgentClient {
|
|
|
574
688
|
* @returns The sub-issue dependency graph
|
|
575
689
|
*/
|
|
576
690
|
async getSubIssueGraph(issueIdOrIdentifier) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
691
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
692
|
+
if (!canProceed) {
|
|
693
|
+
const breaker = this.circuitBreaker;
|
|
694
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
695
|
+
throw breaker.createOpenError();
|
|
696
|
+
}
|
|
697
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
698
|
+
}
|
|
699
|
+
await this.rateLimiter.acquire();
|
|
700
|
+
const query = `
|
|
701
|
+
query SubIssueGraph($id: String!) {
|
|
702
|
+
issue(id: $id) {
|
|
703
|
+
id
|
|
704
|
+
identifier
|
|
705
|
+
children(first: 50) {
|
|
706
|
+
nodes {
|
|
707
|
+
id
|
|
708
|
+
identifier
|
|
709
|
+
title
|
|
710
|
+
description
|
|
711
|
+
priority
|
|
712
|
+
url
|
|
713
|
+
state { name }
|
|
714
|
+
labels(first: 20) { nodes { name } }
|
|
715
|
+
relations(first: 50) {
|
|
716
|
+
nodes {
|
|
717
|
+
type
|
|
718
|
+
relatedIssue { id identifier }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
inverseRelations(first: 50) {
|
|
722
|
+
nodes {
|
|
723
|
+
type
|
|
724
|
+
issue { id identifier }
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
`;
|
|
732
|
+
try {
|
|
733
|
+
const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
|
|
734
|
+
await this.circuitBreaker.recordSuccess();
|
|
735
|
+
const data = result.data;
|
|
736
|
+
if (!data.issue) {
|
|
580
737
|
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
581
738
|
}
|
|
582
|
-
const
|
|
583
|
-
const subIssueIds = new Set(children.nodes.map((c) => c.id));
|
|
584
|
-
const
|
|
585
|
-
// Build identifier map for all sub-issues
|
|
586
|
-
for (const child of children.nodes) {
|
|
587
|
-
subIssueIdentifiers.set(child.id, child.identifier);
|
|
588
|
-
}
|
|
589
|
-
const graphNodes = [];
|
|
590
|
-
for (const child of children.nodes) {
|
|
591
|
-
const state = await child.state;
|
|
592
|
-
const labels = await child.labels();
|
|
593
|
-
// Get relations to find blocking dependencies
|
|
594
|
-
const relations = await child.relations();
|
|
595
|
-
const inverseRelations = await child.inverseRelations();
|
|
739
|
+
const parentIssue = data.issue;
|
|
740
|
+
const subIssueIds = new Set(parentIssue.children.nodes.map((c) => c.id));
|
|
741
|
+
const graphNodes = parentIssue.children.nodes.map((child) => {
|
|
596
742
|
const blockedBy = [];
|
|
597
743
|
const blocks = [];
|
|
598
|
-
//
|
|
599
|
-
for (const rel of inverseRelations.nodes) {
|
|
600
|
-
if (rel.type === 'blocks') {
|
|
601
|
-
|
|
602
|
-
if (sourceIssue && subIssueIds.has(sourceIssue.id)) {
|
|
603
|
-
blockedBy.push(sourceIssue.identifier);
|
|
604
|
-
}
|
|
744
|
+
// Inverse relations: other issues blocking this one
|
|
745
|
+
for (const rel of child.inverseRelations.nodes) {
|
|
746
|
+
if (rel.type === 'blocks' && rel.issue && subIssueIds.has(rel.issue.id)) {
|
|
747
|
+
blockedBy.push(rel.issue.identifier);
|
|
605
748
|
}
|
|
606
749
|
}
|
|
607
|
-
//
|
|
608
|
-
for (const rel of relations.nodes) {
|
|
609
|
-
if (rel.type === 'blocks') {
|
|
610
|
-
|
|
611
|
-
if (relatedIssue && subIssueIds.has(relatedIssue.id)) {
|
|
612
|
-
blocks.push(relatedIssue.identifier);
|
|
613
|
-
}
|
|
750
|
+
// Outgoing relations: this issue blocking others
|
|
751
|
+
for (const rel of child.relations.nodes) {
|
|
752
|
+
if (rel.type === 'blocks' && rel.relatedIssue && subIssueIds.has(rel.relatedIssue.id)) {
|
|
753
|
+
blocks.push(rel.relatedIssue.identifier);
|
|
614
754
|
}
|
|
615
755
|
}
|
|
616
|
-
|
|
756
|
+
return {
|
|
617
757
|
issue: {
|
|
618
758
|
id: child.id,
|
|
619
759
|
identifier: child.identifier,
|
|
620
760
|
title: child.title,
|
|
621
761
|
description: child.description ?? undefined,
|
|
622
|
-
status: state?.name,
|
|
762
|
+
status: child.state?.name,
|
|
623
763
|
priority: child.priority,
|
|
624
|
-
labels: labels.nodes.map((l) => l.name),
|
|
764
|
+
labels: child.labels.nodes.map((l) => l.name),
|
|
625
765
|
url: child.url,
|
|
626
766
|
},
|
|
627
767
|
blockedBy,
|
|
628
768
|
blocks,
|
|
629
|
-
}
|
|
630
|
-
}
|
|
769
|
+
};
|
|
770
|
+
});
|
|
631
771
|
return {
|
|
632
772
|
parentId: parentIssue.id,
|
|
633
773
|
parentIdentifier: parentIssue.identifier,
|
|
634
774
|
subIssues: graphNodes,
|
|
635
775
|
};
|
|
636
|
-
}
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
779
|
+
const statusCode = extractAuthStatusCode(error);
|
|
780
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
781
|
+
}
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
637
784
|
}
|
|
638
785
|
}
|
|
639
786
|
/**
|
|
@@ -642,3 +789,27 @@ export class LinearAgentClient {
|
|
|
642
789
|
export function createLinearAgentClient(config) {
|
|
643
790
|
return new LinearAgentClient(config);
|
|
644
791
|
}
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// Helpers
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
/**
|
|
796
|
+
* Extract HTTP status code from an error for circuit breaker recording.
|
|
797
|
+
*/
|
|
798
|
+
function extractAuthStatusCode(error) {
|
|
799
|
+
if (typeof error !== 'object' || error === null)
|
|
800
|
+
return 0;
|
|
801
|
+
const err = error;
|
|
802
|
+
if (typeof err.status === 'number')
|
|
803
|
+
return err.status;
|
|
804
|
+
if (typeof err.statusCode === 'number')
|
|
805
|
+
return err.statusCode;
|
|
806
|
+
const response = err.response;
|
|
807
|
+
if (response) {
|
|
808
|
+
if (typeof response.status === 'number')
|
|
809
|
+
return response.status;
|
|
810
|
+
if (typeof response.statusCode === 'number')
|
|
811
|
+
return response.statusCode;
|
|
812
|
+
}
|
|
813
|
+
// Default to 400 for auth errors detected by message pattern
|
|
814
|
+
return 400;
|
|
815
|
+
}
|