@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 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
- `pi-linear-tools` is a 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 tools and CLI commands.
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
- Useful mental model:
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 (!INCLUDE_USAGE_SUMMARY || !result || typeof result !== 'object') {
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: `${item.text}\n\n_${usageDelta.summary}_`,
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
- cached
471
- ? `Linear API rate limit exceeded (cached).\n\nThe rate limit resets at: ${resetTime}\n\nPlease wait before making more requests.`
472
- : `Linear API rate limit exceeded.\n\nThe rate limit resets at: ${resetTime}\n\nPlease wait before making more requests, or reduce the frequency of API calls.`,
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(error);
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
- try {
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
- } catch (error) {
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
- try {
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
- } catch (error) {
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
- try {
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
- } catch (error) {
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
- try {
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
- } catch (error) {
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
- try {
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
- } catch (error) {
1003
- return handleToolExecutionError(error, 'Linear milestone operation failed', {
1004
- transformError: withMilestoneScopeHint,
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 and default team mappings)',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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 [project, issuesResult] = await Promise.all([
3636
+ const [projectResult, issuesResult] = await Promise.all([
3537
3637
  milestone.project?.catch?.(() => null) ?? milestone.project,
3538
- milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
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?.(() => null) ?? issue.state,
3545
- issue.assignee?.catch?.(() => null) ?? issue.assignee,
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