@fink-andreas/pi-linear-tools 0.4.1 → 0.4.2

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2 (2026-03-26)
4
+
5
+ Rate-limit resilience and API usage diagnostics release.
6
+
7
+ ### Bug Fixes
8
+ - Hardened issue update flow when post-mutation refresh is rate-limited
9
+ - Added explicit user-facing note when update succeeded but refresh is partial due to rate limits
10
+ - Fixed per-command API usage metric keying so usage deltas report correctly
11
+
12
+ ### Performance / API Usage
13
+ - Added short-lived in-memory caches for viewer, project list, team list, and team workflow states
14
+ - Added direct-ID fast paths for project/team resolution to avoid full list fetches
15
+
16
+ ### Diagnostics
17
+ - Added optional per-command usage summary output via `PI_LINEAR_TOOLS_USAGE_SUMMARY=true`
18
+ - Added debug-level per-command request delta logging across issue/project/team/milestone tools
19
+
20
+ ### Documentation / Tests
21
+ - Documented new diagnostics flag and cache behavior
22
+ - Added regression tests for rate-limit fallback and API usage caching
23
+
3
24
  ## v0.4.0 (2026-03-23)
4
25
 
5
26
  Comprehensive error handling and file-first logging release.
package/README.md CHANGED
@@ -7,14 +7,13 @@
7
7
  ### As a pi package (recommended)
8
8
  ```bash
9
9
  pi install npm:@fink-andreas/pi-linear-tools
10
- pi config
11
10
  ```
12
- Enable the `pi-linear-tools` extension resource.
13
11
 
14
12
  ### As an npm package
15
13
  ```bash
16
14
  npm install -g @fink-andreas/pi-linear-tools
17
15
  ```
16
+ Use pi-linear-tools as a CLI tool.
18
17
 
19
18
  ## Initial configuration
20
19
 
@@ -54,10 +53,9 @@ Actions: `list`, `view`, `create`, `update`, `delete`
54
53
 
55
54
  ## CLI usage
56
55
 
57
- If installed globally via npm, you can run:
56
+ If installed globally via ```npm install -g @fink-andreas/pi-linear-tools```, CLI command ```pi-linear-tools``` is available:
58
57
 
59
58
  ```bash
60
- npm install -g @fink-andreas/pi-linear-tools
61
59
  pi-linear-tools --help
62
60
  pi-linear-tools config
63
61
  pi-linear-tools config --api-key lin_xxx
@@ -65,12 +63,6 @@ pi-linear-tools config --default-team ENG
65
63
  pi-linear-tools config --team ENG --project "My Project"
66
64
  ```
67
65
 
68
- If `pi-linear-tools` is not found, run it from the repo:
69
-
70
- ```bash
71
- node bin/pi-linear-tools.js --help
72
- ```
73
-
74
66
  ### Issue commands
75
67
 
76
68
  ```bash
@@ -144,6 +136,12 @@ Settings path:
144
136
  Environment fallback:
145
137
  - `LINEAR_API_KEY` (takes precedence over settings)
146
138
 
139
+ Debug/diagnostics environment flags:
140
+ - `PI_LINEAR_TOOLS_USAGE_SUMMARY=true` — append per-command Linear API usage summary to tool output markdown and include `details.apiUsage`
141
+ - `LOG_LEVEL=debug` — enable detailed file logging
142
+ - `PI_LINEAR_TOOLS_LOG_TO_CONSOLE=true` — mirror logs to console (normally file-first logging)
143
+ - `PI_LINEAR_TOOLS_LOG_FILE=/custom/path.log` — override log file path
144
+
147
145
  ## Development
148
146
 
149
147
  ```bash
@@ -1,6 +1,6 @@
1
1
  import { loadSettings, saveSettings } from '../src/settings.js';
2
- import { createLinearClient, checkAndClearRateLimit, markRateLimited } from '../src/linear-client.js';
3
- import { setQuietMode } from '../src/logger.js';
2
+ import { createLinearClient, checkAndClearRateLimit, markRateLimited, getClientRequestMetrics } from '../src/linear-client.js';
3
+ import { setQuietMode, debug } from '../src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
6
6
  fetchTeams,
@@ -65,6 +65,7 @@ import { authenticate, getAccessToken, logout } from '../src/auth/index.js';
65
65
  import { withMilestoneScopeHint } from '../src/error-hints.js';
66
66
 
67
67
  let cachedApiKey = null;
68
+ const INCLUDE_USAGE_SUMMARY = String(process.env.PI_LINEAR_TOOLS_USAGE_SUMMARY || '').toLowerCase() === 'true';
68
69
 
69
70
  async function getLinearAuth() {
70
71
  const envKey = process.env.LINEAR_API_KEY;
@@ -106,6 +107,65 @@ async function createAuthenticatedClient() {
106
107
  return createLinearClient(await getLinearAuth());
107
108
  }
108
109
 
110
+ async function withRequestUsageLogging(client, toolName, action, operation) {
111
+ const before = getClientRequestMetrics(client);
112
+
113
+ try {
114
+ const result = await operation();
115
+ const after = getClientRequestMetrics(client);
116
+
117
+ const usageDelta = {
118
+ tool: toolName,
119
+ action,
120
+ requestsDelta: after.total - before.total,
121
+ successDelta: after.success - before.success,
122
+ failedDelta: after.failed - before.failed,
123
+ rateLimitedDelta: after.rateLimited - before.rateLimited,
124
+ summary: `Linear API usage: ${after.total - before.total} req (${after.success - before.success} ok, ${after.failed - before.failed} failed, ${after.rateLimited - before.rateLimited} rate-limited)`,
125
+ };
126
+
127
+ debug('[pi-linear-tools] API usage per command', usageDelta);
128
+
129
+ if (!INCLUDE_USAGE_SUMMARY || !result || typeof result !== 'object') {
130
+ return result;
131
+ }
132
+
133
+ const details = (result.details && typeof result.details === 'object') ? result.details : {};
134
+ const content = Array.isArray(result.content)
135
+ ? result.content.map((item, idx) => {
136
+ if (idx !== 0 || item?.type !== 'text' || typeof item.text !== 'string') return item;
137
+ return {
138
+ ...item,
139
+ text: `${item.text}\n\n_${usageDelta.summary}_`,
140
+ };
141
+ })
142
+ : result.content;
143
+
144
+ return {
145
+ ...result,
146
+ content,
147
+ details: {
148
+ ...details,
149
+ apiUsage: usageDelta,
150
+ },
151
+ };
152
+ } catch (error) {
153
+ const after = getClientRequestMetrics(client);
154
+
155
+ debug('[pi-linear-tools] API usage per command (error)', {
156
+ tool: toolName,
157
+ action,
158
+ requestsDelta: after.total - before.total,
159
+ successDelta: after.success - before.success,
160
+ failedDelta: after.failed - before.failed,
161
+ rateLimitedDelta: after.rateLimited - before.rateLimited,
162
+ error: String(error?.message || error || 'unknown'),
163
+ });
164
+
165
+ throw error;
166
+ }
167
+ }
168
+
109
169
  async function resolveDefaultTeam(projectId) {
110
170
  const settings = await loadSettings();
111
171
 
@@ -552,28 +612,30 @@ async function registerLinearTools(pi) {
552
612
  try {
553
613
  const client = await createAuthenticatedClient();
554
614
 
555
- switch (params.action) {
556
- case 'list':
557
- return await executeIssueList(client, params);
558
- case 'view':
559
- return await executeIssueView(client, params);
560
- case 'create':
561
- return await executeIssueCreate(client, params, { resolveDefaultTeam });
562
- case 'update':
563
- return await executeIssueUpdate(client, params);
564
- case 'comment':
565
- return await executeIssueComment(client, params);
566
- case 'start':
567
- return await executeIssueStart(client, params, {
568
- gitExecutor: async (branchName, fromRef, onBranchExists) => {
569
- return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
570
- },
571
- });
572
- case 'delete':
573
- return await executeIssueDelete(client, params);
574
- default:
575
- throw new Error(`Unknown action: ${params.action}`);
576
- }
615
+ return await withRequestUsageLogging(client, 'linear_issue', params.action, async () => {
616
+ switch (params.action) {
617
+ case 'list':
618
+ return await executeIssueList(client, params);
619
+ case 'view':
620
+ return await executeIssueView(client, params);
621
+ case 'create':
622
+ return await executeIssueCreate(client, params, { resolveDefaultTeam });
623
+ case 'update':
624
+ return await executeIssueUpdate(client, params);
625
+ case 'comment':
626
+ return await executeIssueComment(client, params);
627
+ case 'start':
628
+ return await executeIssueStart(client, params, {
629
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
630
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
631
+ },
632
+ });
633
+ case 'delete':
634
+ return await executeIssueDelete(client, params);
635
+ default:
636
+ throw new Error(`Unknown action: ${params.action}`);
637
+ }
638
+ });
577
639
  } catch (error) {
578
640
  // Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
579
641
  const errorType = error?.type || '';
@@ -648,12 +710,14 @@ async function registerLinearTools(pi) {
648
710
  try {
649
711
  const client = await createAuthenticatedClient();
650
712
 
651
- switch (params.action) {
652
- case 'list':
653
- return await executeProjectList(client);
654
- default:
655
- throw new Error(`Unknown action: ${params.action}`);
656
- }
713
+ return await withRequestUsageLogging(client, 'linear_project', params.action, async () => {
714
+ switch (params.action) {
715
+ case 'list':
716
+ return await executeProjectList(client);
717
+ default:
718
+ throw new Error(`Unknown action: ${params.action}`);
719
+ }
720
+ });
657
721
  } catch (error) {
658
722
  // Comprehensive error handling - catch ALL errors
659
723
  const errorType = error?.type || '';
@@ -696,12 +760,14 @@ async function registerLinearTools(pi) {
696
760
  try {
697
761
  const client = await createAuthenticatedClient();
698
762
 
699
- switch (params.action) {
700
- case 'list':
701
- return await executeTeamList(client);
702
- default:
703
- throw new Error(`Unknown action: ${params.action}`);
704
- }
763
+ return await withRequestUsageLogging(client, 'linear_team', params.action, async () => {
764
+ switch (params.action) {
765
+ case 'list':
766
+ return await executeTeamList(client);
767
+ default:
768
+ throw new Error(`Unknown action: ${params.action}`);
769
+ }
770
+ });
705
771
  } catch (error) {
706
772
  // Comprehensive error handling - catch ALL errors
707
773
  const errorType = error?.type || '';
@@ -765,20 +831,22 @@ async function registerLinearTools(pi) {
765
831
  try {
766
832
  const client = await createAuthenticatedClient();
767
833
 
768
- switch (params.action) {
769
- case 'list':
770
- return await executeMilestoneList(client, params);
771
- case 'view':
772
- return await executeMilestoneView(client, params);
773
- case 'create':
774
- return await executeMilestoneCreate(client, params);
775
- case 'update':
776
- return await executeMilestoneUpdate(client, params);
777
- case 'delete':
778
- return await executeMilestoneDelete(client, params);
779
- default:
780
- throw new Error(`Unknown action: ${params.action}`);
781
- }
834
+ return await withRequestUsageLogging(client, 'linear_milestone', params.action, async () => {
835
+ switch (params.action) {
836
+ case 'list':
837
+ return await executeMilestoneList(client, params);
838
+ case 'view':
839
+ return await executeMilestoneView(client, params);
840
+ case 'create':
841
+ return await executeMilestoneCreate(client, params);
842
+ case 'update':
843
+ return await executeMilestoneUpdate(client, params);
844
+ case 'delete':
845
+ return await executeMilestoneDelete(client, params);
846
+ default:
847
+ throw new Error(`Unknown action: ${params.action}`);
848
+ }
849
+ });
782
850
  } catch (error) {
783
851
  // Apply milestone-specific hint, then wrap with operation context
784
852
  const hintError = withMilestoneScopeHint(error);
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadSettings, saveSettings } from './src/settings.js';
2
- import { createLinearClient, checkAndClearRateLimit, markRateLimited } from './src/linear-client.js';
3
- import { setQuietMode } from './src/logger.js';
2
+ import { createLinearClient, checkAndClearRateLimit, markRateLimited, getClientRequestMetrics } from './src/linear-client.js';
3
+ import { setQuietMode, debug } from './src/logger.js';
4
4
  import {
5
5
  resolveProjectRef,
6
6
  fetchTeams,
@@ -65,6 +65,7 @@ import { authenticate, getAccessToken, logout } from './src/auth/index.js';
65
65
  import { withMilestoneScopeHint } from './src/error-hints.js';
66
66
 
67
67
  let cachedApiKey = null;
68
+ const INCLUDE_USAGE_SUMMARY = String(process.env.PI_LINEAR_TOOLS_USAGE_SUMMARY || '').toLowerCase() === 'true';
68
69
 
69
70
  async function getLinearAuth() {
70
71
  const envKey = process.env.LINEAR_API_KEY;
@@ -106,6 +107,65 @@ async function createAuthenticatedClient() {
106
107
  return createLinearClient(await getLinearAuth());
107
108
  }
108
109
 
110
+ async function withRequestUsageLogging(client, toolName, action, operation) {
111
+ const before = getClientRequestMetrics(client);
112
+
113
+ try {
114
+ const result = await operation();
115
+ const after = getClientRequestMetrics(client);
116
+
117
+ const usageDelta = {
118
+ tool: toolName,
119
+ action,
120
+ requestsDelta: after.total - before.total,
121
+ successDelta: after.success - before.success,
122
+ failedDelta: after.failed - before.failed,
123
+ rateLimitedDelta: after.rateLimited - before.rateLimited,
124
+ summary: `Linear API usage: ${after.total - before.total} req (${after.success - before.success} ok, ${after.failed - before.failed} failed, ${after.rateLimited - before.rateLimited} rate-limited)`,
125
+ };
126
+
127
+ debug('[pi-linear-tools] API usage per command', usageDelta);
128
+
129
+ if (!INCLUDE_USAGE_SUMMARY || !result || typeof result !== 'object') {
130
+ return result;
131
+ }
132
+
133
+ const details = (result.details && typeof result.details === 'object') ? result.details : {};
134
+ const content = Array.isArray(result.content)
135
+ ? result.content.map((item, idx) => {
136
+ if (idx !== 0 || item?.type !== 'text' || typeof item.text !== 'string') return item;
137
+ return {
138
+ ...item,
139
+ text: `${item.text}\n\n_${usageDelta.summary}_`,
140
+ };
141
+ })
142
+ : result.content;
143
+
144
+ return {
145
+ ...result,
146
+ content,
147
+ details: {
148
+ ...details,
149
+ apiUsage: usageDelta,
150
+ },
151
+ };
152
+ } catch (error) {
153
+ const after = getClientRequestMetrics(client);
154
+
155
+ debug('[pi-linear-tools] API usage per command (error)', {
156
+ tool: toolName,
157
+ action,
158
+ requestsDelta: after.total - before.total,
159
+ successDelta: after.success - before.success,
160
+ failedDelta: after.failed - before.failed,
161
+ rateLimitedDelta: after.rateLimited - before.rateLimited,
162
+ error: String(error?.message || error || 'unknown'),
163
+ });
164
+
165
+ throw error;
166
+ }
167
+ }
168
+
109
169
  async function resolveDefaultTeam(projectId) {
110
170
  const settings = await loadSettings();
111
171
 
@@ -552,28 +612,30 @@ async function registerLinearTools(pi) {
552
612
  try {
553
613
  const client = await createAuthenticatedClient();
554
614
 
555
- switch (params.action) {
556
- case 'list':
557
- return await executeIssueList(client, params);
558
- case 'view':
559
- return await executeIssueView(client, params);
560
- case 'create':
561
- return await executeIssueCreate(client, params, { resolveDefaultTeam });
562
- case 'update':
563
- return await executeIssueUpdate(client, params);
564
- case 'comment':
565
- return await executeIssueComment(client, params);
566
- case 'start':
567
- return await executeIssueStart(client, params, {
568
- gitExecutor: async (branchName, fromRef, onBranchExists) => {
569
- return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
570
- },
571
- });
572
- case 'delete':
573
- return await executeIssueDelete(client, params);
574
- default:
575
- throw new Error(`Unknown action: ${params.action}`);
576
- }
615
+ return await withRequestUsageLogging(client, 'linear_issue', params.action, async () => {
616
+ switch (params.action) {
617
+ case 'list':
618
+ return await executeIssueList(client, params);
619
+ case 'view':
620
+ return await executeIssueView(client, params);
621
+ case 'create':
622
+ return await executeIssueCreate(client, params, { resolveDefaultTeam });
623
+ case 'update':
624
+ return await executeIssueUpdate(client, params);
625
+ case 'comment':
626
+ return await executeIssueComment(client, params);
627
+ case 'start':
628
+ return await executeIssueStart(client, params, {
629
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
630
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
631
+ },
632
+ });
633
+ case 'delete':
634
+ return await executeIssueDelete(client, params);
635
+ default:
636
+ throw new Error(`Unknown action: ${params.action}`);
637
+ }
638
+ });
577
639
  } catch (error) {
578
640
  // Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
579
641
  const errorType = error?.type || '';
@@ -648,12 +710,14 @@ async function registerLinearTools(pi) {
648
710
  try {
649
711
  const client = await createAuthenticatedClient();
650
712
 
651
- switch (params.action) {
652
- case 'list':
653
- return await executeProjectList(client);
654
- default:
655
- throw new Error(`Unknown action: ${params.action}`);
656
- }
713
+ return await withRequestUsageLogging(client, 'linear_project', params.action, async () => {
714
+ switch (params.action) {
715
+ case 'list':
716
+ return await executeProjectList(client);
717
+ default:
718
+ throw new Error(`Unknown action: ${params.action}`);
719
+ }
720
+ });
657
721
  } catch (error) {
658
722
  // Comprehensive error handling - catch ALL errors
659
723
  const errorType = error?.type || '';
@@ -696,12 +760,14 @@ async function registerLinearTools(pi) {
696
760
  try {
697
761
  const client = await createAuthenticatedClient();
698
762
 
699
- switch (params.action) {
700
- case 'list':
701
- return await executeTeamList(client);
702
- default:
703
- throw new Error(`Unknown action: ${params.action}`);
704
- }
763
+ return await withRequestUsageLogging(client, 'linear_team', params.action, async () => {
764
+ switch (params.action) {
765
+ case 'list':
766
+ return await executeTeamList(client);
767
+ default:
768
+ throw new Error(`Unknown action: ${params.action}`);
769
+ }
770
+ });
705
771
  } catch (error) {
706
772
  // Comprehensive error handling - catch ALL errors
707
773
  const errorType = error?.type || '';
@@ -765,20 +831,22 @@ async function registerLinearTools(pi) {
765
831
  try {
766
832
  const client = await createAuthenticatedClient();
767
833
 
768
- switch (params.action) {
769
- case 'list':
770
- return await executeMilestoneList(client, params);
771
- case 'view':
772
- return await executeMilestoneView(client, params);
773
- case 'create':
774
- return await executeMilestoneCreate(client, params);
775
- case 'update':
776
- return await executeMilestoneUpdate(client, params);
777
- case 'delete':
778
- return await executeMilestoneDelete(client, params);
779
- default:
780
- throw new Error(`Unknown action: ${params.action}`);
781
- }
834
+ return await withRequestUsageLogging(client, 'linear_milestone', params.action, async () => {
835
+ switch (params.action) {
836
+ case 'list':
837
+ return await executeMilestoneList(client, params);
838
+ case 'view':
839
+ return await executeMilestoneView(client, params);
840
+ case 'create':
841
+ return await executeMilestoneCreate(client, params);
842
+ case 'update':
843
+ return await executeMilestoneUpdate(client, params);
844
+ case 'delete':
845
+ return await executeMilestoneDelete(client, params);
846
+ default:
847
+ throw new Error(`Unknown action: ${params.action}`);
848
+ }
849
+ });
782
850
  } catch (error) {
783
851
  // Apply milestone-specific hint, then wrap with operation context
784
852
  const hintError = withMilestoneScopeHint(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "start": "node index.js",
30
- "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js",
30
+ "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-api-usage-caching.js && node tests/test-assignee-update.js && node tests/test-rate-limit-fallback-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js && node tests/test-team-filter.js",
31
31
  "dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
32
32
  "release:check": "npm test && npm pack --dry-run"
33
33
  },
@@ -44,6 +44,10 @@
44
44
  ]
45
45
  },
46
46
  "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/fink-andreas/pi-linear-tools.git"
50
+ },
47
51
  "dependencies": {
48
52
  "@linear/sdk": "^75.0.0",
49
53
  "@github/keytar": "^7.10.6"
package/src/cli.js CHANGED
@@ -149,7 +149,7 @@ Auth Actions:
149
149
  status Show current authentication status
150
150
 
151
151
  Issue Actions:
152
- list [--project X] [--states X,Y] [--assignee me|all] [--limit N]
152
+ list [--project X] [--states X,Y] [--assignee me|all] [--team X] [--limit N]
153
153
  view <issue> [--no-comments]
154
154
  create --title X [--team X] [--project X] [--description X] [--priority 0-4] [--assignee me|ID]
155
155
  update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
@@ -217,6 +217,7 @@ List Options:
217
217
  --project X Project name or ID (default: current directory name)
218
218
  --states X,Y Filter by state names (comma-separated)
219
219
  --assignee X Filter by assignee: "me" or "all"
220
+ --team X Filter by team key (e.g., ENG) or ID
220
221
  --limit N Max results (default: 50)
221
222
 
222
223
  View Options:
@@ -531,6 +532,7 @@ async function handleIssueList(args) {
531
532
  project: readFlag(args, '--project'),
532
533
  states: parseArrayValue(readFlag(args, '--states')),
533
534
  assignee: readFlag(args, '--assignee'),
535
+ team: readFlag(args, '--team'),
534
536
  limit: parseNumber(readFlag(args, '--limit')),
535
537
  };
536
538
 
package/src/handlers.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  deleteProjectMilestone,
28
28
  deleteIssue,
29
29
  withHandlerErrorHandling,
30
+ getViewer,
30
31
  } from './linear.js';
31
32
  import { debug } from './logger.js';
32
33
 
@@ -130,6 +131,14 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
130
131
 
131
132
  /**
132
133
  * List issues in a project
134
+ * @param {LinearClient} client - Linear SDK client
135
+ * @param {Object} params - Parameters
136
+ * @param {string} [params.project] - Project name or ID
137
+ * @param {string[]} [params.states] - State names to filter by
138
+ * @param {string} [params.assignee] - "me" or "all" for assignee filtering
139
+ * @param {string} [params.team] - Team key or ID to filter by
140
+ * @param {number} [params.limit] - Maximum results (default: 20)
141
+ * @returns {Promise<{content: Array, details: Object}>}
133
142
  */
134
143
  export async function executeIssueList(client, params) {
135
144
  return withHandlerErrorHandling(async () => {
@@ -142,12 +151,20 @@ export async function executeIssueList(client, params) {
142
151
 
143
152
  let assigneeId = null;
144
153
  if (params.assignee === 'me') {
145
- const viewer = await client.viewer;
154
+ const viewer = await getViewer(client);
146
155
  assigneeId = viewer.id;
147
156
  }
148
157
 
158
+ // Resolve team if provided
159
+ let teamId = null;
160
+ if (params.team) {
161
+ const team = await resolveTeamRef(client, params.team);
162
+ teamId = team.id;
163
+ }
164
+
149
165
  const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
150
166
  assigneeId,
167
+ teamId,
151
168
  limit: params.limit || 20,
152
169
  });
153
170
 
@@ -260,7 +277,7 @@ export async function executeIssueCreate(client, params, options = {}) {
260
277
  }
261
278
 
262
279
  if (params.assignee === 'me') {
263
- const viewer = await client.viewer;
280
+ const viewer = await getViewer(client);
264
281
  createInput.assigneeId = viewer.id;
265
282
  } else if (params.assignee) {
266
283
  createInput.assigneeId = params.assignee;
@@ -358,7 +375,7 @@ export async function executeIssueUpdate(client, params) {
358
375
 
359
376
  // Handle assignee parameter
360
377
  if (params.assignee === 'me') {
361
- const viewer = await client.viewer;
378
+ const viewer = await getViewer(client);
362
379
  updatePatch.assigneeId = viewer.id;
363
380
  } else if (params.assignee) {
364
381
  updatePatch.assigneeId = params.assignee;
@@ -413,8 +430,12 @@ export async function executeIssueUpdate(client, params) {
413
430
  ? ` (${changeSummaryParts.join(', ')})`
414
431
  : '';
415
432
 
433
+ const rateLimitNote = result.usedRateLimitFallback
434
+ ? '\n\n_Note: update succeeded, but detailed issue refresh was rate-limited. Some returned fields may be partial until rate limit resets._'
435
+ : '';
436
+
416
437
  return toTextResult(
417
- `Updated issue ${result.issue.identifier}${suffix}`,
438
+ `Updated issue ${result.issue.identifier}${suffix}${rateLimitNote}`,
418
439
  {
419
440
  issueId: result.issue.id,
420
441
  identifier: result.issue.identifier,
@@ -422,6 +443,7 @@ export async function executeIssueUpdate(client, params) {
422
443
  state: result.issue.state,
423
444
  priority: result.issue.priority,
424
445
  projectMilestone: result.issue.projectMilestone,
446
+ usedRateLimitFallback: !!result.usedRateLimitFallback,
425
447
  }
426
448
  );
427
449
  }
@@ -28,6 +28,10 @@ function getTrackerKey(apiKey) {
28
28
  return apiKey || 'default';
29
29
  }
30
30
 
31
+ function getTrackerKeyFromClient(client) {
32
+ return client?.__piLinearTrackerKey || client?.apiKey || 'default';
33
+ }
34
+
31
35
  function getRequestMetric(trackerKey) {
32
36
  let metric = requestMetrics.get(trackerKey);
33
37
  if (!metric) {
@@ -124,7 +128,7 @@ export function markRateLimited(resetAt) {
124
128
  * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
125
129
  */
126
130
  export function getClientRateLimit(client) {
127
- const trackerData = rateLimitTracker.get(client.apiKey || 'default');
131
+ const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
128
132
  if (trackerData) {
129
133
  return {
130
134
  remaining: trackerData.remaining,
@@ -140,7 +144,7 @@ export function getClientRateLimit(client) {
140
144
  * Expose per-client request counters for diagnostics
141
145
  */
142
146
  export function getClientRequestMetrics(client) {
143
- const key = client?.apiKey || 'default';
147
+ const key = getTrackerKeyFromClient(client);
144
148
  const metric = requestMetrics.get(key);
145
149
  if (!metric) {
146
150
  return {
@@ -227,6 +231,7 @@ export function createLinearClient(auth) {
227
231
  const client = new LinearClient(clientConfig);
228
232
 
229
233
  const trackerKey = getTrackerKey(apiKey);
234
+ client.__piLinearTrackerKey = trackerKey;
230
235
  if (!rateLimitTracker.has(trackerKey)) {
231
236
  rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
232
237
  }
package/src/linear.js CHANGED
@@ -7,6 +7,39 @@
7
7
 
8
8
  import { warn, info, debug } from './logger.js';
9
9
 
10
+ const CACHE_TTL_MS = {
11
+ viewer: 30_000,
12
+ projects: 60_000,
13
+ teams: 60_000,
14
+ teamStates: 60_000,
15
+ };
16
+
17
+ const viewerCache = new Map();
18
+ const projectsCache = new Map();
19
+ const teamsCache = new Map();
20
+ const teamStatesCache = new Map();
21
+
22
+ function getClientCacheKey(client) {
23
+ return client?.apiKey || 'default';
24
+ }
25
+
26
+ function getCache(map, key) {
27
+ const entry = map.get(key);
28
+ if (!entry) return null;
29
+ if (Date.now() > entry.expiresAt) {
30
+ map.delete(key);
31
+ return null;
32
+ }
33
+ return entry.value;
34
+ }
35
+
36
+ function setCache(map, key, value, ttlMs) {
37
+ map.set(key, {
38
+ value,
39
+ expiresAt: Date.now() + ttlMs,
40
+ });
41
+ }
42
+
10
43
  // ===== OPTIMIZED GRAPHQL QUERIES =====
11
44
  // These queries fetch relations upfront to avoid N+1 API calls
12
45
 
@@ -510,15 +543,24 @@ function normalizeIssueRefList(value) {
510
543
  */
511
544
  export async function fetchViewer(client) {
512
545
  return withLinearErrorHandling(async () => {
546
+ const cacheKey = getClientCacheKey(client);
547
+ const cached = getCache(viewerCache, cacheKey);
548
+ if (cached) return cached;
549
+
513
550
  const viewer = await client.viewer;
514
- return {
551
+ const result = {
515
552
  id: viewer.id,
516
553
  name: viewer.name,
517
554
  displayName: viewer.displayName,
518
555
  };
556
+
557
+ setCache(viewerCache, cacheKey, result, CACHE_TTL_MS.viewer);
558
+ return result;
519
559
  }, 'fetchViewer');
520
560
  }
521
561
 
562
+ export const getViewer = fetchViewer;
563
+
522
564
  /**
523
565
  * Fetch issues in specific states, optionally filtered by assignee
524
566
  * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
@@ -580,12 +622,13 @@ export async function fetchIssues(client, assigneeId, openStates, limit) {
580
622
  * @param {Array<string>|null} states - List of state names to include (null = all states)
581
623
  * @param {Object} options
582
624
  * @param {string|null} options.assigneeId - Assignee ID to filter by (null = all assignees)
625
+ * @param {string|null} options.teamId - Team ID to filter by (null = all teams)
583
626
  * @param {number} options.limit - Maximum number of issues to fetch
584
627
  * @returns {Promise<{issues: Array, truncated: boolean}>}
585
628
  */
586
629
  export async function fetchIssuesByProject(client, projectId, states, options = {}) {
587
630
  return withLinearErrorHandling(async () => {
588
- const { assigneeId = null, limit = 20 } = options;
631
+ const { assigneeId = null, teamId = null, limit = 20 } = options;
589
632
 
590
633
  const filter = {
591
634
  project: { id: { eq: projectId } },
@@ -599,6 +642,10 @@ export async function fetchIssuesByProject(client, projectId, states, options =
599
642
  filter.assignee = { id: { eq: assigneeId } };
600
643
  }
601
644
 
645
+ if (teamId) {
646
+ filter.team = { id: { eq: teamId } };
647
+ }
648
+
602
649
  // Use optimized rawRequest to fetch issues with ALL relations in ONE request
603
650
  // This eliminates the N+1 problem where each issue triggered 5 additional API calls
604
651
  const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
@@ -643,6 +690,10 @@ export async function fetchIssuesByProject(client, projectId, states, options =
643
690
  */
644
691
  export async function fetchProjects(client) {
645
692
  return withLinearErrorHandling(async () => {
693
+ const cacheKey = getClientCacheKey(client);
694
+ const cached = getCache(projectsCache, cacheKey);
695
+ if (cached) return cached;
696
+
646
697
  const result = await client.projects();
647
698
  const nodes = result.nodes ?? [];
648
699
 
@@ -651,7 +702,9 @@ export async function fetchProjects(client) {
651
702
  projects: nodes.map((p) => ({ id: p.id, name: p.name })),
652
703
  });
653
704
 
654
- return nodes.map(p => ({ id: p.id, name: p.name }));
705
+ const projects = nodes.map(p => ({ id: p.id, name: p.name }));
706
+ setCache(projectsCache, cacheKey, projects, CACHE_TTL_MS.projects);
707
+ return projects;
655
708
  }, 'fetchProjects');
656
709
  }
657
710
 
@@ -687,6 +740,10 @@ export async function fetchWorkspaces(client) {
687
740
  */
688
741
  export async function fetchTeams(client) {
689
742
  return withLinearErrorHandling(async () => {
743
+ const cacheKey = getClientCacheKey(client);
744
+ const cached = getCache(teamsCache, cacheKey);
745
+ if (cached) return cached;
746
+
690
747
  const result = await client.teams();
691
748
  const nodes = result.nodes ?? [];
692
749
 
@@ -695,7 +752,9 @@ export async function fetchTeams(client) {
695
752
  teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
696
753
  });
697
754
 
698
- return nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
755
+ const teams = nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
756
+ setCache(teamsCache, cacheKey, teams, CACHE_TTL_MS.teams);
757
+ return teams;
699
758
  }, 'fetchTeams');
700
759
  }
701
760
 
@@ -711,10 +770,17 @@ export async function resolveTeamRef(client, teamRef) {
711
770
  throw new Error('Missing team reference');
712
771
  }
713
772
 
714
- const teams = await fetchTeams(client);
715
-
716
- // If it looks like a Linear ID (UUID), try direct lookup first
773
+ // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
717
774
  if (isLinearId(ref)) {
775
+ try {
776
+ const direct = await client.team(ref);
777
+ if (direct) {
778
+ return { id: direct.id, key: direct.key, name: direct.name };
779
+ }
780
+ } catch {
781
+ // fall back to cached/full-team lookup below
782
+ }
783
+
718
784
  const byId = teams.find((t) => t.id === ref);
719
785
  if (byId) {
720
786
  return byId;
@@ -722,6 +788,8 @@ export async function resolveTeamRef(client, teamRef) {
722
788
  throw new Error(`Team not found with ID: ${ref}`);
723
789
  }
724
790
 
791
+ const teams = await fetchTeams(client);
792
+
725
793
  // Try exact key match (e.g., "ENG")
726
794
  const byKey = teams.find((t) => t.key === ref);
727
795
  if (byKey) {
@@ -778,17 +846,24 @@ export async function resolveIssue(client, issueRef) {
778
846
  */
779
847
  export async function getTeamWorkflowStates(client, teamRef) {
780
848
  return withLinearErrorHandling(async () => {
849
+ const cacheKey = `${getClientCacheKey(client)}::${teamRef}`;
850
+ const cached = getCache(teamStatesCache, cacheKey);
851
+ if (cached) return cached;
852
+
781
853
  const team = await client.team(teamRef);
782
854
  if (!team) {
783
855
  throw new Error(`Team not found: ${teamRef}`);
784
856
  }
785
857
 
786
858
  const states = await team.states();
787
- return (states.nodes || []).map(s => ({
859
+ const mapped = (states.nodes || []).map(s => ({
788
860
  id: s.id,
789
861
  name: s.name,
790
862
  type: s.type,
791
863
  }));
864
+
865
+ setCache(teamStatesCache, cacheKey, mapped, CACHE_TTL_MS.teamStates);
866
+ return mapped;
792
867
  }, 'getTeamWorkflowStates');
793
868
  }
794
869
 
@@ -804,17 +879,27 @@ export async function resolveProjectRef(client, projectRef) {
804
879
  throw new Error('Missing project reference');
805
880
  }
806
881
 
807
- const projects = await fetchProjects(client);
808
-
809
- // If it looks like a Linear ID (UUID), try direct lookup first
882
+ // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
810
883
  if (isLinearId(ref)) {
811
- const byId = projects.find((p) => p.id === ref);
884
+ try {
885
+ const direct = await client.project(ref);
886
+ if (direct) {
887
+ return { id: direct.id, name: direct.name };
888
+ }
889
+ } catch {
890
+ // fall back to cached/full-project lookup below
891
+ }
892
+
893
+ const projectsById = await fetchProjects(client);
894
+ const byId = projectsById.find((p) => p.id === ref);
812
895
  if (byId) {
813
896
  return byId;
814
897
  }
815
898
  throw new Error(`Project not found with ID: ${ref}`);
816
899
  }
817
900
 
901
+ const projects = await fetchProjects(client);
902
+
818
903
  // Try exact name match
819
904
  const exactName = projects.find((p) => p.name === ref);
820
905
  if (exactName) {
@@ -1114,6 +1199,47 @@ export async function addIssueComment(client, issueRef, body, parentCommentId) {
1114
1199
  * @param {Object} patch - Fields to update
1115
1200
  * @returns {Promise<{issue: Object, changed: Array<string>}>}
1116
1201
  */
1202
+ function buildFallbackUpdatedIssue(targetIssue, updateInput) {
1203
+ const fallback = { ...(targetIssue || {}) };
1204
+
1205
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'title')) {
1206
+ fallback.title = updateInput.title;
1207
+ }
1208
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'description')) {
1209
+ fallback.description = updateInput.description;
1210
+ }
1211
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'priority')) {
1212
+ fallback.priority = updateInput.priority;
1213
+ }
1214
+
1215
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'assigneeId')) {
1216
+ const assigneeId = updateInput.assigneeId;
1217
+ if (assigneeId === null) {
1218
+ fallback.assignee = null;
1219
+ } else if (typeof assigneeId === 'string' && assigneeId.trim()) {
1220
+ fallback.assignee = {
1221
+ id: assigneeId,
1222
+ name: fallback.assignee?.name || 'Unknown',
1223
+ displayName: fallback.assignee?.displayName || 'Unknown',
1224
+ };
1225
+ }
1226
+ }
1227
+
1228
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'projectMilestoneId')) {
1229
+ const milestoneId = updateInput.projectMilestoneId;
1230
+ if (milestoneId === null) {
1231
+ fallback.projectMilestone = null;
1232
+ } else if (typeof milestoneId === 'string' && milestoneId.trim()) {
1233
+ fallback.projectMilestone = {
1234
+ id: milestoneId,
1235
+ name: fallback.projectMilestone?.name || 'Unknown',
1236
+ };
1237
+ }
1238
+ }
1239
+
1240
+ return fallback;
1241
+ }
1242
+
1117
1243
  export async function updateIssue(client, issueRef, patch = {}) {
1118
1244
  return withLinearErrorHandling(async () => {
1119
1245
  const targetIssue = await resolveIssue(client, issueRef);
@@ -1275,14 +1401,28 @@ export async function updateIssue(client, issueRef, patch = {}) {
1275
1401
  }
1276
1402
 
1277
1403
  // Prefer official data path: refetch the issue after successful mutation.
1278
- let updatedSdkIssue = null;
1404
+ // If this extra fetch is rate-limited, keep operation successful and return fallback data.
1405
+ let updatedIssue = null;
1406
+ let usedRateLimitFallback = false;
1279
1407
  try {
1280
- updatedSdkIssue = await client.issue(targetIssue.id);
1281
- } catch {
1282
- updatedSdkIssue = null;
1283
- }
1408
+ const updatedSdkIssue = await client.issue(targetIssue.id);
1409
+ updatedIssue = await transformIssue(updatedSdkIssue);
1410
+ } catch (refreshError) {
1411
+ const refreshMessage = String(refreshError?.message || refreshError || 'unknown');
1412
+ const isRefreshRateLimited = refreshError?.type === 'Ratelimited' || refreshMessage.toLowerCase().includes('rate limit');
1413
+
1414
+ if (!isRefreshRateLimited) {
1415
+ throw refreshError;
1416
+ }
1284
1417
 
1285
- const updatedIssue = await transformIssue(updatedSdkIssue);
1418
+ debug('updateIssue: post-update refresh was rate-limited; returning fallback issue payload', {
1419
+ issueRef,
1420
+ issueId: targetIssue?.id,
1421
+ });
1422
+
1423
+ usedRateLimitFallback = true;
1424
+ updatedIssue = buildFallbackUpdatedIssue(targetIssue, updateInput);
1425
+ }
1286
1426
 
1287
1427
  const changed = [...Object.keys(updateInput)];
1288
1428
  if (parentOfRefs.length > 0) changed.push('parentOf');
@@ -1294,6 +1434,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
1294
1434
  return {
1295
1435
  issue: updatedIssue || targetIssue,
1296
1436
  changed,
1437
+ usedRateLimitFallback,
1297
1438
  };
1298
1439
  }, 'updateIssue');
1299
1440
  }
package/FUNCTIONALITY.md DELETED
@@ -1,57 +0,0 @@
1
- # pi-linear-tools Functionality
2
-
3
- ## Scope
4
-
5
- `pi-linear-tools` provides Linear SDK functionality as a pi extension package.
6
-
7
- Included:
8
- - extension configuration command (`/linear-tools-config`)
9
- - issue/project/milestone tools powered by `@linear/sdk`
10
- - issue start flow with optional git branch creation/switch
11
- - settings persistence for API key/default team/project team mapping
12
-
13
- Excluded:
14
- - daemon runtime
15
- - polling loop
16
- - systemd service installation/control
17
- - tmux/process session management
18
- - RPC process lifecycle management
19
-
20
- ## Core modules
21
-
22
- - `src/linear-client.js`: Linear SDK client factory
23
- - `src/linear.js`: issue/project/milestone operations and formatting helpers
24
- - `src/settings.js`: settings defaults/validation/load/save
25
- - `src/logger.js`: structured logging
26
- - `extensions/pi-linear-tools.js`: command and tool registration
27
- - `src/cli.js`: optional local CLI for settings operations
28
-
29
- ## Tool behavior notes
30
-
31
- - `LINEAR_API_KEY` is resolved from env first, then settings
32
- - project references may be project name or project ID
33
- - issue references may be identifier (`ABC-123`) or Linear issue ID
34
- - default team resolution order for issue creation:
35
- 1. explicit `team` parameter
36
- 2. project-level configured team
37
- 3. global `defaultTeam`
38
- - `linear_issue` `start` action:
39
- - uses Linear branch name if available
40
- - can create/switch git branch via `pi.exec("git", ...)`
41
-
42
- ## Settings schema
43
-
44
- ```json
45
- {
46
- "schemaVersion": 1,
47
- "linearApiKey": null,
48
- "defaultTeam": null,
49
- "projects": {
50
- "<linear-project-id>": {
51
- "scope": {
52
- "team": "ENG"
53
- }
54
- }
55
- }
56
- }
57
- ```
@@ -1,30 +0,0 @@
1
- # Post-release verification checklist
2
-
3
- ## Registry and install checks
4
-
5
- ```bash
6
- npm view @fink-andreas/pi-linear-tools version
7
- npm install -g @fink-andreas/pi-linear-tools
8
- pi-linear-tools --help
9
- ```
10
-
11
- ## pi package route smoke test
12
-
13
- ```bash
14
- pi install git:github.com/fink-andreas/pi-linear-tools
15
- # then enable extension resource and run
16
- /linear-tools-help
17
- ```
18
-
19
- ## Basic command smoke test
20
-
21
- ```bash
22
- pi-linear-tools project list
23
- pi-linear-tools team list
24
- ```
25
-
26
- ## Closeout
27
-
28
- - Capture any regressions as follow-up Linear issues
29
- - Post release summary to INN-234
30
- - Mark milestone complete
package/RELEASE.md DELETED
@@ -1,50 +0,0 @@
1
- # Release Runbook
2
-
3
- ## npm publish (`v0.1.0`)
4
-
5
- 1. Ensure clean working tree
6
- ```bash
7
- git status
8
- ```
9
- 2. Run pre-publish checks
10
- ```bash
11
- npm run release:check
12
- ```
13
- 3. Verify npm authentication
14
- ```bash
15
- npm whoami
16
- ```
17
- 4. Publish package
18
- ```bash
19
- npm publish --access public
20
- ```
21
- 5. Verify published version
22
- ```bash
23
- npm view @fink-andreas/pi-linear-tools version
24
- ```
25
-
26
- ## Post-publish quick validation
27
-
28
- ```bash
29
- npm install -g @fink-andreas/pi-linear-tools
30
- pi-linear-tools --help
31
- ```
32
-
33
- ## GitHub release
34
-
35
- ```bash
36
- git tag v0.1.0
37
- git push origin v0.1.0
38
- ```
39
-
40
- Create release notes from `RELEASE_NOTES_v0.1.0.md`:
41
-
42
- ```bash
43
- gh release create v0.1.0 --title "v0.1.0" --notes-file RELEASE_NOTES_v0.1.0.md
44
- ```
45
-
46
- Verify release:
47
-
48
- ```bash
49
- gh release view v0.1.0
50
- ```