@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 +19 -0
- package/extensions/pi-linear-tools.js +165 -40
- package/package.json +1 -1
- package/src/handlers.js +45 -42
- package/src/linear-client.js +133 -2
- package/src/linear.js +1071 -636
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')
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
740
|
+
try {
|
|
741
|
+
const client = await createAuthenticatedClient();
|
|
677
742
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
return withHandlerErrorHandling(async () => {
|
|
133
|
+
let projectRef = params.project;
|
|
134
|
+
if (!projectRef) {
|
|
135
|
+
projectRef = process.cwd().split('/').pop();
|
|
136
|
+
}
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
const resolved = await resolveProjectRef(client, projectRef);
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
168
|
+
const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
|
|
169
|
+
if (priorityLabel) metaParts.push(priorityLabel);
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
171
|
+
lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
|
|
172
|
+
}
|
|
175
173
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
/**
|
package/src/linear-client.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
/**
|