@fink-andreas/pi-linear-tools 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0 (2026-03-22)
4
+
5
+ Rate limit optimization and crash prevention release.
6
+
7
+ ### Bug Fixes
8
+ - Fixed rate limit crashes by eliminating N+1 API queries (root cause)
9
+ - Added global rate limit tracking to prevent repeated API calls
10
+ - Added comprehensive error handling to prevent extension crashes
11
+
12
+ ### Performance
13
+ - Replaced SDK lazy-loading with optimized GraphQL queries
14
+ - API requests per issue listing: ~251 → 1
15
+ - Listings before hitting rate limit: ~50 → ~5000
16
+
17
+ ### Improvements
18
+ - Reduced default pagination limit (50 → 20) to reduce API load
19
+ - Better user-friendly error messages for rate limit errors
20
+ - Rate limit pre-check before making API calls
21
+
3
22
  ## v0.2.0 (2026-02-28)
4
23
 
5
24
  OAuth 2.0 authentication and Markdown rendering release.
@@ -1,5 +1,5 @@
1
1
  import { loadSettings, saveSettings } from '../src/settings.js';
2
- import { createLinearClient } from '../src/linear-client.js';
2
+ import { createLinearClient, isGloballyRateLimited, markRateLimited } from '../src/linear-client.js';
3
3
  import { setQuietMode } from '../src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
@@ -502,7 +502,10 @@ function renderMarkdownResult(result, _options, _theme) {
502
502
  }
503
503
 
504
504
  async function registerLinearTools(pi) {
505
- if (typeof pi.registerTool !== 'function') return;
505
+ if (typeof pi.registerTool !== 'function') {
506
+ console.warn('[pi-linear-tools] pi.registerTool not available');
507
+ return;
508
+ }
506
509
 
507
510
  pi.registerTool({
508
511
  name: 'linear_issue',
@@ -628,29 +631,90 @@ async function registerLinearTools(pi) {
628
631
  },
629
632
  renderResult: renderMarkdownResult,
630
633
  async execute(_toolCallId, params) {
631
- const client = await createAuthenticatedClient();
632
-
633
- switch (params.action) {
634
- case 'list':
635
- return executeIssueList(client, params);
636
- case 'view':
637
- return executeIssueView(client, params);
638
- case 'create':
639
- return executeIssueCreate(client, params, { resolveDefaultTeam });
640
- case 'update':
641
- return executeIssueUpdate(client, params);
642
- case 'comment':
643
- return executeIssueComment(client, params);
644
- case 'start':
645
- return executeIssueStart(client, params, {
646
- gitExecutor: async (branchName, fromRef, onBranchExists) => {
647
- return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
648
- },
649
- });
650
- case 'delete':
651
- return executeIssueDelete(client, params);
652
- default:
653
- throw new Error(`Unknown action: ${params.action}`);
634
+ // Pre-check: skip API calls if we know we're rate limited
635
+ const { isRateLimited, resetAt } = isGloballyRateLimited();
636
+ if (isRateLimited) {
637
+ throw new Error(
638
+ `Linear API rate limit exceeded (cached).\n\n` +
639
+ `The rate limit resets at: ${resetAt.toLocaleTimeString()}\n\n` +
640
+ `Please wait before making more requests.`
641
+ );
642
+ }
643
+
644
+ try {
645
+ const client = await createAuthenticatedClient();
646
+
647
+ switch (params.action) {
648
+ case 'list':
649
+ return await executeIssueList(client, params);
650
+ case 'view':
651
+ return await executeIssueView(client, params);
652
+ case 'create':
653
+ return await executeIssueCreate(client, params, { resolveDefaultTeam });
654
+ case 'update':
655
+ return await executeIssueUpdate(client, params);
656
+ case 'comment':
657
+ return await executeIssueComment(client, params);
658
+ case 'start':
659
+ return await executeIssueStart(client, params, {
660
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
661
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
662
+ },
663
+ });
664
+ case 'delete':
665
+ return await executeIssueDelete(client, params);
666
+ default:
667
+ throw new Error(`Unknown action: ${params.action}`);
668
+ }
669
+ } catch (error) {
670
+ // Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
671
+ const errorType = error?.type || '';
672
+ const errorMessage = String(error?.message || error || 'Unknown error');
673
+
674
+ // Rate limit error - provide clear reset time and mark globally
675
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
676
+ const resetTimestamp = error?.requestsResetAt || (Date.now() + 3600000);
677
+ const resetTime = new Date(resetTimestamp).toLocaleTimeString();
678
+ markRateLimited(resetTimestamp);
679
+ throw new Error(
680
+ `Linear API rate limit exceeded.\n\n` +
681
+ `The rate limit resets at: ${resetTime}\n\n` +
682
+ `Please wait before making more requests, or reduce the frequency of API calls.`
683
+ );
684
+ }
685
+
686
+ // Authentication/Forbidden errors (handles SDK's ForbiddenLinearError)
687
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
688
+ errorMessage.toLowerCase().includes('forbidden') || errorMessage.toLowerCase().includes('unauthorized')) {
689
+ throw new Error(
690
+ `Linear API authentication failed: ${errorMessage}\n\n` +
691
+ `Please check your API key or OAuth token permissions.`
692
+ );
693
+ }
694
+
695
+ // Network errors (handles SDK's NetworkError)
696
+ if (errorType === 'NetworkError' || errorMessage.toLowerCase().includes('network')) {
697
+ throw new Error(
698
+ `Network error communicating with Linear API.\n\n` +
699
+ `Please check your internet connection and try again.`
700
+ );
701
+ }
702
+
703
+ // Internal server errors (handles SDK's InternalError)
704
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
705
+ throw new Error(
706
+ `Linear API server error (${error?.status || 'unknown'}).\n\n` +
707
+ `Linear may be experiencing issues. Please try again later.`
708
+ );
709
+ }
710
+
711
+ // Re-throw if already formatted with "Linear API error:"
712
+ if (errorMessage.includes('Linear API error:')) {
713
+ throw error;
714
+ }
715
+
716
+ // Wrap unexpected errors with context - NEVER let raw errors propagate
717
+ throw new Error(`Linear issue operation failed: ${errorMessage}`);
654
718
  }
655
719
  },
656
720
  });
@@ -673,13 +737,32 @@ async function registerLinearTools(pi) {
673
737
  },
674
738
  renderResult: renderMarkdownResult,
675
739
  async execute(_toolCallId, params) {
676
- const client = await createAuthenticatedClient();
740
+ try {
741
+ const client = await createAuthenticatedClient();
677
742
 
678
- switch (params.action) {
679
- case 'list':
680
- return executeProjectList(client);
681
- default:
682
- throw new Error(`Unknown action: ${params.action}`);
743
+ switch (params.action) {
744
+ case 'list':
745
+ return await executeProjectList(client);
746
+ default:
747
+ throw new Error(`Unknown action: ${params.action}`);
748
+ }
749
+ } catch (error) {
750
+ // Comprehensive error handling - catch ALL errors
751
+ const errorType = error?.type || '';
752
+ const errorMessage = String(error?.message || error || 'Unknown error');
753
+
754
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
755
+ const resetAt = error?.requestsResetAt
756
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
757
+ : 'approximately 1 hour from now';
758
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
759
+ }
760
+
761
+ if (errorMessage.includes('Linear API error:')) {
762
+ throw error;
763
+ }
764
+
765
+ throw new Error(`Linear project operation failed: ${errorMessage}`);
683
766
  }
684
767
  },
685
768
  });
@@ -702,13 +785,32 @@ async function registerLinearTools(pi) {
702
785
  },
703
786
  renderResult: renderMarkdownResult,
704
787
  async execute(_toolCallId, params) {
705
- const client = await createAuthenticatedClient();
788
+ try {
789
+ const client = await createAuthenticatedClient();
790
+
791
+ switch (params.action) {
792
+ case 'list':
793
+ return await executeTeamList(client);
794
+ default:
795
+ throw new Error(`Unknown action: ${params.action}`);
796
+ }
797
+ } catch (error) {
798
+ // Comprehensive error handling - catch ALL errors
799
+ const errorType = error?.type || '';
800
+ const errorMessage = String(error?.message || error || 'Unknown error');
801
+
802
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
803
+ const resetAt = error?.requestsResetAt
804
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
805
+ : 'approximately 1 hour from now';
806
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
807
+ }
808
+
809
+ if (errorMessage.includes('Linear API error:')) {
810
+ throw error;
811
+ }
706
812
 
707
- switch (params.action) {
708
- case 'list':
709
- return executeTeamList(client);
710
- default:
711
- throw new Error(`Unknown action: ${params.action}`);
813
+ throw new Error(`Linear team operation failed: ${errorMessage}`);
712
814
  }
713
815
  },
714
816
  });
@@ -752,9 +854,9 @@ async function registerLinearTools(pi) {
752
854
  },
753
855
  renderResult: renderMarkdownResult,
754
856
  async execute(_toolCallId, params) {
755
- const client = await createAuthenticatedClient();
756
-
757
857
  try {
858
+ const client = await createAuthenticatedClient();
859
+
758
860
  switch (params.action) {
759
861
  case 'list':
760
862
  return await executeMilestoneList(client, params);
@@ -770,7 +872,24 @@ async function registerLinearTools(pi) {
770
872
  throw new Error(`Unknown action: ${params.action}`);
771
873
  }
772
874
  } catch (error) {
773
- throw withMilestoneScopeHint(error);
875
+ // Apply milestone-specific hint, then wrap with operation context
876
+ const hintError = withMilestoneScopeHint(error);
877
+ // Comprehensive error handling - catch ALL errors
878
+ const errorType = error?.type || '';
879
+ const errorMessage = String(hintError?.message || hintError || 'Unknown error');
880
+
881
+ if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
882
+ const resetAt = error?.requestsResetAt
883
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
884
+ : 'approximately 1 hour from now';
885
+ throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
886
+ }
887
+
888
+ if (errorMessage.includes('Linear API error:')) {
889
+ throw hintError;
890
+ }
891
+
892
+ throw new Error(`Linear milestone operation failed: ${errorMessage}`);
774
893
  }
775
894
  },
776
895
  });
@@ -778,6 +897,8 @@ async function registerLinearTools(pi) {
778
897
  }
779
898
 
780
899
  export default async function piLinearToolsExtension(pi) {
900
+ // Safety wrapper: never let extension errors crash pi
901
+ try {
781
902
  pi.registerCommand('linear-tools-config', {
782
903
  description: 'Configure pi-linear-tools settings (API key and default team mappings)',
783
904
  handler: async (argsText, ctx) => {
@@ -915,4 +1036,8 @@ export default async function piLinearToolsExtension(pi) {
915
1036
  });
916
1037
 
917
1038
  await registerLinearTools(pi);
1039
+ } catch (error) {
1040
+ // Safety: never let extension initialization crash pi
1041
+ console.error('[pi-linear-tools] Extension initialization failed:', error?.message || error);
1042
+ }
918
1043
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
package/src/handlers.js CHANGED
@@ -27,8 +27,9 @@ import {
27
27
  updateProjectMilestone,
28
28
  deleteProjectMilestone,
29
29
  deleteIssue,
30
+ withHandlerErrorHandling,
30
31
  } from './linear.js';
31
- import { debug } from './logger.js';
32
+ import { debug, warn } from './logger.js';
32
33
 
33
34
  function toTextResult(text, details = {}) {
34
35
  return {
@@ -128,57 +129,59 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
128
129
  * List issues in a project
129
130
  */
130
131
  export async function executeIssueList(client, params) {
131
- let projectRef = params.project;
132
- if (!projectRef) {
133
- projectRef = process.cwd().split('/').pop();
134
- }
132
+ return withHandlerErrorHandling(async () => {
133
+ let projectRef = params.project;
134
+ if (!projectRef) {
135
+ projectRef = process.cwd().split('/').pop();
136
+ }
135
137
 
136
- const resolved = await resolveProjectRef(client, projectRef);
138
+ const resolved = await resolveProjectRef(client, projectRef);
137
139
 
138
- let assigneeId = null;
139
- if (params.assignee === 'me') {
140
- const viewer = await client.viewer;
141
- assigneeId = viewer.id;
142
- }
143
-
144
- const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
145
- assigneeId,
146
- limit: params.limit || 50,
147
- });
140
+ let assigneeId = null;
141
+ if (params.assignee === 'me') {
142
+ const viewer = await client.viewer;
143
+ assigneeId = viewer.id;
144
+ }
148
145
 
149
- if (issues.length === 0) {
150
- return toTextResult(`No issues found in project "${resolved.name}"`, {
151
- projectId: resolved.id,
152
- projectName: resolved.name,
153
- issueCount: 0,
146
+ const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
147
+ assigneeId,
148
+ limit: params.limit || 20,
154
149
  });
155
- }
156
150
 
157
- const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
151
+ if (issues.length === 0) {
152
+ return toTextResult(`No issues found in project "${resolved.name}"`, {
153
+ projectId: resolved.id,
154
+ projectName: resolved.name,
155
+ issueCount: 0,
156
+ });
157
+ }
158
158
 
159
- for (const issue of issues) {
160
- const stateLabel = issue.state?.name || 'Unknown';
161
- const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
162
- const priorityLabel = issue.priority !== undefined && issue.priority !== null
163
- ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
164
- : null;
159
+ const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
165
160
 
166
- const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
167
- if (priorityLabel) metaParts.push(priorityLabel);
161
+ for (const issue of issues) {
162
+ const stateLabel = issue.state?.name || 'Unknown';
163
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
164
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
165
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
166
+ : null;
168
167
 
169
- lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
170
- }
168
+ const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
169
+ if (priorityLabel) metaParts.push(priorityLabel);
171
170
 
172
- if (truncated) {
173
- lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
174
- }
171
+ lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
172
+ }
175
173
 
176
- return toTextResult(lines.join('\n'), {
177
- projectId: resolved.id,
178
- projectName: resolved.name,
179
- issueCount: issues.length,
180
- truncated,
181
- });
174
+ if (truncated) {
175
+ lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
176
+ }
177
+
178
+ return toTextResult(lines.join('\n'), {
179
+ projectId: resolved.id,
180
+ projectName: resolved.name,
181
+ issueCount: issues.length,
182
+ truncated,
183
+ });
184
+ }, 'executeIssueList');
182
185
  }
183
186
 
184
187
  /**
@@ -11,8 +11,88 @@ import { debug, warn, error as logError } from './logger.js';
11
11
  /** @type {Function|null} Test-only client factory override */
12
12
  let _testClientFactory = null;
13
13
 
14
+ /** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
15
+ const rateLimitTracker = new Map();
16
+
17
+ /** Track globally if we've detected a rate limit error */
18
+ let globalRateLimited = false;
19
+ let globalRateLimitResetAt = null;
20
+
21
+ /**
22
+ * Check if we know we're rate limited and should skip API calls
23
+ * @returns {{isRateLimited: boolean, resetAt: Date|null}}
24
+ */
25
+ export function isGloballyRateLimited() {
26
+ if (!globalRateLimited || !globalRateLimitResetAt) {
27
+ return { isRateLimited: false, resetAt: null };
28
+ }
29
+
30
+ // Check if rate limit window has passed
31
+ if (Date.now() >= globalRateLimitResetAt) {
32
+ globalRateLimited = false;
33
+ globalRateLimitResetAt = null;
34
+ return { isRateLimited: false, resetAt: null };
35
+ }
36
+
37
+ return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
38
+ }
39
+
14
40
  /**
15
- * Create a Linear SDK client
41
+ * Mark that we've hit the rate limit
42
+ * @param {number} resetAt - Reset timestamp in milliseconds
43
+ */
44
+ export function markRateLimited(resetAt) {
45
+ globalRateLimited = true;
46
+ globalRateLimitResetAt = resetAt;
47
+ warn('[pi-linear-tools] Rate limit hit - will skip API calls until reset', {
48
+ resetAt: new Date(resetAt).toLocaleTimeString(),
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Extract rate limit info from SDK client response
54
+ * The Linear SDK stores response metadata on the client after requests
55
+ * @param {LinearClient} client - Linear SDK client
56
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
57
+ */
58
+ export function getClientRateLimit(client) {
59
+ // Try to get from tracker first
60
+ const trackerData = rateLimitTracker.get(client.apiKey || 'default');
61
+ if (trackerData) {
62
+ return {
63
+ remaining: trackerData.remaining,
64
+ resetAt: trackerData.resetAt,
65
+ resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
66
+ };
67
+ }
68
+
69
+ return { remaining: null, resetAt: null, resetTime: null };
70
+ }
71
+
72
+ /**
73
+ * Check and warn about low rate limit for a client
74
+ * Call this after making API requests to check if limits are getting low
75
+ * @param {LinearClient} client - Linear SDK client
76
+ * @returns {boolean} True if warning was issued
77
+ */
78
+ export function checkAndWarnRateLimit(client) {
79
+ const { remaining, resetTime } = getClientRateLimit(client);
80
+
81
+ if (remaining !== null && remaining <= 500) {
82
+ const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
83
+ warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
84
+ remaining,
85
+ resetTime,
86
+ usagePercent,
87
+ });
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Create a Linear SDK client with rate limit tracking
16
96
  *
17
97
  * Supports two authentication methods:
18
98
  * 1. API Key: Pass as string or { apiKey: '...' }
@@ -30,18 +110,22 @@ export function createLinearClient(auth) {
30
110
  }
31
111
 
32
112
  let clientConfig;
113
+ let apiKey = null;
33
114
 
34
115
  // Handle different input formats
35
116
  if (typeof auth === 'string') {
36
117
  // Legacy: API key passed as string
37
118
  clientConfig = { apiKey: auth };
119
+ apiKey = auth;
38
120
  } else if (typeof auth === 'object' && auth !== null) {
39
121
  // Object format: { apiKey: '...' } or { accessToken: '...' }
40
122
  if (auth.accessToken) {
41
123
  clientConfig = { apiKey: auth.accessToken };
124
+ apiKey = auth.accessToken;
42
125
  debug('Creating Linear client with OAuth access token');
43
126
  } else if (auth.apiKey) {
44
127
  clientConfig = { apiKey: auth.apiKey };
128
+ apiKey = auth.apiKey;
45
129
  debug('Creating Linear client with API key');
46
130
  } else {
47
131
  throw new Error(
@@ -54,7 +138,54 @@ export function createLinearClient(auth) {
54
138
  );
55
139
  }
56
140
 
57
- return new LinearClient(clientConfig);
141
+ const client = new LinearClient(clientConfig);
142
+
143
+ // Initialize rate limit tracking for this client
144
+ const trackerKey = apiKey || 'default';
145
+ if (!rateLimitTracker.has(trackerKey)) {
146
+ rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
147
+ }
148
+
149
+ // Wrap the rawRequest to capture rate limit headers
150
+ // rawRequest is on client.client (internal GraphQL client)
151
+ const originalRawRequest = client.client.rawRequest.bind(client.client);
152
+ client.client.rawRequest = async function wrappedRawRequest(query, variables, requestHeaders) {
153
+ const response = await originalRawRequest(query, variables, requestHeaders);
154
+
155
+ // Extract rate limit headers from response
156
+ if (response.headers) {
157
+ const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
158
+ const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
159
+
160
+ if (remaining !== null) {
161
+ const tracker = rateLimitTracker.get(trackerKey);
162
+ if (tracker) {
163
+ tracker.remaining = parseInt(remaining, 10);
164
+ }
165
+ }
166
+ if (resetAt !== null) {
167
+ const tracker = rateLimitTracker.get(trackerKey);
168
+ if (tracker) {
169
+ tracker.resetAt = parseInt(resetAt, 10);
170
+ }
171
+ }
172
+ }
173
+
174
+ // Check if we should warn about low rate limits
175
+ const tracker = rateLimitTracker.get(trackerKey);
176
+ if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
177
+ const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
178
+ warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
179
+ remaining: tracker.remaining,
180
+ resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
181
+ usagePercent,
182
+ });
183
+ }
184
+
185
+ return response;
186
+ };
187
+
188
+ return client;
58
189
  }
59
190
 
60
191
  /**