@fink-andreas/pi-linear-tools 0.5.0 → 0.6.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 +22 -0
- package/README.md +24 -14
- package/extensions/pi-linear-tools.js +159 -70
- package/package.json +1 -1
- package/src/linear-client.js +51 -0
- package/src/linear.js +123 -5
- package/src/settings.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.6.0 (2026-04-23)
|
|
4
|
+
|
|
5
|
+
Rate-limit resilience for milestone operations.
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
- **Fixed linear_milestone view rate-limit crash**: `linear_milestone view` no longer crashes when hitting Linear API rate limits. Errors are now properly surfaced as safe tool results instead of propagating as crashes.
|
|
9
|
+
- **Fixed milestone issues rate-limit swallowing**: `fetchMilestoneDetails()` now correctly propagates rate-limit errors from lazy-loaded issue data instead of silently returning partial results.
|
|
10
|
+
|
|
11
|
+
### Performance / API Usage
|
|
12
|
+
- **Replaced N+1 lazy loading with single GraphQL query**: Milestone details now fetch all issue state/assignee in ONE request instead of N+1 per-issue lazy loads. This dramatically reduces API calls and rate-limit exposure for large milestones.
|
|
13
|
+
- **Added cached rate-limit pre-check**: `linear_milestone` now has the same cached pre-check that `linear_issue` uses to short-circuit before making API calls when the cache already knows about a rate limit.
|
|
14
|
+
- **Exported `isRateLimitError()` helper**: Made available for consistent error handling across tools.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
- **Reorganized README**: Moved Key Concepts section for better onboarding
|
|
18
|
+
- **Added combined Linear+Pi logo**: Light/dark theme support for branding
|
|
19
|
+
|
|
20
|
+
## v0.5.1 (2026-04-17)
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
- **Prevent pi crashes from Linear tool errors**: Added `executeToolSafely` wrapper that catches errors and returns them as safe tool results instead of throwing, preventing pi from crashing on Linear API errors
|
|
24
|
+
|
|
3
25
|
## v0.5.0 (2026-04-17)
|
|
4
26
|
|
|
5
27
|
Linear document sync, project lifecycle management, and estimate/story points support.
|
package/README.md
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
# pi-linear-tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/linear-pi-combined-white.svg">
|
|
6
|
+
<source media="(prefers-color-scheme: light)" srcset="docs/images/linear-pi-combined-black.svg">
|
|
7
|
+
<img src="docs/images/linear-pi-combined-black.svg" alt="Linear + Pi" width="340">
|
|
8
|
+
</picture>
|
|
9
|
+
</p>
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
- `issue update` changes issue fields; `issue comment` adds discussion; `issue activity` reads the Activity timeline
|
|
7
|
-
- `project update` changes project fields; `project-update` manages Updates tab entries
|
|
8
|
-
- `project-update` maps to Linear project updates in the Updates tab
|
|
9
|
-
- `sync-doc init` scaffolds `.linear-tools/config.json` in the folder you point at
|
|
10
|
-
- `sync-doc run` and `sync-doc check` default to all configured targets in `.linear-tools/config.json`
|
|
11
|
-
- `sync-doc --target X` narrows the operation to one configured target
|
|
12
|
-
|
|
13
|
-
Reference conventions:
|
|
14
|
-
- issues use issue key or issue ID
|
|
15
|
-
- projects use project name or project ID
|
|
16
|
-
- project updates use project update ID
|
|
17
|
-
- milestones use milestone ID
|
|
11
|
+
`pi-linear-tools` is a token eficcient Pi extension for the [Pi coding agent](https://github.com/badlogic/pi-mono) that lets you manage [Linear](https://linear.app/about) issues, projects, and milestones via LLM tool calls and CLI commands.
|
|
18
12
|
|
|
19
13
|
## Install
|
|
20
14
|
|
|
@@ -54,6 +48,22 @@ Optional non-interactive commands:
|
|
|
54
48
|
- `/linear-tools-config`
|
|
55
49
|
- `/linear-tools-help`
|
|
56
50
|
|
|
51
|
+
## Key Concepts
|
|
52
|
+
|
|
53
|
+
Useful mental model:
|
|
54
|
+
- `issue update` changes issue fields; `issue comment` adds discussion; `issue activity` reads the Activity timeline
|
|
55
|
+
- `project update` changes project fields; `project-update` manages Updates tab entries
|
|
56
|
+
- `project-update` maps to Linear project updates in the Updates tab
|
|
57
|
+
- `sync-doc init` scaffolds `.linear-tools/config.json` in the folder you point at
|
|
58
|
+
- `sync-doc run` and `sync-doc check` default to all configured targets in `.linear-tools/config.json`
|
|
59
|
+
- `sync-doc --target X` narrows the operation to one configured target
|
|
60
|
+
|
|
61
|
+
Reference conventions:
|
|
62
|
+
- issues use issue key or issue ID
|
|
63
|
+
- projects use project name or project ID
|
|
64
|
+
- project updates use project update ID
|
|
65
|
+
- milestones use milestone ID
|
|
66
|
+
|
|
57
67
|
## LLM-callable tools
|
|
58
68
|
|
|
59
69
|
### `linear_issue`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadSettings, saveSettings } from '../src/settings.js';
|
|
2
|
-
import { createLinearClient, checkAndClearRateLimit, markRateLimited, getClientRequestMetrics } from '../src/linear-client.js';
|
|
2
|
+
import { createLinearClient, checkAndClearRateLimit, markRateLimited, getClientRequestMetrics, getClientRateLimitInfo } from '../src/linear-client.js';
|
|
3
3
|
import { setQuietMode, debug } from '../src/logger.js';
|
|
4
4
|
import {
|
|
5
5
|
resolveProjectRef,
|
|
@@ -120,12 +120,13 @@ async function createAuthenticatedClient() {
|
|
|
120
120
|
return createLinearClient(await getLinearAuth());
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
async function withRequestUsageLogging(client, toolName, action, operation) {
|
|
123
|
+
async function withRequestUsageLogging(client, toolName, action, operation, rateLimitDebug = false) {
|
|
124
124
|
const before = getClientRequestMetrics(client);
|
|
125
125
|
|
|
126
126
|
try {
|
|
127
127
|
const result = await operation();
|
|
128
128
|
const after = getClientRequestMetrics(client);
|
|
129
|
+
const rateLimitInfo = rateLimitDebug ? getClientRateLimitInfo(client) : null;
|
|
129
130
|
|
|
130
131
|
const usageDelta = {
|
|
131
132
|
tool: toolName,
|
|
@@ -139,17 +140,54 @@ async function withRequestUsageLogging(client, toolName, action, operation) {
|
|
|
139
140
|
|
|
140
141
|
debug('[pi-linear-tools] API usage per command', usageDelta);
|
|
141
142
|
|
|
142
|
-
if (
|
|
143
|
+
if (rateLimitDebug && rateLimitInfo) {
|
|
144
|
+
debug('[pi-linear-tools] Rate limit status', {
|
|
145
|
+
tool: toolName,
|
|
146
|
+
action,
|
|
147
|
+
rateLimitUsed: rateLimitInfo.used,
|
|
148
|
+
rateLimitRemaining: rateLimitInfo.remaining,
|
|
149
|
+
rateLimitPercent: rateLimitInfo.usagePercent,
|
|
150
|
+
rateLimitResetAt: rateLimitInfo.resetTime,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// When rateLimitDebug is true, always include rate limit info in result details
|
|
155
|
+
// even if INCLUDE_USAGE_SUMMARY is disabled
|
|
156
|
+
if (!result || typeof result !== 'object') {
|
|
143
157
|
return result;
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
const details = (result.details && typeof result.details === 'object') ? result.details : {};
|
|
161
|
+
|
|
162
|
+
// Add rate limit info to details if debug is enabled
|
|
163
|
+
if (rateLimitDebug && rateLimitInfo) {
|
|
164
|
+
details.rateLimit = rateLimitInfo;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!INCLUDE_USAGE_SUMMARY && !rateLimitDebug) {
|
|
168
|
+
return {
|
|
169
|
+
...result,
|
|
170
|
+
details,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
147
174
|
const content = Array.isArray(result.content)
|
|
148
175
|
? result.content.map((item, idx) => {
|
|
149
176
|
if (idx !== 0 || item?.type !== 'text' || typeof item.text !== 'string') return item;
|
|
177
|
+
let appendedText = item.text;
|
|
178
|
+
|
|
179
|
+
if (INCLUDE_USAGE_SUMMARY) {
|
|
180
|
+
appendedText += `\n\n_${usageDelta.summary}_`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (rateLimitDebug && rateLimitInfo) {
|
|
184
|
+
const percent = rateLimitInfo.usagePercent;
|
|
185
|
+
appendedText += `\n\n---\n**Rate Limit**: ${rateLimitInfo.used}/${rateLimitInfo.total} used (${percent}%) • Resets at ${rateLimitInfo.resetTime}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
return {
|
|
151
189
|
...item,
|
|
152
|
-
text:
|
|
190
|
+
text: appendedText,
|
|
153
191
|
};
|
|
154
192
|
})
|
|
155
193
|
: result.content;
|
|
@@ -159,11 +197,12 @@ async function withRequestUsageLogging(client, toolName, action, operation) {
|
|
|
159
197
|
content,
|
|
160
198
|
details: {
|
|
161
199
|
...details,
|
|
162
|
-
apiUsage: usageDelta,
|
|
200
|
+
...(INCLUDE_USAGE_SUMMARY ? { apiUsage: usageDelta } : {}),
|
|
163
201
|
},
|
|
164
202
|
};
|
|
165
203
|
} catch (error) {
|
|
166
204
|
const after = getClientRequestMetrics(client);
|
|
205
|
+
const rateLimitInfo = rateLimitDebug ? getClientRateLimitInfo(client) : null;
|
|
167
206
|
|
|
168
207
|
debug('[pi-linear-tools] API usage per command (error)', {
|
|
169
208
|
tool: toolName,
|
|
@@ -173,6 +212,11 @@ async function withRequestUsageLogging(client, toolName, action, operation) {
|
|
|
173
212
|
failedDelta: after.failed - before.failed,
|
|
174
213
|
rateLimitedDelta: after.rateLimited - before.rateLimited,
|
|
175
214
|
error: String(error?.message || error || 'unknown'),
|
|
215
|
+
...(rateLimitDebug && rateLimitInfo ? {
|
|
216
|
+
rateLimitUsed: rateLimitInfo.used,
|
|
217
|
+
rateLimitRemaining: rateLimitInfo.remaining,
|
|
218
|
+
rateLimitPercent: rateLimitInfo.usagePercent,
|
|
219
|
+
} : {}),
|
|
176
220
|
});
|
|
177
221
|
|
|
178
222
|
throw error;
|
|
@@ -460,21 +504,35 @@ function toToolTextResult(text, details = {}) {
|
|
|
460
504
|
}
|
|
461
505
|
|
|
462
506
|
function buildRateLimitToolResult(error, options = {}) {
|
|
463
|
-
const { cached = false } = options;
|
|
507
|
+
const { cached = false, viaCachedPreCheck = false } = options;
|
|
464
508
|
const resetTimestamp = error?.requestsResetAt || Date.now() + 3600000;
|
|
465
509
|
const resetTime = new Date(resetTimestamp).toLocaleTimeString();
|
|
466
510
|
|
|
467
511
|
markRateLimited(resetTimestamp);
|
|
468
512
|
|
|
469
513
|
return toToolTextResult(
|
|
470
|
-
|
|
471
|
-
? `Linear API rate limit exceeded (cached
|
|
472
|
-
|
|
514
|
+
viaCachedPreCheck
|
|
515
|
+
? `Linear API rate limit exceeded (cached — this request was not sent to Linear).
|
|
516
|
+
|
|
517
|
+
The rate limit resets at: ${resetTime}
|
|
518
|
+
|
|
519
|
+
Please wait before making more requests.`
|
|
520
|
+
: cached
|
|
521
|
+
? `Linear API rate limit exceeded (cached).
|
|
522
|
+
|
|
523
|
+
The rate limit resets at: ${resetTime}
|
|
524
|
+
|
|
525
|
+
Please wait before making more requests.`
|
|
526
|
+
: `Linear API rate limit exceeded.
|
|
527
|
+
|
|
528
|
+
The rate limit resets at: ${resetTime}
|
|
529
|
+
|
|
530
|
+
Please wait before making more requests, or reduce the frequency of API calls.`,
|
|
473
531
|
{
|
|
474
532
|
error: true,
|
|
475
533
|
errorType: 'Ratelimited',
|
|
476
534
|
rateLimited: true,
|
|
477
|
-
cached,
|
|
535
|
+
cached: viaCachedPreCheck || cached,
|
|
478
536
|
requestsResetAt: resetTimestamp,
|
|
479
537
|
resetTime,
|
|
480
538
|
}
|
|
@@ -487,7 +545,7 @@ function handleToolExecutionError(error, operationLabel, options = {}) {
|
|
|
487
545
|
const errorMessage = String(transformedError?.message || transformedError || 'Unknown error');
|
|
488
546
|
|
|
489
547
|
if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
|
|
490
|
-
return buildRateLimitToolResult(
|
|
548
|
+
return buildRateLimitToolResult(transformedError, { viaCachedPreCheck: options.viaCachedPreCheck });
|
|
491
549
|
}
|
|
492
550
|
|
|
493
551
|
if (errorMessage.includes('Linear API error:')) {
|
|
@@ -497,6 +555,40 @@ function handleToolExecutionError(error, operationLabel, options = {}) {
|
|
|
497
555
|
throw new Error(`${operationLabel}: ${errorMessage}`);
|
|
498
556
|
}
|
|
499
557
|
|
|
558
|
+
function buildGenericToolErrorResult(error, operationLabel) {
|
|
559
|
+
const errorType = error?.type || error?.name || 'Error';
|
|
560
|
+
const errorMessage = String(error?.message || error || 'Unknown error');
|
|
561
|
+
|
|
562
|
+
return toToolTextResult(`${operationLabel}: ${errorMessage}`, {
|
|
563
|
+
error: true,
|
|
564
|
+
errorType,
|
|
565
|
+
rateLimited: false,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function executeToolSafely(operationLabel, operation, options = {}) {
|
|
570
|
+
try {
|
|
571
|
+
return await operation();
|
|
572
|
+
} catch (error) {
|
|
573
|
+
debug('[pi-linear-tools] tool execution failed', {
|
|
574
|
+
operationLabel,
|
|
575
|
+
errorType: error?.type || error?.name || null,
|
|
576
|
+
error: String(error?.message || error || 'unknown'),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
return handleToolExecutionError(error, operationLabel, options);
|
|
581
|
+
} catch (handledError) {
|
|
582
|
+
debug('[pi-linear-tools] returning generic safe tool error result', {
|
|
583
|
+
operationLabel,
|
|
584
|
+
errorType: handledError?.type || handledError?.name || null,
|
|
585
|
+
error: String(handledError?.message || handledError || 'unknown'),
|
|
586
|
+
});
|
|
587
|
+
return buildGenericToolErrorResult(handledError, operationLabel);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
500
592
|
function renderMarkdownResult(result, _options, _theme) {
|
|
501
593
|
const text = result.content?.[0]?.text || '';
|
|
502
594
|
|
|
@@ -666,13 +758,15 @@ async function registerLinearTools(pi) {
|
|
|
666
758
|
},
|
|
667
759
|
renderResult: renderMarkdownResult,
|
|
668
760
|
async execute(_toolCallId, params) {
|
|
669
|
-
|
|
761
|
+
return executeToolSafely('Linear issue operation failed', async () => {
|
|
670
762
|
// Pre-check: skip API calls if we know we're rate limited
|
|
671
763
|
const { isRateLimited, resetAt } = checkAndClearRateLimit();
|
|
672
764
|
if (isRateLimited) {
|
|
673
765
|
return buildRateLimitToolResult({ requestsResetAt: resetAt.getTime(), type: 'Ratelimited' }, { cached: true });
|
|
674
766
|
}
|
|
675
767
|
|
|
768
|
+
const settings = await loadSettings();
|
|
769
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
676
770
|
const client = await createAuthenticatedClient();
|
|
677
771
|
|
|
678
772
|
return await withRequestUsageLogging(client, 'linear_issue', params.action, async () => {
|
|
@@ -700,39 +794,8 @@ async function registerLinearTools(pi) {
|
|
|
700
794
|
default:
|
|
701
795
|
throw new Error(`Unknown action: ${params.action}`);
|
|
702
796
|
}
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
// Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
|
|
706
|
-
const errorType = error?.type || '';
|
|
707
|
-
const errorMessage = String(error?.message || error || 'Unknown error');
|
|
708
|
-
|
|
709
|
-
// Authentication/Forbidden errors (handles SDK's ForbiddenLinearError)
|
|
710
|
-
if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
|
|
711
|
-
errorMessage.toLowerCase().includes('forbidden') || errorMessage.toLowerCase().includes('unauthorized')) {
|
|
712
|
-
throw new Error(
|
|
713
|
-
`Linear API authentication failed: ${errorMessage}\n\n` +
|
|
714
|
-
`Please check your API key or OAuth token permissions.`
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Network errors (handles SDK's NetworkError)
|
|
719
|
-
if (errorType === 'NetworkError' || errorMessage.toLowerCase().includes('network')) {
|
|
720
|
-
throw new Error(
|
|
721
|
-
`Network error communicating with Linear API.\n\n` +
|
|
722
|
-
`Please check your internet connection and try again.`
|
|
723
|
-
);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Internal server errors (handles SDK's InternalError)
|
|
727
|
-
if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`Linear API server error (${error?.status || 'unknown'}).\n\n` +
|
|
730
|
-
`Linear may be experiencing issues. Please try again later.`
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return handleToolExecutionError(error, 'Linear issue operation failed');
|
|
735
|
-
}
|
|
797
|
+
}, rateLimitDebug);
|
|
798
|
+
});
|
|
736
799
|
},
|
|
737
800
|
});
|
|
738
801
|
|
|
@@ -798,7 +861,9 @@ async function registerLinearTools(pi) {
|
|
|
798
861
|
},
|
|
799
862
|
renderResult: renderMarkdownResult,
|
|
800
863
|
async execute(_toolCallId, params) {
|
|
801
|
-
|
|
864
|
+
return executeToolSafely('Linear project operation failed', async () => {
|
|
865
|
+
const settings = await loadSettings();
|
|
866
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
802
867
|
const client = await createAuthenticatedClient();
|
|
803
868
|
|
|
804
869
|
return await withRequestUsageLogging(client, 'linear_project', params.action, async () => {
|
|
@@ -820,10 +885,8 @@ async function registerLinearTools(pi) {
|
|
|
820
885
|
default:
|
|
821
886
|
throw new Error(`Unknown action: ${params.action}`);
|
|
822
887
|
}
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
return handleToolExecutionError(error, 'Linear project operation failed');
|
|
826
|
-
}
|
|
888
|
+
}, rateLimitDebug);
|
|
889
|
+
});
|
|
827
890
|
},
|
|
828
891
|
});
|
|
829
892
|
|
|
@@ -877,7 +940,9 @@ async function registerLinearTools(pi) {
|
|
|
877
940
|
},
|
|
878
941
|
renderResult: renderMarkdownResult,
|
|
879
942
|
async execute(_toolCallId, params) {
|
|
880
|
-
|
|
943
|
+
return executeToolSafely('Linear project update operation failed', async () => {
|
|
944
|
+
const settings = await loadSettings();
|
|
945
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
881
946
|
const client = await createAuthenticatedClient();
|
|
882
947
|
|
|
883
948
|
return await withRequestUsageLogging(client, 'linear_project_update', params.action, async () => {
|
|
@@ -897,10 +962,8 @@ async function registerLinearTools(pi) {
|
|
|
897
962
|
default:
|
|
898
963
|
throw new Error(`Unknown action: ${params.action}`);
|
|
899
964
|
}
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
return handleToolExecutionError(error, 'Linear project update operation failed');
|
|
903
|
-
}
|
|
965
|
+
}, rateLimitDebug);
|
|
966
|
+
});
|
|
904
967
|
},
|
|
905
968
|
});
|
|
906
969
|
|
|
@@ -923,7 +986,9 @@ async function registerLinearTools(pi) {
|
|
|
923
986
|
},
|
|
924
987
|
renderResult: renderMarkdownResult,
|
|
925
988
|
async execute(_toolCallId, params) {
|
|
926
|
-
|
|
989
|
+
return executeToolSafely('Linear team operation failed', async () => {
|
|
990
|
+
const settings = await loadSettings();
|
|
991
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
927
992
|
const client = await createAuthenticatedClient();
|
|
928
993
|
|
|
929
994
|
return await withRequestUsageLogging(client, 'linear_team', params.action, async () => {
|
|
@@ -933,10 +998,8 @@ async function registerLinearTools(pi) {
|
|
|
933
998
|
default:
|
|
934
999
|
throw new Error(`Unknown action: ${params.action}`);
|
|
935
1000
|
}
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
return handleToolExecutionError(error, 'Linear team operation failed');
|
|
939
|
-
}
|
|
1001
|
+
}, rateLimitDebug);
|
|
1002
|
+
});
|
|
940
1003
|
},
|
|
941
1004
|
});
|
|
942
1005
|
|
|
@@ -980,9 +1043,18 @@ async function registerLinearTools(pi) {
|
|
|
980
1043
|
},
|
|
981
1044
|
renderResult: renderMarkdownResult,
|
|
982
1045
|
async execute(_toolCallId, params) {
|
|
983
|
-
|
|
1046
|
+
return executeToolSafely('Linear milestone operation failed', async () => {
|
|
1047
|
+
// Pre-check: skip API calls if we know we're rate limited
|
|
1048
|
+
const { isRateLimited, resetAt } = checkAndClearRateLimit();
|
|
1049
|
+
if (isRateLimited) {
|
|
1050
|
+
return buildRateLimitToolResult({ requestsResetAt: resetAt.getTime(), type: 'Ratelimited' }, { viaCachedPreCheck: true });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const settings = await loadSettings();
|
|
1054
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
984
1055
|
const client = await createAuthenticatedClient();
|
|
985
1056
|
|
|
1057
|
+
|
|
986
1058
|
return await withRequestUsageLogging(client, 'linear_milestone', params.action, async () => {
|
|
987
1059
|
switch (params.action) {
|
|
988
1060
|
case 'list':
|
|
@@ -998,12 +1070,10 @@ async function registerLinearTools(pi) {
|
|
|
998
1070
|
default:
|
|
999
1071
|
throw new Error(`Unknown action: ${params.action}`);
|
|
1000
1072
|
}
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1073
|
+
}, rateLimitDebug);
|
|
1074
|
+
}, {
|
|
1075
|
+
transformError: withMilestoneScopeHint,
|
|
1076
|
+
});
|
|
1007
1077
|
},
|
|
1008
1078
|
});
|
|
1009
1079
|
}
|
|
@@ -1013,13 +1083,14 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1013
1083
|
// Safety wrapper: never let extension errors crash pi
|
|
1014
1084
|
try {
|
|
1015
1085
|
pi.registerCommand('linear-tools-config', {
|
|
1016
|
-
description: 'Configure pi-linear-tools settings (API key
|
|
1086
|
+
description: 'Configure pi-linear-tools settings (API key, default team, rate limit debug)',
|
|
1017
1087
|
handler: async (argsText, ctx) => {
|
|
1018
1088
|
const args = parseArgs(argsText);
|
|
1019
1089
|
const apiKey = readFlag(args, '--api-key');
|
|
1020
1090
|
const defaultTeam = readFlag(args, '--default-team');
|
|
1021
1091
|
const projectTeam = readFlag(args, '--team');
|
|
1022
1092
|
const projectName = readFlag(args, '--project');
|
|
1093
|
+
const rateLimitDebug = readFlag(args, '--rate-limit-debug');
|
|
1023
1094
|
|
|
1024
1095
|
if (apiKey) {
|
|
1025
1096
|
const settings = await loadSettings();
|
|
@@ -1040,6 +1111,17 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1040
1111
|
return;
|
|
1041
1112
|
}
|
|
1042
1113
|
|
|
1114
|
+
if (rateLimitDebug) {
|
|
1115
|
+
const settings = await loadSettings();
|
|
1116
|
+
const enabled = rateLimitDebug === 'true' || rateLimitDebug === '1';
|
|
1117
|
+
settings.rateLimitDebug = enabled;
|
|
1118
|
+
await saveSettings(settings);
|
|
1119
|
+
if (ctx?.hasUI) {
|
|
1120
|
+
ctx.ui.notify(`Rate limit debug ${enabled ? 'enabled' : 'disabled'}`, 'info');
|
|
1121
|
+
}
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1043
1125
|
if (defaultTeam) {
|
|
1044
1126
|
const settings = await loadSettings();
|
|
1045
1127
|
settings.defaultTeam = defaultTeam;
|
|
@@ -1083,18 +1165,24 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1083
1165
|
return;
|
|
1084
1166
|
}
|
|
1085
1167
|
|
|
1086
|
-
if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
|
|
1168
|
+
if (!apiKey && !defaultTeam && !projectTeam && !projectName && !rateLimitDebug && ctx?.hasUI && ctx?.ui) {
|
|
1087
1169
|
await runInteractiveConfigFlow(ctx, pi);
|
|
1088
1170
|
return;
|
|
1089
1171
|
}
|
|
1090
1172
|
|
|
1173
|
+
// Show current settings if no valid action was specified
|
|
1174
|
+
if (apiKey || defaultTeam || projectTeam || projectName || rateLimitDebug) {
|
|
1175
|
+
// Actions above already handled and returned
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1091
1179
|
const settings = await loadSettings();
|
|
1092
1180
|
const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
1093
1181
|
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
|
|
1094
1182
|
|
|
1095
1183
|
pi.sendMessage({
|
|
1096
1184
|
customType: 'pi-linear-tools',
|
|
1097
|
-
content: `Configuration:\n LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})\n Default workspace: ${settings.defaultWorkspace?.name || 'not set'}\n Default team: ${settings.defaultTeam || 'not set'}\n Project team mappings: ${Object.keys(settings.projects || {}).length}\n\nCommands:\n /linear-tools-config --api-key lin_xxx\n /linear-tools-config --default-team ENG\n /linear-tools-config --team ENG --project MyProject\n\nNote: environment LINEAR_API_KEY takes precedence over settings file.`,
|
|
1185
|
+
content: `Configuration:\n LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})\n Default workspace: ${settings.defaultWorkspace?.name || 'not set'}\n Default team: ${settings.defaultTeam || 'not set'}\n Rate limit debug: ${settings.rateLimitDebug ? 'enabled' : 'disabled'}\n Project team mappings: ${Object.keys(settings.projects || {}).length}\n\nCommands:\n /linear-tools-config --api-key lin_xxx\n /linear-tools-config --default-team ENG\n /linear-tools-config --team ENG --project MyProject\n /linear-tools-config --rate-limit-debug true|false\n\nNote: environment LINEAR_API_KEY takes precedence over settings file.`,
|
|
1098
1186
|
display: true,
|
|
1099
1187
|
});
|
|
1100
1188
|
},
|
|
@@ -1139,6 +1227,7 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1139
1227
|
' /linear-tools-config --api-key <key>',
|
|
1140
1228
|
' /linear-tools-config --default-team <team-key>',
|
|
1141
1229
|
' /linear-tools-config --team <team-key> --project <project-name-or-id>',
|
|
1230
|
+
' /linear-tools-config --rate-limit-debug true|false',
|
|
1142
1231
|
' /linear-tools-help',
|
|
1143
1232
|
' /linear-tools-reload',
|
|
1144
1233
|
'',
|
package/package.json
CHANGED
package/src/linear-client.js
CHANGED
|
@@ -140,6 +140,40 @@ export function getClientRateLimit(client) {
|
|
|
140
140
|
return { remaining: null, resetAt: null, resetTime: null };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Get detailed rate limit info including usage percentage
|
|
145
|
+
* @param {LinearClient} client - Linear SDK client
|
|
146
|
+
* @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null, used: number, usagePercent: number|null, total: number}}
|
|
147
|
+
*/
|
|
148
|
+
export function getClientRateLimitInfo(client) {
|
|
149
|
+
const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
|
|
150
|
+
const total = 5000; // Linear's hourly request limit
|
|
151
|
+
|
|
152
|
+
if (trackerData && trackerData.remaining !== undefined) {
|
|
153
|
+
const remaining = trackerData.remaining;
|
|
154
|
+
const used = Math.max(0, total - remaining);
|
|
155
|
+
const usagePercent = Math.round((used / total) * 100);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
remaining,
|
|
159
|
+
resetAt: trackerData.resetAt,
|
|
160
|
+
resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
|
|
161
|
+
used,
|
|
162
|
+
usagePercent,
|
|
163
|
+
total,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
remaining: null,
|
|
169
|
+
resetAt: null,
|
|
170
|
+
resetTime: null,
|
|
171
|
+
used: 0,
|
|
172
|
+
usagePercent: null,
|
|
173
|
+
total,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
/**
|
|
144
178
|
* Expose per-client request counters for diagnostics
|
|
145
179
|
*/
|
|
@@ -317,3 +351,20 @@ export function setTestClientFactory(factory) {
|
|
|
317
351
|
export function resetTestClientFactory() {
|
|
318
352
|
_testClientFactory = null;
|
|
319
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Set rate limit tracker state for testing (TEST ONLY)
|
|
357
|
+
* @param {string} apiKey - API key used to create the client
|
|
358
|
+
* @param {{remaining: number, resetAt: number}} state - Tracker state
|
|
359
|
+
*/
|
|
360
|
+
export function setTestRateLimitTracker(apiKey, state) {
|
|
361
|
+
const trackerKey = getTrackerKey(apiKey);
|
|
362
|
+
rateLimitTracker.set(trackerKey, state);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Clear rate limit tracker for testing (TEST ONLY)
|
|
367
|
+
*/
|
|
368
|
+
export function clearTestRateLimitTracker() {
|
|
369
|
+
rateLimitTracker.clear();
|
|
370
|
+
}
|
package/src/linear.js
CHANGED
|
@@ -235,6 +235,45 @@ const PROJECT_UPDATE_MUTATION = `
|
|
|
235
235
|
}
|
|
236
236
|
`;
|
|
237
237
|
|
|
238
|
+
|
|
239
|
+
const MILESTONE_DETAILS_QUERY = `
|
|
240
|
+
query MilestoneDetails($id: String!, $issueLimit: Int!) {
|
|
241
|
+
projectMilestone(id: $id) {
|
|
242
|
+
id
|
|
243
|
+
name
|
|
244
|
+
description
|
|
245
|
+
progress
|
|
246
|
+
sortOrder
|
|
247
|
+
targetDate
|
|
248
|
+
status
|
|
249
|
+
project {
|
|
250
|
+
id
|
|
251
|
+
name
|
|
252
|
+
}
|
|
253
|
+
issues(first: $issueLimit) {
|
|
254
|
+
nodes {
|
|
255
|
+
id
|
|
256
|
+
identifier
|
|
257
|
+
title
|
|
258
|
+
priority
|
|
259
|
+
estimate
|
|
260
|
+
state {
|
|
261
|
+
id
|
|
262
|
+
name
|
|
263
|
+
color
|
|
264
|
+
type
|
|
265
|
+
}
|
|
266
|
+
assignee {
|
|
267
|
+
id
|
|
268
|
+
name
|
|
269
|
+
displayName
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
`;
|
|
276
|
+
|
|
238
277
|
const PROJECT_DELETE_MUTATION = `
|
|
239
278
|
mutation ProjectDelete($id: String!) {
|
|
240
279
|
projectDelete(id: $id) {
|
|
@@ -1331,7 +1370,7 @@ function checkRateLimitWarning() {
|
|
|
1331
1370
|
* @param {Error} error - The error to check
|
|
1332
1371
|
* @returns {boolean}
|
|
1333
1372
|
*/
|
|
1334
|
-
function isLinearError(error) {
|
|
1373
|
+
export function isLinearError(error) {
|
|
1335
1374
|
return error?.constructor?.name?.includes('LinearError') ||
|
|
1336
1375
|
error?.name?.includes('LinearError') ||
|
|
1337
1376
|
error?.type?.startsWith?.('Ratelimited') ||
|
|
@@ -1342,6 +1381,19 @@ function isLinearError(error) {
|
|
|
1342
1381
|
error?.type === 'InternalError';
|
|
1343
1382
|
}
|
|
1344
1383
|
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Check if an error is a rate-limit error
|
|
1387
|
+
* @param {Error} error - The error to check
|
|
1388
|
+
* @returns {boolean}
|
|
1389
|
+
*/
|
|
1390
|
+
export function isRateLimitError(error) {
|
|
1391
|
+
return (
|
|
1392
|
+
isLinearError(error) &&
|
|
1393
|
+
(error?.type === 'Ratelimited' || String(error?.message || '').toLowerCase().includes('rate limit'))
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1345
1397
|
/**
|
|
1346
1398
|
* Format a Linear API error into a user-friendly message
|
|
1347
1399
|
* @param {Error} error - The original error
|
|
@@ -3527,24 +3579,90 @@ export async function fetchProjectMilestones(client, projectId) {
|
|
|
3527
3579
|
*/
|
|
3528
3580
|
export async function fetchMilestoneDetails(client, milestoneId) {
|
|
3529
3581
|
return withLinearErrorHandling(async () => {
|
|
3582
|
+
// Prefer raw GraphQL: one request instead of N+1 SDK lazy loads.
|
|
3583
|
+
// This dramatically reduces rate-limit exposure for milestone view.
|
|
3584
|
+
if (getRawRequest(client)) {
|
|
3585
|
+
try {
|
|
3586
|
+
const data = await executeGraphQL(client, MILESTONE_DETAILS_QUERY, {
|
|
3587
|
+
id: milestoneId,
|
|
3588
|
+
issueLimit: 250,
|
|
3589
|
+
});
|
|
3590
|
+
|
|
3591
|
+
const raw = data?.projectMilestone;
|
|
3592
|
+
if (!raw) {
|
|
3593
|
+
throw new Error(`Milestone not found: ${milestoneId}`);
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
const project = raw.project ? { id: raw.project.id, name: raw.project.name } : null;
|
|
3597
|
+
const issues = (raw.issues?.nodes || []).map((issue) => ({
|
|
3598
|
+
id: issue.id,
|
|
3599
|
+
identifier: issue.identifier,
|
|
3600
|
+
title: issue.title,
|
|
3601
|
+
state: issue.state ? { name: issue.state.name, color: issue.state.color, type: issue.state.type } : null,
|
|
3602
|
+
assignee: issue.assignee ? { id: issue.assignee.id, name: issue.assignee.name, displayName: issue.assignee.displayName } : null,
|
|
3603
|
+
priority: issue.priority ?? null,
|
|
3604
|
+
estimate: issue.estimate ?? null,
|
|
3605
|
+
}));
|
|
3606
|
+
|
|
3607
|
+
return {
|
|
3608
|
+
id: raw.id,
|
|
3609
|
+
name: raw.name,
|
|
3610
|
+
description: raw.description ?? null,
|
|
3611
|
+
progress: raw.progress ?? null,
|
|
3612
|
+
order: raw.sortOrder ?? null,
|
|
3613
|
+
targetDate: raw.targetDate ?? null,
|
|
3614
|
+
status: raw.status ?? null,
|
|
3615
|
+
project,
|
|
3616
|
+
issues,
|
|
3617
|
+
};
|
|
3618
|
+
} catch (err) {
|
|
3619
|
+
// If raw GraphQL fails (e.g., network error), fall through to SDK path.
|
|
3620
|
+
// Rate-limit errors are re-thrown by executeGraphQL -> withLinearErrorHandling.
|
|
3621
|
+
if (!isLinearError(err)) {
|
|
3622
|
+
debug('fetchMilestoneDetails: raw GraphQL unavailable, falling back to SDK', { error: err?.message });
|
|
3623
|
+
} else {
|
|
3624
|
+
throw err; // re-throw Linear errors including rate limits
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
// SDK fallback: the rate-limit propagation logic for lazy loads is preserved.
|
|
3530
3630
|
const milestone = await client.projectMilestone(milestoneId);
|
|
3531
3631
|
if (!milestone) {
|
|
3532
3632
|
throw new Error(`Milestone not found: ${milestoneId}`);
|
|
3533
3633
|
}
|
|
3534
3634
|
|
|
3535
3635
|
// Fetch project and issues in parallel
|
|
3536
|
-
const [
|
|
3636
|
+
const [projectResult, issuesResult] = await Promise.all([
|
|
3537
3637
|
milestone.project?.catch?.(() => null) ?? milestone.project,
|
|
3538
|
-
milestone.issues?.()?.catch?.(() =>
|
|
3638
|
+
milestone.issues?.()?.catch?.((err) => {
|
|
3639
|
+
if (isRateLimitError(err)) {
|
|
3640
|
+
// Propagate rate-limit errors so the caller can surface a safe user message
|
|
3641
|
+
// instead of silently swallowing the issue list.
|
|
3642
|
+
throw err;
|
|
3643
|
+
}
|
|
3644
|
+
return { nodes: [] };
|
|
3645
|
+
}) ?? { nodes: [] },
|
|
3539
3646
|
]);
|
|
3540
3647
|
|
|
3648
|
+
const project = projectResult;
|
|
3649
|
+
|
|
3541
3650
|
const issues = await Promise.all(
|
|
3542
3651
|
(issuesResult.nodes || []).map(async (issue) => {
|
|
3652
|
+
// Propagate rate-limit errors from per-issue lazy loads so the caller can surface
|
|
3653
|
+
// a safe user message. Silently degrading to partial data masks the problem.
|
|
3543
3654
|
const [state, assignee] = await Promise.all([
|
|
3544
|
-
issue.state?.catch?.(() =>
|
|
3545
|
-
|
|
3655
|
+
issue.state?.catch?.((err) => {
|
|
3656
|
+
if (isRateLimitError(err)) throw err;
|
|
3657
|
+
return null;
|
|
3658
|
+
}) ?? issue.state,
|
|
3659
|
+
issue.assignee?.catch?.((err) => {
|
|
3660
|
+
if (isRateLimitError(err)) throw err;
|
|
3661
|
+
return null;
|
|
3662
|
+
}) ?? issue.assignee,
|
|
3546
3663
|
]);
|
|
3547
3664
|
|
|
3665
|
+
|
|
3548
3666
|
return {
|
|
3549
3667
|
id: issue.id,
|
|
3550
3668
|
identifier: issue.identifier,
|
package/src/settings.js
CHANGED
|
@@ -21,6 +21,7 @@ export function getDefaultSettings() {
|
|
|
21
21
|
defaultTeam: null,
|
|
22
22
|
defaultWorkspace: null,
|
|
23
23
|
projects: {},
|
|
24
|
+
rateLimitDebug: false, // Enable detailed rate limit info per tool call
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -111,6 +112,11 @@ function migrateSettings(settings) {
|
|
|
111
112
|
migrated.projects = {};
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// Ensure rateLimitDebug is a boolean
|
|
116
|
+
if (migrated.rateLimitDebug === undefined) {
|
|
117
|
+
migrated.rateLimitDebug = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
// Migrate project scopes
|
|
115
121
|
for (const [projectId, cfg] of Object.entries(migrated.projects)) {
|
|
116
122
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
@@ -216,6 +222,11 @@ export function validateSettings(settings) {
|
|
|
216
222
|
}
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
// Validate rateLimitDebug
|
|
226
|
+
if (settings.rateLimitDebug !== undefined && typeof settings.rateLimitDebug !== 'boolean') {
|
|
227
|
+
errors.push('settings.rateLimitDebug must be a boolean');
|
|
228
|
+
}
|
|
229
|
+
|
|
219
230
|
return { valid: errors.length === 0, errors };
|
|
220
231
|
}
|
|
221
232
|
|