@fink-andreas/pi-linear-tools 0.5.1 → 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 +17 -0
- package/README.md +24 -14
- package/extensions/pi-linear-tools.js +113 -19
- 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,22 @@
|
|
|
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
|
+
|
|
3
20
|
## v0.5.1 (2026-04-17)
|
|
4
21
|
|
|
5
22
|
### Bug Fixes
|
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:')) {
|
|
@@ -707,6 +765,8 @@ async function registerLinearTools(pi) {
|
|
|
707
765
|
return buildRateLimitToolResult({ requestsResetAt: resetAt.getTime(), type: 'Ratelimited' }, { cached: true });
|
|
708
766
|
}
|
|
709
767
|
|
|
768
|
+
const settings = await loadSettings();
|
|
769
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
710
770
|
const client = await createAuthenticatedClient();
|
|
711
771
|
|
|
712
772
|
return await withRequestUsageLogging(client, 'linear_issue', params.action, async () => {
|
|
@@ -734,7 +794,7 @@ async function registerLinearTools(pi) {
|
|
|
734
794
|
default:
|
|
735
795
|
throw new Error(`Unknown action: ${params.action}`);
|
|
736
796
|
}
|
|
737
|
-
});
|
|
797
|
+
}, rateLimitDebug);
|
|
738
798
|
});
|
|
739
799
|
},
|
|
740
800
|
});
|
|
@@ -802,6 +862,8 @@ async function registerLinearTools(pi) {
|
|
|
802
862
|
renderResult: renderMarkdownResult,
|
|
803
863
|
async execute(_toolCallId, params) {
|
|
804
864
|
return executeToolSafely('Linear project operation failed', async () => {
|
|
865
|
+
const settings = await loadSettings();
|
|
866
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
805
867
|
const client = await createAuthenticatedClient();
|
|
806
868
|
|
|
807
869
|
return await withRequestUsageLogging(client, 'linear_project', params.action, async () => {
|
|
@@ -823,7 +885,7 @@ async function registerLinearTools(pi) {
|
|
|
823
885
|
default:
|
|
824
886
|
throw new Error(`Unknown action: ${params.action}`);
|
|
825
887
|
}
|
|
826
|
-
});
|
|
888
|
+
}, rateLimitDebug);
|
|
827
889
|
});
|
|
828
890
|
},
|
|
829
891
|
});
|
|
@@ -879,6 +941,8 @@ async function registerLinearTools(pi) {
|
|
|
879
941
|
renderResult: renderMarkdownResult,
|
|
880
942
|
async execute(_toolCallId, params) {
|
|
881
943
|
return executeToolSafely('Linear project update operation failed', async () => {
|
|
944
|
+
const settings = await loadSettings();
|
|
945
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
882
946
|
const client = await createAuthenticatedClient();
|
|
883
947
|
|
|
884
948
|
return await withRequestUsageLogging(client, 'linear_project_update', params.action, async () => {
|
|
@@ -898,7 +962,7 @@ async function registerLinearTools(pi) {
|
|
|
898
962
|
default:
|
|
899
963
|
throw new Error(`Unknown action: ${params.action}`);
|
|
900
964
|
}
|
|
901
|
-
});
|
|
965
|
+
}, rateLimitDebug);
|
|
902
966
|
});
|
|
903
967
|
},
|
|
904
968
|
});
|
|
@@ -923,6 +987,8 @@ async function registerLinearTools(pi) {
|
|
|
923
987
|
renderResult: renderMarkdownResult,
|
|
924
988
|
async execute(_toolCallId, params) {
|
|
925
989
|
return executeToolSafely('Linear team operation failed', async () => {
|
|
990
|
+
const settings = await loadSettings();
|
|
991
|
+
const rateLimitDebug = settings.rateLimitDebug || false;
|
|
926
992
|
const client = await createAuthenticatedClient();
|
|
927
993
|
|
|
928
994
|
return await withRequestUsageLogging(client, 'linear_team', params.action, async () => {
|
|
@@ -932,7 +998,7 @@ async function registerLinearTools(pi) {
|
|
|
932
998
|
default:
|
|
933
999
|
throw new Error(`Unknown action: ${params.action}`);
|
|
934
1000
|
}
|
|
935
|
-
});
|
|
1001
|
+
}, rateLimitDebug);
|
|
936
1002
|
});
|
|
937
1003
|
},
|
|
938
1004
|
});
|
|
@@ -978,8 +1044,17 @@ async function registerLinearTools(pi) {
|
|
|
978
1044
|
renderResult: renderMarkdownResult,
|
|
979
1045
|
async execute(_toolCallId, params) {
|
|
980
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;
|
|
981
1055
|
const client = await createAuthenticatedClient();
|
|
982
1056
|
|
|
1057
|
+
|
|
983
1058
|
return await withRequestUsageLogging(client, 'linear_milestone', params.action, async () => {
|
|
984
1059
|
switch (params.action) {
|
|
985
1060
|
case 'list':
|
|
@@ -995,7 +1070,7 @@ async function registerLinearTools(pi) {
|
|
|
995
1070
|
default:
|
|
996
1071
|
throw new Error(`Unknown action: ${params.action}`);
|
|
997
1072
|
}
|
|
998
|
-
});
|
|
1073
|
+
}, rateLimitDebug);
|
|
999
1074
|
}, {
|
|
1000
1075
|
transformError: withMilestoneScopeHint,
|
|
1001
1076
|
});
|
|
@@ -1008,13 +1083,14 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1008
1083
|
// Safety wrapper: never let extension errors crash pi
|
|
1009
1084
|
try {
|
|
1010
1085
|
pi.registerCommand('linear-tools-config', {
|
|
1011
|
-
description: 'Configure pi-linear-tools settings (API key
|
|
1086
|
+
description: 'Configure pi-linear-tools settings (API key, default team, rate limit debug)',
|
|
1012
1087
|
handler: async (argsText, ctx) => {
|
|
1013
1088
|
const args = parseArgs(argsText);
|
|
1014
1089
|
const apiKey = readFlag(args, '--api-key');
|
|
1015
1090
|
const defaultTeam = readFlag(args, '--default-team');
|
|
1016
1091
|
const projectTeam = readFlag(args, '--team');
|
|
1017
1092
|
const projectName = readFlag(args, '--project');
|
|
1093
|
+
const rateLimitDebug = readFlag(args, '--rate-limit-debug');
|
|
1018
1094
|
|
|
1019
1095
|
if (apiKey) {
|
|
1020
1096
|
const settings = await loadSettings();
|
|
@@ -1035,6 +1111,17 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1035
1111
|
return;
|
|
1036
1112
|
}
|
|
1037
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
|
+
|
|
1038
1125
|
if (defaultTeam) {
|
|
1039
1126
|
const settings = await loadSettings();
|
|
1040
1127
|
settings.defaultTeam = defaultTeam;
|
|
@@ -1078,18 +1165,24 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1078
1165
|
return;
|
|
1079
1166
|
}
|
|
1080
1167
|
|
|
1081
|
-
if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
|
|
1168
|
+
if (!apiKey && !defaultTeam && !projectTeam && !projectName && !rateLimitDebug && ctx?.hasUI && ctx?.ui) {
|
|
1082
1169
|
await runInteractiveConfigFlow(ctx, pi);
|
|
1083
1170
|
return;
|
|
1084
1171
|
}
|
|
1085
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
|
+
|
|
1086
1179
|
const settings = await loadSettings();
|
|
1087
1180
|
const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
1088
1181
|
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
|
|
1089
1182
|
|
|
1090
1183
|
pi.sendMessage({
|
|
1091
1184
|
customType: 'pi-linear-tools',
|
|
1092
|
-
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.`,
|
|
1093
1186
|
display: true,
|
|
1094
1187
|
});
|
|
1095
1188
|
},
|
|
@@ -1134,6 +1227,7 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
1134
1227
|
' /linear-tools-config --api-key <key>',
|
|
1135
1228
|
' /linear-tools-config --default-team <team-key>',
|
|
1136
1229
|
' /linear-tools-config --team <team-key> --project <project-name-or-id>',
|
|
1230
|
+
' /linear-tools-config --rate-limit-debug true|false',
|
|
1137
1231
|
' /linear-tools-help',
|
|
1138
1232
|
' /linear-tools-reload',
|
|
1139
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
|
|