@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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/src/agent-client-project-repo.test.d.ts +2 -0
  4. package/dist/src/agent-client-project-repo.test.d.ts.map +1 -0
  5. package/dist/src/agent-client-project-repo.test.js +153 -0
  6. package/dist/src/agent-client.d.ts +261 -0
  7. package/dist/src/agent-client.d.ts.map +1 -0
  8. package/dist/src/agent-client.js +902 -0
  9. package/dist/src/agent-session.d.ts +303 -0
  10. package/dist/src/agent-session.d.ts.map +1 -0
  11. package/dist/src/agent-session.js +969 -0
  12. package/dist/src/checkbox-utils.d.ts +88 -0
  13. package/dist/src/checkbox-utils.d.ts.map +1 -0
  14. package/dist/src/checkbox-utils.js +120 -0
  15. package/dist/src/circuit-breaker.d.ts +76 -0
  16. package/dist/src/circuit-breaker.d.ts.map +1 -0
  17. package/dist/src/circuit-breaker.js +229 -0
  18. package/dist/src/circuit-breaker.test.d.ts +2 -0
  19. package/dist/src/circuit-breaker.test.d.ts.map +1 -0
  20. package/dist/src/circuit-breaker.test.js +292 -0
  21. package/dist/src/constants.d.ts +87 -0
  22. package/dist/src/constants.d.ts.map +1 -0
  23. package/dist/src/constants.js +101 -0
  24. package/dist/src/defaults/auto-trigger.d.ts +35 -0
  25. package/dist/src/defaults/auto-trigger.d.ts.map +1 -0
  26. package/dist/src/defaults/auto-trigger.js +36 -0
  27. package/dist/src/defaults/index.d.ts +12 -0
  28. package/dist/src/defaults/index.d.ts.map +1 -0
  29. package/dist/src/defaults/index.js +11 -0
  30. package/dist/src/defaults/priority.d.ts +20 -0
  31. package/dist/src/defaults/priority.d.ts.map +1 -0
  32. package/dist/src/defaults/priority.js +37 -0
  33. package/dist/src/defaults/prompts.d.ts +42 -0
  34. package/dist/src/defaults/prompts.d.ts.map +1 -0
  35. package/dist/src/defaults/prompts.js +310 -0
  36. package/dist/src/defaults/prompts.test.d.ts +2 -0
  37. package/dist/src/defaults/prompts.test.d.ts.map +1 -0
  38. package/dist/src/defaults/prompts.test.js +263 -0
  39. package/dist/src/defaults/work-type-detection.d.ts +19 -0
  40. package/dist/src/defaults/work-type-detection.d.ts.map +1 -0
  41. package/dist/src/defaults/work-type-detection.js +93 -0
  42. package/dist/src/errors.d.ts +91 -0
  43. package/dist/src/errors.d.ts.map +1 -0
  44. package/dist/src/errors.js +173 -0
  45. package/dist/src/frontend-adapter.d.ts +168 -0
  46. package/dist/src/frontend-adapter.d.ts.map +1 -0
  47. package/dist/src/frontend-adapter.js +314 -0
  48. package/dist/src/frontend-adapter.test.d.ts +2 -0
  49. package/dist/src/frontend-adapter.test.d.ts.map +1 -0
  50. package/dist/src/frontend-adapter.test.js +545 -0
  51. package/dist/src/index.d.ts +28 -0
  52. package/dist/src/index.d.ts.map +1 -0
  53. package/dist/src/index.js +30 -0
  54. package/dist/src/issue-tracker-proxy.d.ts +140 -0
  55. package/dist/src/issue-tracker-proxy.d.ts.map +1 -0
  56. package/dist/src/issue-tracker-proxy.js +10 -0
  57. package/dist/src/platform-adapter.d.ts +132 -0
  58. package/dist/src/platform-adapter.d.ts.map +1 -0
  59. package/dist/src/platform-adapter.js +260 -0
  60. package/dist/src/platform-adapter.test.d.ts +2 -0
  61. package/dist/src/platform-adapter.test.d.ts.map +1 -0
  62. package/dist/src/platform-adapter.test.js +468 -0
  63. package/dist/src/proxy-client.d.ts +103 -0
  64. package/dist/src/proxy-client.d.ts.map +1 -0
  65. package/dist/src/proxy-client.js +191 -0
  66. package/dist/src/rate-limiter.d.ts +64 -0
  67. package/dist/src/rate-limiter.d.ts.map +1 -0
  68. package/dist/src/rate-limiter.js +163 -0
  69. package/dist/src/rate-limiter.test.d.ts +2 -0
  70. package/dist/src/rate-limiter.test.d.ts.map +1 -0
  71. package/dist/src/rate-limiter.test.js +217 -0
  72. package/dist/src/retry.d.ts +59 -0
  73. package/dist/src/retry.d.ts.map +1 -0
  74. package/dist/src/retry.js +82 -0
  75. package/dist/src/types.d.ts +492 -0
  76. package/dist/src/types.d.ts.map +1 -0
  77. package/dist/src/types.js +143 -0
  78. package/dist/src/utils.d.ts +52 -0
  79. package/dist/src/utils.d.ts.map +1 -0
  80. package/dist/src/utils.js +277 -0
  81. package/dist/src/webhook-types.d.ts +308 -0
  82. package/dist/src/webhook-types.d.ts.map +1 -0
  83. package/dist/src/webhook-types.js +46 -0
  84. 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
+ }