@renseiai/agentfactory-linear 0.8.0
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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/src/agent-client-project-repo.test.d.ts +2 -0
- package/dist/src/agent-client-project-repo.test.d.ts.map +1 -0
- package/dist/src/agent-client-project-repo.test.js +153 -0
- package/dist/src/agent-client.d.ts +261 -0
- package/dist/src/agent-client.d.ts.map +1 -0
- package/dist/src/agent-client.js +902 -0
- package/dist/src/agent-session.d.ts +303 -0
- package/dist/src/agent-session.d.ts.map +1 -0
- package/dist/src/agent-session.js +969 -0
- package/dist/src/checkbox-utils.d.ts +88 -0
- package/dist/src/checkbox-utils.d.ts.map +1 -0
- package/dist/src/checkbox-utils.js +120 -0
- 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/constants.d.ts +87 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +101 -0
- package/dist/src/defaults/auto-trigger.d.ts +35 -0
- package/dist/src/defaults/auto-trigger.d.ts.map +1 -0
- package/dist/src/defaults/auto-trigger.js +36 -0
- package/dist/src/defaults/index.d.ts +12 -0
- package/dist/src/defaults/index.d.ts.map +1 -0
- package/dist/src/defaults/index.js +11 -0
- package/dist/src/defaults/priority.d.ts +20 -0
- package/dist/src/defaults/priority.d.ts.map +1 -0
- package/dist/src/defaults/priority.js +37 -0
- package/dist/src/defaults/prompts.d.ts +42 -0
- package/dist/src/defaults/prompts.d.ts.map +1 -0
- package/dist/src/defaults/prompts.js +310 -0
- package/dist/src/defaults/prompts.test.d.ts +2 -0
- package/dist/src/defaults/prompts.test.d.ts.map +1 -0
- package/dist/src/defaults/prompts.test.js +263 -0
- package/dist/src/defaults/work-type-detection.d.ts +19 -0
- package/dist/src/defaults/work-type-detection.d.ts.map +1 -0
- package/dist/src/defaults/work-type-detection.js +93 -0
- package/dist/src/errors.d.ts +91 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +173 -0
- package/dist/src/frontend-adapter.d.ts +168 -0
- package/dist/src/frontend-adapter.d.ts.map +1 -0
- package/dist/src/frontend-adapter.js +314 -0
- package/dist/src/frontend-adapter.test.d.ts +2 -0
- package/dist/src/frontend-adapter.test.d.ts.map +1 -0
- package/dist/src/frontend-adapter.test.js +545 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +30 -0
- 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/platform-adapter.d.ts +132 -0
- package/dist/src/platform-adapter.d.ts.map +1 -0
- package/dist/src/platform-adapter.js +260 -0
- package/dist/src/platform-adapter.test.d.ts +2 -0
- package/dist/src/platform-adapter.test.d.ts.map +1 -0
- package/dist/src/platform-adapter.test.js +468 -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/rate-limiter.d.ts +64 -0
- package/dist/src/rate-limiter.d.ts.map +1 -0
- package/dist/src/rate-limiter.js +163 -0
- package/dist/src/rate-limiter.test.d.ts +2 -0
- package/dist/src/rate-limiter.test.d.ts.map +1 -0
- package/dist/src/rate-limiter.test.js +217 -0
- package/dist/src/retry.d.ts +59 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +82 -0
- package/dist/src/types.d.ts +492 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +143 -0
- package/dist/src/utils.d.ts +52 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +277 -0
- package/dist/src/webhook-types.d.ts +308 -0
- package/dist/src/webhook-types.d.ts.map +1 -0
- package/dist/src/webhook-types.js +46 -0
- package/package.json +70 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { LinearClient, AgentActivitySignal as LinearAgentActivitySignal, IssueRelationType as LinearIssueRelationType, } from '@linear/sdk';
|
|
2
|
+
import { LinearApiError, LinearStatusTransitionError } from './errors.js';
|
|
3
|
+
import { withRetry, DEFAULT_RETRY_CONFIG } from './retry.js';
|
|
4
|
+
import { TokenBucket, extractRetryAfterMs } from './rate-limiter.js';
|
|
5
|
+
import { CircuitBreaker } from './circuit-breaker.js';
|
|
6
|
+
/**
|
|
7
|
+
* Core Linear Agent Client
|
|
8
|
+
* Wraps @linear/sdk with retry logic and helper methods
|
|
9
|
+
*/
|
|
10
|
+
export class LinearAgentClient {
|
|
11
|
+
client;
|
|
12
|
+
retryConfig;
|
|
13
|
+
rateLimiter;
|
|
14
|
+
circuitBreaker;
|
|
15
|
+
onApiResponse;
|
|
16
|
+
statusCache = new Map();
|
|
17
|
+
_apiCallCount = 0;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.client = new LinearClient({
|
|
20
|
+
apiKey: config.apiKey,
|
|
21
|
+
...(config.baseUrl && { apiUrl: config.baseUrl }),
|
|
22
|
+
});
|
|
23
|
+
this.retryConfig = {
|
|
24
|
+
...DEFAULT_RETRY_CONFIG,
|
|
25
|
+
...config.retry,
|
|
26
|
+
};
|
|
27
|
+
this.rateLimiter = config.rateLimiterStrategy ?? new TokenBucket(config.rateLimit);
|
|
28
|
+
this.circuitBreaker = config.circuitBreakerStrategy ?? new CircuitBreaker(config.circuitBreaker);
|
|
29
|
+
this.onApiResponse = config.onApiResponse;
|
|
30
|
+
}
|
|
31
|
+
/** Number of successful API calls since last reset */
|
|
32
|
+
get apiCallCount() {
|
|
33
|
+
return this._apiCallCount;
|
|
34
|
+
}
|
|
35
|
+
/** Reset the API call counter (typically called at the start of each scan) */
|
|
36
|
+
resetApiCallCount() {
|
|
37
|
+
this._apiCallCount = 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get the underlying LinearClient instance
|
|
41
|
+
*/
|
|
42
|
+
get linearClient() {
|
|
43
|
+
return this.client;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Execute an operation with circuit breaker, rate limiting, and retry logic.
|
|
47
|
+
*
|
|
48
|
+
* Order of operations:
|
|
49
|
+
* 1. Check circuit breaker — if open, throw CircuitOpenError (zero quota consumed)
|
|
50
|
+
* 2. Acquire rate limit token
|
|
51
|
+
* 3. Execute the operation
|
|
52
|
+
* 4. On success: record success on circuit breaker
|
|
53
|
+
* 5. On auth error: record failure on circuit breaker (may trip it)
|
|
54
|
+
* 6. On retryable error: retry with exponential backoff
|
|
55
|
+
*/
|
|
56
|
+
async withRetry(fn) {
|
|
57
|
+
return withRetry(async () => {
|
|
58
|
+
// Check circuit breaker BEFORE acquiring a rate limit token
|
|
59
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
60
|
+
if (!canProceed) {
|
|
61
|
+
// Create a descriptive error; if the breaker is a CircuitBreaker instance, use its helper
|
|
62
|
+
const breaker = this.circuitBreaker;
|
|
63
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
64
|
+
throw breaker.createOpenError();
|
|
65
|
+
}
|
|
66
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
67
|
+
}
|
|
68
|
+
await this.rateLimiter.acquire();
|
|
69
|
+
try {
|
|
70
|
+
const result = await fn();
|
|
71
|
+
// Record success to close/reset the circuit
|
|
72
|
+
await this.circuitBreaker.recordSuccess();
|
|
73
|
+
this._apiCallCount++;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Check if this is an auth error that should trip the circuit
|
|
78
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
79
|
+
const statusCode = extractAuthStatusCode(error);
|
|
80
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
81
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
82
|
+
console.warn(`[LinearAgentClient] Auth error detected (status ${statusCode}), circuit breaker notified: ${msg}`);
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}, {
|
|
87
|
+
config: this.retryConfig,
|
|
88
|
+
getRetryAfterMs: extractRetryAfterMs,
|
|
89
|
+
onRateLimited: (retryAfterMs) => {
|
|
90
|
+
const seconds = retryAfterMs / 1000;
|
|
91
|
+
console.warn(`[LinearAgentClient] Rate limited by Linear API, backing off ${seconds}s`);
|
|
92
|
+
this.rateLimiter.penalize(seconds);
|
|
93
|
+
},
|
|
94
|
+
onRetry: ({ attempt, delay }) => {
|
|
95
|
+
console.log(`[LinearAgentClient] Retry attempt ${attempt + 1}/${this.retryConfig.maxRetries}, ` +
|
|
96
|
+
`waiting ${delay}ms`);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Fetch an issue by ID or identifier (e.g., "SUP-50")
|
|
102
|
+
*/
|
|
103
|
+
async getIssue(issueIdOrIdentifier) {
|
|
104
|
+
return this.withRetry(async () => {
|
|
105
|
+
const issue = await this.client.issue(issueIdOrIdentifier);
|
|
106
|
+
if (!issue) {
|
|
107
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
108
|
+
}
|
|
109
|
+
return issue;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Update an issue's properties
|
|
114
|
+
*/
|
|
115
|
+
async updateIssue(issueId, data) {
|
|
116
|
+
return this.withRetry(async () => {
|
|
117
|
+
const payload = await this.client.updateIssue(issueId, data);
|
|
118
|
+
if (!payload.success) {
|
|
119
|
+
throw new LinearApiError(`Failed to update issue: ${issueId}`, 400, payload);
|
|
120
|
+
}
|
|
121
|
+
return this.client.issue(issueId);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove the assignee from an issue (unassign)
|
|
126
|
+
* Used when agent completes work to enable clean handoff visibility
|
|
127
|
+
*/
|
|
128
|
+
async unassignIssue(issueId) {
|
|
129
|
+
return this.withRetry(async () => {
|
|
130
|
+
// Linear SDK expects null to clear assignee
|
|
131
|
+
const payload = await this.client.updateIssue(issueId, {
|
|
132
|
+
assigneeId: null,
|
|
133
|
+
});
|
|
134
|
+
if (!payload.success) {
|
|
135
|
+
throw new LinearApiError(`Failed to unassign issue: ${issueId}`, 400, payload);
|
|
136
|
+
}
|
|
137
|
+
return this.client.issue(issueId);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Get workflow states for a team (cached)
|
|
142
|
+
*/
|
|
143
|
+
async getTeamStatuses(teamId) {
|
|
144
|
+
if (this.statusCache.has(teamId)) {
|
|
145
|
+
return this.statusCache.get(teamId);
|
|
146
|
+
}
|
|
147
|
+
return this.withRetry(async () => {
|
|
148
|
+
const team = await this.client.team(teamId);
|
|
149
|
+
const states = await team.states();
|
|
150
|
+
const mapping = {};
|
|
151
|
+
for (const state of states.nodes) {
|
|
152
|
+
mapping[state.name] = state.id;
|
|
153
|
+
}
|
|
154
|
+
this.statusCache.set(teamId, mapping);
|
|
155
|
+
return mapping;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Update issue status by name (e.g., "Started", "Finished")
|
|
160
|
+
*/
|
|
161
|
+
async updateIssueStatus(issueId, statusName) {
|
|
162
|
+
return this.withRetry(async () => {
|
|
163
|
+
const issue = await this.client.issue(issueId);
|
|
164
|
+
const team = await issue.team;
|
|
165
|
+
if (!team) {
|
|
166
|
+
throw new LinearApiError(`Cannot find team for issue: ${issueId}`, 400);
|
|
167
|
+
}
|
|
168
|
+
const statuses = await this.getTeamStatuses(team.id);
|
|
169
|
+
const stateId = statuses[statusName];
|
|
170
|
+
if (!stateId) {
|
|
171
|
+
const currentState = await issue.state;
|
|
172
|
+
throw new LinearStatusTransitionError(`Status "${statusName}" not found in team "${team.name}"`, issueId, currentState?.name ?? 'unknown', statusName);
|
|
173
|
+
}
|
|
174
|
+
return this.updateIssue(issueId, { stateId });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Create a comment on an issue
|
|
179
|
+
*/
|
|
180
|
+
async createComment(issueId, body) {
|
|
181
|
+
return this.withRetry(async () => {
|
|
182
|
+
const payload = await this.client.createComment({
|
|
183
|
+
issueId,
|
|
184
|
+
body,
|
|
185
|
+
});
|
|
186
|
+
if (!payload.success) {
|
|
187
|
+
throw new LinearApiError(`Failed to create comment on issue: ${issueId}`, 400, payload);
|
|
188
|
+
}
|
|
189
|
+
const comment = await payload.comment;
|
|
190
|
+
if (!comment) {
|
|
191
|
+
throw new LinearApiError(`Comment created but not returned for issue: ${issueId}`, 500);
|
|
192
|
+
}
|
|
193
|
+
return comment;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get comments for an issue
|
|
198
|
+
*/
|
|
199
|
+
async getIssueComments(issueId) {
|
|
200
|
+
return this.withRetry(async () => {
|
|
201
|
+
const issue = await this.client.issue(issueId);
|
|
202
|
+
const comments = await issue.comments();
|
|
203
|
+
return comments.nodes;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Create a new issue
|
|
208
|
+
*/
|
|
209
|
+
async createIssue(input) {
|
|
210
|
+
return this.withRetry(async () => {
|
|
211
|
+
const payload = await this.client.createIssue(input);
|
|
212
|
+
if (!payload.success) {
|
|
213
|
+
throw new LinearApiError(`Failed to create issue: ${input.title}`, 400, payload);
|
|
214
|
+
}
|
|
215
|
+
const issue = await payload.issue;
|
|
216
|
+
if (!issue) {
|
|
217
|
+
throw new LinearApiError(`Issue created but not returned: ${input.title}`, 500);
|
|
218
|
+
}
|
|
219
|
+
return issue;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get the authenticated user (the agent)
|
|
224
|
+
*/
|
|
225
|
+
async getViewer() {
|
|
226
|
+
return this.withRetry(() => this.client.viewer);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Get a team by ID, key, or display name
|
|
230
|
+
*/
|
|
231
|
+
async getTeam(teamIdOrKeyOrName) {
|
|
232
|
+
return this.withRetry(async () => {
|
|
233
|
+
try {
|
|
234
|
+
return await this.client.team(teamIdOrKeyOrName);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Fallback: search by display name
|
|
238
|
+
const teams = await this.client.teams({
|
|
239
|
+
filter: { name: { eqIgnoreCase: teamIdOrKeyOrName } },
|
|
240
|
+
});
|
|
241
|
+
if (teams.nodes.length === 0) {
|
|
242
|
+
throw new Error(`Team not found: "${teamIdOrKeyOrName}"`);
|
|
243
|
+
}
|
|
244
|
+
return teams.nodes[0];
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Create an agent activity using the native Linear Agent API
|
|
250
|
+
*
|
|
251
|
+
* @param input - The activity input containing session ID, content, and options
|
|
252
|
+
* @returns Result indicating success and the created activity ID
|
|
253
|
+
*/
|
|
254
|
+
async createAgentActivity(input) {
|
|
255
|
+
return this.withRetry(async () => {
|
|
256
|
+
const signalMap = {
|
|
257
|
+
auth: LinearAgentActivitySignal.Auth,
|
|
258
|
+
continue: LinearAgentActivitySignal.Continue,
|
|
259
|
+
select: LinearAgentActivitySignal.Select,
|
|
260
|
+
stop: LinearAgentActivitySignal.Stop,
|
|
261
|
+
};
|
|
262
|
+
const payload = await this.client.createAgentActivity({
|
|
263
|
+
agentSessionId: input.agentSessionId,
|
|
264
|
+
content: input.content,
|
|
265
|
+
ephemeral: input.ephemeral,
|
|
266
|
+
id: input.id,
|
|
267
|
+
signal: input.signal ? signalMap[input.signal] : undefined,
|
|
268
|
+
});
|
|
269
|
+
if (!payload.success) {
|
|
270
|
+
throw new LinearApiError(`Failed to create agent activity for session: ${input.agentSessionId}`, 400, payload);
|
|
271
|
+
}
|
|
272
|
+
const activity = await payload.agentActivity;
|
|
273
|
+
return {
|
|
274
|
+
success: true,
|
|
275
|
+
activityId: activity?.id,
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Update an agent session
|
|
281
|
+
*
|
|
282
|
+
* Use this to set the externalUrl (linking to agent dashboard/logs)
|
|
283
|
+
* within 10 seconds of receiving a webhook to avoid appearing unresponsive.
|
|
284
|
+
*
|
|
285
|
+
* @param input - The session update input containing sessionId and updates
|
|
286
|
+
* @returns Result indicating success and the session ID
|
|
287
|
+
*/
|
|
288
|
+
async updateAgentSession(input) {
|
|
289
|
+
return this.withRetry(async () => {
|
|
290
|
+
const payload = await this.client.updateAgentSession(input.sessionId, {
|
|
291
|
+
externalUrls: input.externalUrls,
|
|
292
|
+
externalLink: input.externalLink,
|
|
293
|
+
plan: input.plan,
|
|
294
|
+
});
|
|
295
|
+
if (!payload.success) {
|
|
296
|
+
throw new LinearApiError(`Failed to update agent session: ${input.sessionId}`, 400, payload);
|
|
297
|
+
}
|
|
298
|
+
const session = await payload.agentSession;
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
sessionId: session?.id,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Create an agent session on an issue
|
|
307
|
+
*
|
|
308
|
+
* Use this to programmatically create a Linear AgentSession when status transitions
|
|
309
|
+
* occur without explicit agent mention/delegation (e.g., Icebox -> Backlog).
|
|
310
|
+
*
|
|
311
|
+
* This enables the Linear Agent Session UI to show real-time activities even when
|
|
312
|
+
* the agent work is triggered by status changes rather than user mentions.
|
|
313
|
+
*
|
|
314
|
+
* @param input - The session creation input containing issueId and optional external URLs
|
|
315
|
+
* @returns Result indicating success and the created session ID
|
|
316
|
+
*/
|
|
317
|
+
async createAgentSessionOnIssue(input) {
|
|
318
|
+
return this.withRetry(async () => {
|
|
319
|
+
const payload = await this.client.agentSessionCreateOnIssue({
|
|
320
|
+
issueId: input.issueId,
|
|
321
|
+
externalUrls: input.externalUrls,
|
|
322
|
+
externalLink: input.externalLink,
|
|
323
|
+
});
|
|
324
|
+
if (!payload.success) {
|
|
325
|
+
throw new LinearApiError(`Failed to create agent session on issue: ${input.issueId}`, 400, payload);
|
|
326
|
+
}
|
|
327
|
+
const session = await payload.agentSession;
|
|
328
|
+
return {
|
|
329
|
+
success: true,
|
|
330
|
+
sessionId: session?.id,
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// ISSUE RELATION METHODS
|
|
336
|
+
// ============================================================================
|
|
337
|
+
/**
|
|
338
|
+
* Create a relation between two issues
|
|
339
|
+
*
|
|
340
|
+
* @param input - The relation input containing issue IDs and relation type
|
|
341
|
+
* @returns Result indicating success and the created relation ID
|
|
342
|
+
*
|
|
343
|
+
* Relation types:
|
|
344
|
+
* - 'related': General association between issues
|
|
345
|
+
* - 'blocks': Source issue blocks the related issue from progressing
|
|
346
|
+
* - 'duplicate': Source issue is a duplicate of the related issue
|
|
347
|
+
*/
|
|
348
|
+
async createIssueRelation(input) {
|
|
349
|
+
return this.withRetry(async () => {
|
|
350
|
+
// Map our string type to the SDK's enum
|
|
351
|
+
const typeMap = {
|
|
352
|
+
related: LinearIssueRelationType.Related,
|
|
353
|
+
blocks: LinearIssueRelationType.Blocks,
|
|
354
|
+
duplicate: LinearIssueRelationType.Duplicate,
|
|
355
|
+
};
|
|
356
|
+
const payload = await this.client.createIssueRelation({
|
|
357
|
+
issueId: input.issueId,
|
|
358
|
+
relatedIssueId: input.relatedIssueId,
|
|
359
|
+
type: typeMap[input.type],
|
|
360
|
+
});
|
|
361
|
+
if (!payload.success) {
|
|
362
|
+
throw new LinearApiError(`Failed to create issue relation: ${input.issueId} -> ${input.relatedIssueId}`, 400, payload);
|
|
363
|
+
}
|
|
364
|
+
const relation = await payload.issueRelation;
|
|
365
|
+
return {
|
|
366
|
+
success: true,
|
|
367
|
+
relationId: relation?.id,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Create multiple relations from a source issue to multiple target issues
|
|
373
|
+
*
|
|
374
|
+
* @param input - Batch input containing source issue, target issues, and relation type
|
|
375
|
+
* @returns Batch result with successful relation IDs and any errors
|
|
376
|
+
*/
|
|
377
|
+
async createIssueRelationsBatch(input) {
|
|
378
|
+
const relationIds = [];
|
|
379
|
+
const errors = [];
|
|
380
|
+
for (const targetIssueId of input.targetIssueIds) {
|
|
381
|
+
try {
|
|
382
|
+
const result = await this.createIssueRelation({
|
|
383
|
+
issueId: input.sourceIssueId,
|
|
384
|
+
relatedIssueId: targetIssueId,
|
|
385
|
+
type: input.type,
|
|
386
|
+
});
|
|
387
|
+
if (result.relationId) {
|
|
388
|
+
relationIds.push(result.relationId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
errors.push({
|
|
393
|
+
targetIssueId,
|
|
394
|
+
error: error instanceof Error ? error.message : String(error),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
success: errors.length === 0,
|
|
400
|
+
relationIds,
|
|
401
|
+
errors,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Get all relations for an issue (both outgoing and incoming)
|
|
406
|
+
*
|
|
407
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
408
|
+
*
|
|
409
|
+
* @param issueId - The issue ID or identifier (e.g., "SUP-123")
|
|
410
|
+
* @returns Relations result with both directions of relationships
|
|
411
|
+
*/
|
|
412
|
+
async getIssueRelations(issueId) {
|
|
413
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
414
|
+
if (!canProceed) {
|
|
415
|
+
const breaker = this.circuitBreaker;
|
|
416
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
417
|
+
throw breaker.createOpenError();
|
|
418
|
+
}
|
|
419
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
420
|
+
}
|
|
421
|
+
await this.rateLimiter.acquire();
|
|
422
|
+
const query = `
|
|
423
|
+
query IssueRelations($id: String!) {
|
|
424
|
+
issue(id: $id) {
|
|
425
|
+
id
|
|
426
|
+
identifier
|
|
427
|
+
relations(first: 50) {
|
|
428
|
+
nodes {
|
|
429
|
+
id
|
|
430
|
+
type
|
|
431
|
+
createdAt
|
|
432
|
+
relatedIssue { id identifier }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
inverseRelations(first: 50) {
|
|
436
|
+
nodes {
|
|
437
|
+
id
|
|
438
|
+
type
|
|
439
|
+
createdAt
|
|
440
|
+
issue { id identifier }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
`;
|
|
446
|
+
try {
|
|
447
|
+
const result = await this.client.client.rawRequest(query, { id: issueId });
|
|
448
|
+
await this.circuitBreaker.recordSuccess();
|
|
449
|
+
this._apiCallCount++;
|
|
450
|
+
const quota = extractQuotaFromHeaders(result.headers);
|
|
451
|
+
if (quota)
|
|
452
|
+
this.onApiResponse?.(quota);
|
|
453
|
+
const data = result.data;
|
|
454
|
+
if (!data.issue) {
|
|
455
|
+
throw new LinearApiError(`Issue not found: ${issueId}`, 404);
|
|
456
|
+
}
|
|
457
|
+
const relations = data.issue.relations.nodes.map((rel) => ({
|
|
458
|
+
id: rel.id,
|
|
459
|
+
type: rel.type,
|
|
460
|
+
issueId: data.issue.id,
|
|
461
|
+
issueIdentifier: data.issue.identifier,
|
|
462
|
+
relatedIssueId: rel.relatedIssue?.id ?? '',
|
|
463
|
+
relatedIssueIdentifier: rel.relatedIssue?.identifier,
|
|
464
|
+
createdAt: new Date(rel.createdAt),
|
|
465
|
+
}));
|
|
466
|
+
const inverseRelations = data.issue.inverseRelations.nodes.map((rel) => ({
|
|
467
|
+
id: rel.id,
|
|
468
|
+
type: rel.type,
|
|
469
|
+
issueId: rel.issue?.id ?? '',
|
|
470
|
+
issueIdentifier: rel.issue?.identifier,
|
|
471
|
+
relatedIssueId: data.issue.id,
|
|
472
|
+
relatedIssueIdentifier: data.issue.identifier,
|
|
473
|
+
createdAt: new Date(rel.createdAt),
|
|
474
|
+
}));
|
|
475
|
+
return { relations, inverseRelations };
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
479
|
+
const statusCode = extractAuthStatusCode(error);
|
|
480
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
481
|
+
}
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Delete an issue relation
|
|
487
|
+
*
|
|
488
|
+
* @param relationId - The relation ID to delete
|
|
489
|
+
* @returns Result indicating success
|
|
490
|
+
*/
|
|
491
|
+
async deleteIssueRelation(relationId) {
|
|
492
|
+
return this.withRetry(async () => {
|
|
493
|
+
const payload = await this.client.deleteIssueRelation(relationId);
|
|
494
|
+
if (!payload.success) {
|
|
495
|
+
throw new LinearApiError(`Failed to delete issue relation: ${relationId}`, 400, payload);
|
|
496
|
+
}
|
|
497
|
+
return { success: true };
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
// ============================================================================
|
|
501
|
+
// SUB-ISSUE METHODS (for coordination work type)
|
|
502
|
+
// ============================================================================
|
|
503
|
+
/**
|
|
504
|
+
* Fetch all child issues (sub-issues) of a parent issue
|
|
505
|
+
*
|
|
506
|
+
* @param issueIdOrIdentifier - The parent issue ID or identifier (e.g., "SUP-100")
|
|
507
|
+
* @returns Array of child issues
|
|
508
|
+
*/
|
|
509
|
+
async getSubIssues(issueIdOrIdentifier) {
|
|
510
|
+
return this.withRetry(async () => {
|
|
511
|
+
const parentIssue = await this.client.issue(issueIdOrIdentifier);
|
|
512
|
+
if (!parentIssue) {
|
|
513
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
514
|
+
}
|
|
515
|
+
const children = await parentIssue.children();
|
|
516
|
+
return children.nodes;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Check if an issue has a parent (is a child/sub-issue)
|
|
521
|
+
*
|
|
522
|
+
* @param issueIdOrIdentifier - The issue ID or identifier
|
|
523
|
+
* @returns True if the issue has a parent issue
|
|
524
|
+
*/
|
|
525
|
+
async isChildIssue(issueIdOrIdentifier) {
|
|
526
|
+
return this.withRetry(async () => {
|
|
527
|
+
const issue = await this.client.issue(issueIdOrIdentifier);
|
|
528
|
+
if (!issue) {
|
|
529
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
530
|
+
}
|
|
531
|
+
const parent = await issue.parent;
|
|
532
|
+
return parent != null;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Fetch all non-terminal issues in a project using a single GraphQL query.
|
|
537
|
+
*
|
|
538
|
+
* Replaces the N+1 pattern of fetching issues then lazy-loading state/labels/parent/project
|
|
539
|
+
* for each one. Returns pre-resolved data suitable for GovernorIssue construction.
|
|
540
|
+
*
|
|
541
|
+
* @param project - Linear project name
|
|
542
|
+
* @returns Array of issue data with childCount for parent detection
|
|
543
|
+
*/
|
|
544
|
+
async listProjectIssues(project) {
|
|
545
|
+
// Check circuit breaker before consuming rate limit token
|
|
546
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
547
|
+
if (!canProceed) {
|
|
548
|
+
const breaker = this.circuitBreaker;
|
|
549
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
550
|
+
throw breaker.createOpenError();
|
|
551
|
+
}
|
|
552
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
553
|
+
}
|
|
554
|
+
await this.rateLimiter.acquire();
|
|
555
|
+
const query = `
|
|
556
|
+
query ListProjectIssues($filter: IssueFilter!) {
|
|
557
|
+
issues(filter: $filter, first: 250) {
|
|
558
|
+
nodes {
|
|
559
|
+
id
|
|
560
|
+
identifier
|
|
561
|
+
title
|
|
562
|
+
description
|
|
563
|
+
createdAt
|
|
564
|
+
state { name }
|
|
565
|
+
labels { nodes { name } }
|
|
566
|
+
parent { id }
|
|
567
|
+
project { name }
|
|
568
|
+
children { nodes { id } }
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
`;
|
|
573
|
+
const terminalStatuses = ['Accepted', 'Canceled', 'Duplicate'];
|
|
574
|
+
try {
|
|
575
|
+
const result = await this.client.client.rawRequest(query, {
|
|
576
|
+
filter: {
|
|
577
|
+
project: { name: { eq: project } },
|
|
578
|
+
state: { name: { nin: terminalStatuses } },
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
// Record success on circuit breaker
|
|
582
|
+
await this.circuitBreaker.recordSuccess();
|
|
583
|
+
this._apiCallCount++;
|
|
584
|
+
// Extract and report quota
|
|
585
|
+
const quota = extractQuotaFromHeaders(result.headers);
|
|
586
|
+
if (quota)
|
|
587
|
+
this.onApiResponse?.(quota);
|
|
588
|
+
const data = result.data;
|
|
589
|
+
return data.issues.nodes.map((node) => ({
|
|
590
|
+
id: node.id,
|
|
591
|
+
identifier: node.identifier,
|
|
592
|
+
title: node.title,
|
|
593
|
+
description: node.description ?? undefined,
|
|
594
|
+
status: node.state?.name ?? 'Backlog',
|
|
595
|
+
labels: node.labels.nodes.map((l) => l.name),
|
|
596
|
+
createdAt: new Date(node.createdAt).getTime(),
|
|
597
|
+
parentId: node.parent?.id ?? undefined,
|
|
598
|
+
project: node.project?.name ?? undefined,
|
|
599
|
+
childCount: node.children.nodes.length,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
604
|
+
const statusCode = extractAuthStatusCode(error);
|
|
605
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
606
|
+
}
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Check if an issue has child issues (is a parent issue)
|
|
612
|
+
*
|
|
613
|
+
* @param issueIdOrIdentifier - The issue ID or identifier
|
|
614
|
+
* @returns True if the issue has at least one child issue
|
|
615
|
+
*/
|
|
616
|
+
async isParentIssue(issueIdOrIdentifier) {
|
|
617
|
+
return this.withRetry(async () => {
|
|
618
|
+
const issue = await this.client.issue(issueIdOrIdentifier);
|
|
619
|
+
if (!issue) {
|
|
620
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
621
|
+
}
|
|
622
|
+
const children = await issue.children();
|
|
623
|
+
return children.nodes.length > 0;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Check if any sub-issues have been worked on (moved beyond unworked states).
|
|
628
|
+
*
|
|
629
|
+
* Used to decide whether to use acceptance-coordination (for parent issues
|
|
630
|
+
* whose sub-issues were actually worked) vs regular acceptance (for parent
|
|
631
|
+
* issues with only unworked sub-issues).
|
|
632
|
+
*
|
|
633
|
+
* @param issueId - The parent issue ID or identifier
|
|
634
|
+
* @returns True if at least one sub-issue has moved beyond Backlog/Icebox/Triage
|
|
635
|
+
*/
|
|
636
|
+
async hasWorkedSubIssues(issueId) {
|
|
637
|
+
const statuses = await this.getSubIssueStatuses(issueId);
|
|
638
|
+
const unworkedStates = new Set(['Backlog', 'Icebox', 'Triage']);
|
|
639
|
+
return statuses.some(s => !unworkedStates.has(s.status));
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Get lightweight sub-issue statuses (no blocking relations)
|
|
643
|
+
*
|
|
644
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
645
|
+
* Returns identifier, title, and status for each sub-issue.
|
|
646
|
+
* Used by QA and acceptance agents to validate sub-issue completion
|
|
647
|
+
* without the overhead of fetching the full dependency graph.
|
|
648
|
+
*
|
|
649
|
+
* @param issueIdOrIdentifier - The parent issue ID or identifier
|
|
650
|
+
* @returns Array of sub-issue statuses
|
|
651
|
+
*/
|
|
652
|
+
async getSubIssueStatuses(issueIdOrIdentifier) {
|
|
653
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
654
|
+
if (!canProceed) {
|
|
655
|
+
const breaker = this.circuitBreaker;
|
|
656
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
657
|
+
throw breaker.createOpenError();
|
|
658
|
+
}
|
|
659
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
660
|
+
}
|
|
661
|
+
await this.rateLimiter.acquire();
|
|
662
|
+
const query = `
|
|
663
|
+
query SubIssueStatuses($id: String!) {
|
|
664
|
+
issue(id: $id) {
|
|
665
|
+
children(first: 50) {
|
|
666
|
+
nodes {
|
|
667
|
+
identifier
|
|
668
|
+
title
|
|
669
|
+
state { name }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
`;
|
|
675
|
+
try {
|
|
676
|
+
const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
|
|
677
|
+
await this.circuitBreaker.recordSuccess();
|
|
678
|
+
this._apiCallCount++;
|
|
679
|
+
const quota = extractQuotaFromHeaders(result.headers);
|
|
680
|
+
if (quota)
|
|
681
|
+
this.onApiResponse?.(quota);
|
|
682
|
+
const data = result.data;
|
|
683
|
+
if (!data.issue) {
|
|
684
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
685
|
+
}
|
|
686
|
+
return data.issue.children.nodes.map((child) => ({
|
|
687
|
+
identifier: child.identifier,
|
|
688
|
+
title: child.title,
|
|
689
|
+
status: child.state?.name ?? 'Unknown',
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
694
|
+
const statusCode = extractAuthStatusCode(error);
|
|
695
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
696
|
+
}
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Get the repository URL associated with a project via its links or description
|
|
702
|
+
*
|
|
703
|
+
* Checks project links for a link with label matching 'Repository' or 'GitHub'
|
|
704
|
+
* (case-insensitive). Falls back to parsing the project description for a
|
|
705
|
+
* "Repository: <url>" pattern.
|
|
706
|
+
*
|
|
707
|
+
* @param projectId - The project ID
|
|
708
|
+
* @returns The repository URL if found, null otherwise
|
|
709
|
+
*/
|
|
710
|
+
async getProjectRepositoryUrl(projectId) {
|
|
711
|
+
return this.withRetry(async () => {
|
|
712
|
+
const project = await this.client.project(projectId);
|
|
713
|
+
// Check project external links for a Repository/GitHub link
|
|
714
|
+
const links = await project.externalLinks();
|
|
715
|
+
for (const link of links.nodes) {
|
|
716
|
+
if (link.label && /^(repository|github)$/i.test(link.label)) {
|
|
717
|
+
return link.url;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Fallback: check project description for Repository: pattern
|
|
721
|
+
if (project.description) {
|
|
722
|
+
const match = project.description.match(/Repository:\s*([\S]+)/i);
|
|
723
|
+
if (match) {
|
|
724
|
+
return match[1];
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Get sub-issues with their blocking relations for dependency graph building
|
|
732
|
+
*
|
|
733
|
+
* Uses a single raw GraphQL query instead of N+1 lazy-loaded SDK calls.
|
|
734
|
+
* Previous implementation made 2 + 4N + M API calls (where N = children,
|
|
735
|
+
* M = total relations). This version makes exactly 1 API call.
|
|
736
|
+
*
|
|
737
|
+
* Builds a complete dependency graph of a parent issue's children, including
|
|
738
|
+
* which sub-issues block which other sub-issues. This is used by the coordinator
|
|
739
|
+
* agent to determine execution order.
|
|
740
|
+
*
|
|
741
|
+
* @param issueIdOrIdentifier - The parent issue ID or identifier
|
|
742
|
+
* @returns The sub-issue dependency graph
|
|
743
|
+
*/
|
|
744
|
+
async getSubIssueGraph(issueIdOrIdentifier) {
|
|
745
|
+
const canProceed = await this.circuitBreaker.canProceed();
|
|
746
|
+
if (!canProceed) {
|
|
747
|
+
const breaker = this.circuitBreaker;
|
|
748
|
+
if (typeof breaker.createOpenError === 'function') {
|
|
749
|
+
throw breaker.createOpenError();
|
|
750
|
+
}
|
|
751
|
+
throw new LinearApiError('Circuit breaker is open — API calls blocked', 503);
|
|
752
|
+
}
|
|
753
|
+
await this.rateLimiter.acquire();
|
|
754
|
+
const query = `
|
|
755
|
+
query SubIssueGraph($id: String!) {
|
|
756
|
+
issue(id: $id) {
|
|
757
|
+
id
|
|
758
|
+
identifier
|
|
759
|
+
children(first: 50) {
|
|
760
|
+
nodes {
|
|
761
|
+
id
|
|
762
|
+
identifier
|
|
763
|
+
title
|
|
764
|
+
description
|
|
765
|
+
priority
|
|
766
|
+
url
|
|
767
|
+
state { name }
|
|
768
|
+
labels(first: 20) { nodes { name } }
|
|
769
|
+
relations(first: 50) {
|
|
770
|
+
nodes {
|
|
771
|
+
type
|
|
772
|
+
relatedIssue { id identifier }
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
inverseRelations(first: 50) {
|
|
776
|
+
nodes {
|
|
777
|
+
type
|
|
778
|
+
issue { id identifier }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
`;
|
|
786
|
+
try {
|
|
787
|
+
const result = await this.client.client.rawRequest(query, { id: issueIdOrIdentifier });
|
|
788
|
+
await this.circuitBreaker.recordSuccess();
|
|
789
|
+
this._apiCallCount++;
|
|
790
|
+
const quota = extractQuotaFromHeaders(result.headers);
|
|
791
|
+
if (quota)
|
|
792
|
+
this.onApiResponse?.(quota);
|
|
793
|
+
const data = result.data;
|
|
794
|
+
if (!data.issue) {
|
|
795
|
+
throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
|
|
796
|
+
}
|
|
797
|
+
const parentIssue = data.issue;
|
|
798
|
+
const subIssueIds = new Set(parentIssue.children.nodes.map((c) => c.id));
|
|
799
|
+
const graphNodes = parentIssue.children.nodes.map((child) => {
|
|
800
|
+
const blockedBy = [];
|
|
801
|
+
const blocks = [];
|
|
802
|
+
// Inverse relations: other issues blocking this one
|
|
803
|
+
for (const rel of child.inverseRelations.nodes) {
|
|
804
|
+
if (rel.type === 'blocks' && rel.issue && subIssueIds.has(rel.issue.id)) {
|
|
805
|
+
blockedBy.push(rel.issue.identifier);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// Outgoing relations: this issue blocking others
|
|
809
|
+
for (const rel of child.relations.nodes) {
|
|
810
|
+
if (rel.type === 'blocks' && rel.relatedIssue && subIssueIds.has(rel.relatedIssue.id)) {
|
|
811
|
+
blocks.push(rel.relatedIssue.identifier);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
issue: {
|
|
816
|
+
id: child.id,
|
|
817
|
+
identifier: child.identifier,
|
|
818
|
+
title: child.title,
|
|
819
|
+
description: child.description ?? undefined,
|
|
820
|
+
status: child.state?.name,
|
|
821
|
+
priority: child.priority,
|
|
822
|
+
labels: child.labels.nodes.map((l) => l.name),
|
|
823
|
+
url: child.url,
|
|
824
|
+
},
|
|
825
|
+
blockedBy,
|
|
826
|
+
blocks,
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
return {
|
|
830
|
+
parentId: parentIssue.id,
|
|
831
|
+
parentIdentifier: parentIssue.identifier,
|
|
832
|
+
subIssues: graphNodes,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
if (this.circuitBreaker.isAuthError(error)) {
|
|
837
|
+
const statusCode = extractAuthStatusCode(error);
|
|
838
|
+
await this.circuitBreaker.recordAuthFailure(statusCode);
|
|
839
|
+
}
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Create a configured LinearAgentClient instance
|
|
846
|
+
*/
|
|
847
|
+
export function createLinearAgentClient(config) {
|
|
848
|
+
return new LinearAgentClient(config);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Extract quota information from Linear API response headers.
|
|
852
|
+
*/
|
|
853
|
+
function extractQuotaFromHeaders(headers) {
|
|
854
|
+
if (!headers)
|
|
855
|
+
return undefined;
|
|
856
|
+
const get = (key) => {
|
|
857
|
+
if (headers instanceof Map)
|
|
858
|
+
return headers.get(key) ?? undefined;
|
|
859
|
+
if (typeof headers.get === 'function')
|
|
860
|
+
return headers.get(key);
|
|
861
|
+
return undefined;
|
|
862
|
+
};
|
|
863
|
+
const requestsRemaining = get('x-ratelimit-requests-remaining');
|
|
864
|
+
const requestsLimit = get('x-ratelimit-requests-limit');
|
|
865
|
+
const complexityRemaining = get('x-ratelimit-complexity-remaining');
|
|
866
|
+
const complexityLimit = get('x-ratelimit-complexity-limit');
|
|
867
|
+
const resetSeconds = get('x-ratelimit-requests-reset');
|
|
868
|
+
// Only return if we got at least one header
|
|
869
|
+
if (!requestsRemaining && !complexityRemaining)
|
|
870
|
+
return undefined;
|
|
871
|
+
return {
|
|
872
|
+
requestsRemaining: requestsRemaining ? parseInt(requestsRemaining, 10) : undefined,
|
|
873
|
+
requestsLimit: requestsLimit ? parseInt(requestsLimit, 10) : undefined,
|
|
874
|
+
complexityRemaining: complexityRemaining ? parseInt(complexityRemaining, 10) : undefined,
|
|
875
|
+
complexityLimit: complexityLimit ? parseInt(complexityLimit, 10) : undefined,
|
|
876
|
+
resetSeconds: resetSeconds ? parseInt(resetSeconds, 10) : undefined,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
// Helpers
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
/**
|
|
883
|
+
* Extract HTTP status code from an error for circuit breaker recording.
|
|
884
|
+
*/
|
|
885
|
+
function extractAuthStatusCode(error) {
|
|
886
|
+
if (typeof error !== 'object' || error === null)
|
|
887
|
+
return 0;
|
|
888
|
+
const err = error;
|
|
889
|
+
if (typeof err.status === 'number')
|
|
890
|
+
return err.status;
|
|
891
|
+
if (typeof err.statusCode === 'number')
|
|
892
|
+
return err.statusCode;
|
|
893
|
+
const response = err.response;
|
|
894
|
+
if (response) {
|
|
895
|
+
if (typeof response.status === 'number')
|
|
896
|
+
return response.status;
|
|
897
|
+
if (typeof response.statusCode === 'number')
|
|
898
|
+
return response.statusCode;
|
|
899
|
+
}
|
|
900
|
+
// Default to 400 for auth errors detected by message pattern
|
|
901
|
+
return 400;
|
|
902
|
+
}
|