@fink-andreas/pi-linear-tools 0.2.1 → 0.4.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.
@@ -133,25 +133,23 @@ const refreshManager = new TokenRefreshManager();
133
133
  * @returns {Promise<string>} New access token
134
134
  * @throws {Error} If refresh fails
135
135
  */
136
- export async function refreshTokens(refreshToken) {
136
+ async function refreshTokens(refreshToken) {
137
137
  return refreshManager.refresh(refreshToken);
138
138
  }
139
139
 
140
140
  /**
141
- * Check if a token refresh is currently in progress
141
+ * Check if a token refresh is currently in progress (internal)
142
142
  *
143
143
  * @returns {boolean} True if a refresh is in progress
144
144
  */
145
- export function isRefreshing() {
145
+ function isRefreshing() {
146
146
  return refreshManager.isRefreshInProgress();
147
147
  }
148
148
 
149
149
  /**
150
- * Reset the token refresh manager
151
- *
152
- * This is mainly for testing purposes.
150
+ * Reset the token refresh manager (internal, for testing)
153
151
  */
154
- export function resetRefreshManager() {
152
+ function resetRefreshManager() {
155
153
  refreshManager.reset();
156
154
  }
157
155
 
package/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  executeMilestoneUpdate,
25
25
  executeMilestoneDelete,
26
26
  } from './handlers.js';
27
+ import { withMilestoneScopeHint } from './error-hints.js';
27
28
 
28
29
  // ===== ARGUMENT PARSING =====
29
30
 
@@ -67,19 +68,6 @@ function parseBoolean(value) {
67
68
  return undefined;
68
69
  }
69
70
 
70
- function withMilestoneScopeHint(error) {
71
- const message = String(error?.message || error || 'Unknown error');
72
-
73
- if (/invalid scope/i.test(message) && /write/i.test(message)) {
74
- return new Error(
75
- `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
76
- `Use API key auth: pi-linear-tools config --api-key <key>`
77
- );
78
- }
79
-
80
- return error;
81
- }
82
-
83
71
  // ===== AUTH RESOLUTION =====
84
72
 
85
73
  let cachedApiKey = null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Error hint utilities
3
+ *
4
+ * Provides helpful hints for common error scenarios.
5
+ */
6
+
7
+ /**
8
+ * Wraps errors related to milestone operations with helpful scope hints
9
+ *
10
+ * @param {Error} error - The error to wrap
11
+ * @returns {Error} The original error or a new error with additional hint
12
+ */
13
+ export function withMilestoneScopeHint(error) {
14
+ const message = String(error?.message || error || 'Unknown error');
15
+
16
+ if (/invalid scope/i.test(message) && /write/i.test(message)) {
17
+ return new Error(
18
+ `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
19
+ `Use API key auth for milestone management: /linear-tools-config --api-key <key>`
20
+ );
21
+ }
22
+
23
+ return error;
24
+ }
package/src/handlers.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * All handlers are pure functions that accept a LinearClient and parameters.
6
6
  */
7
7
 
8
- import { createLinearClient } from './linear-client.js';
9
8
  import {
10
9
  prepareIssueStart,
11
10
  setIssueState,
@@ -14,7 +13,6 @@ import {
14
13
  createIssue,
15
14
  fetchProjects,
16
15
  fetchTeams,
17
- fetchWorkspaces,
18
16
  resolveProjectRef,
19
17
  resolveTeamRef,
20
18
  getTeamWorkflowStates,
@@ -27,6 +25,7 @@ import {
27
25
  updateProjectMilestone,
28
26
  deleteProjectMilestone,
29
27
  deleteIssue,
28
+ withHandlerErrorHandling,
30
29
  } from './linear.js';
31
30
  import { debug } from './logger.js';
32
31
 
@@ -73,8 +72,12 @@ async function runGitCommand(args) {
73
72
  * @returns {Promise<boolean>}
74
73
  */
75
74
  async function gitBranchExists(branchName) {
76
- const result = await runGitCommand(['rev-parse', '--verify', branchName]);
77
- return result.code === 0;
75
+ try {
76
+ const result = await runGitCommand(['rev-parse', '--verify', branchName]);
77
+ return result.code === 0;
78
+ } catch {
79
+ return false;
80
+ }
78
81
  }
79
82
 
80
83
  /**
@@ -128,57 +131,59 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
128
131
  * List issues in a project
129
132
  */
130
133
  export async function executeIssueList(client, params) {
131
- let projectRef = params.project;
132
- if (!projectRef) {
133
- projectRef = process.cwd().split('/').pop();
134
- }
135
-
136
- const resolved = await resolveProjectRef(client, projectRef);
134
+ return withHandlerErrorHandling(async () => {
135
+ let projectRef = params.project;
136
+ if (!projectRef) {
137
+ projectRef = process.cwd().split('/').pop();
138
+ }
137
139
 
138
- let assigneeId = null;
139
- if (params.assignee === 'me') {
140
- const viewer = await client.viewer;
141
- assigneeId = viewer.id;
142
- }
140
+ const resolved = await resolveProjectRef(client, projectRef);
143
141
 
144
- const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
145
- assigneeId,
146
- limit: params.limit || 50,
147
- });
142
+ let assigneeId = null;
143
+ if (params.assignee === 'me') {
144
+ const viewer = await client.viewer;
145
+ assigneeId = viewer.id;
146
+ }
148
147
 
149
- if (issues.length === 0) {
150
- return toTextResult(`No issues found in project "${resolved.name}"`, {
151
- projectId: resolved.id,
152
- projectName: resolved.name,
153
- issueCount: 0,
148
+ const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
149
+ assigneeId,
150
+ limit: params.limit || 20,
154
151
  });
155
- }
156
152
 
157
- const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
153
+ if (issues.length === 0) {
154
+ return toTextResult(`No issues found in project "${resolved.name}"`, {
155
+ projectId: resolved.id,
156
+ projectName: resolved.name,
157
+ issueCount: 0,
158
+ });
159
+ }
158
160
 
159
- for (const issue of issues) {
160
- const stateLabel = issue.state?.name || 'Unknown';
161
- const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
162
- const priorityLabel = issue.priority !== undefined && issue.priority !== null
163
- ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
164
- : null;
161
+ const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
165
162
 
166
- const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
167
- if (priorityLabel) metaParts.push(priorityLabel);
163
+ for (const issue of issues) {
164
+ const stateLabel = issue.state?.name || 'Unknown';
165
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
166
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
167
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
168
+ : null;
168
169
 
169
- lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
170
- }
170
+ const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
171
+ if (priorityLabel) metaParts.push(priorityLabel);
171
172
 
172
- if (truncated) {
173
- lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
174
- }
173
+ lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
174
+ }
175
175
 
176
- return toTextResult(lines.join('\n'), {
177
- projectId: resolved.id,
178
- projectName: resolved.name,
179
- issueCount: issues.length,
180
- truncated,
181
- });
176
+ if (truncated) {
177
+ lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
178
+ }
179
+
180
+ return toTextResult(lines.join('\n'), {
181
+ projectId: resolved.id,
182
+ projectName: resolved.name,
183
+ issueCount: issues.length,
184
+ truncated,
185
+ });
186
+ }, 'executeIssueList');
182
187
  }
183
188
 
184
189
  /**
@@ -6,13 +6,185 @@
6
6
  */
7
7
 
8
8
  import { LinearClient } from '@linear/sdk';
9
- import { debug, warn, error as logError } from './logger.js';
9
+ import { debug, warn, info } from './logger.js';
10
10
 
11
11
  /** @type {Function|null} Test-only client factory override */
12
12
  let _testClientFactory = null;
13
13
 
14
+ /** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
15
+ const rateLimitTracker = new Map();
16
+
17
+ /** @type {Map<string, {total: number, success: number, failed: number, rateLimited: number, windowStart: number, lastSummaryAt: number}>} */
18
+ const requestMetrics = new Map();
19
+
20
+ /** Track globally if we've detected a rate limit error */
21
+ let globalRateLimited = false;
22
+ let globalRateLimitResetAt = null;
23
+
24
+ const REQUEST_SUMMARY_INTERVAL = 50;
25
+ const REQUEST_SUMMARY_MIN_MS = 15000;
26
+
27
+ function getTrackerKey(apiKey) {
28
+ return apiKey || 'default';
29
+ }
30
+
31
+ function getRequestMetric(trackerKey) {
32
+ let metric = requestMetrics.get(trackerKey);
33
+ if (!metric) {
34
+ metric = {
35
+ total: 0,
36
+ success: 0,
37
+ failed: 0,
38
+ rateLimited: 0,
39
+ windowStart: Date.now(),
40
+ lastSummaryAt: 0,
41
+ };
42
+ requestMetrics.set(trackerKey, metric);
43
+ }
44
+ return metric;
45
+ }
46
+
47
+ function maybeLogRequestSummary(trackerKey) {
48
+ const metric = requestMetrics.get(trackerKey);
49
+ const tracker = rateLimitTracker.get(trackerKey);
50
+ if (!metric || !tracker) return;
51
+
52
+ const now = Date.now();
53
+ const shouldLogByCount = metric.total % REQUEST_SUMMARY_INTERVAL === 0;
54
+ const shouldLogByTime = now - metric.lastSummaryAt >= REQUEST_SUMMARY_MIN_MS;
55
+
56
+ if (!shouldLogByCount && !shouldLogByTime) return;
57
+
58
+ metric.lastSummaryAt = now;
59
+ const used = Math.max(0, 5000 - (tracker.remaining ?? 5000));
60
+
61
+ info('[pi-linear-tools] Linear API usage summary', {
62
+ trackerKey,
63
+ requestsTotal: metric.total,
64
+ requestsSuccess: metric.success,
65
+ requestsFailed: metric.failed,
66
+ requestsRateLimited: metric.rateLimited,
67
+ requestsRemaining: tracker.remaining,
68
+ requestsUsed: used,
69
+ resetAt: tracker.resetAt,
70
+ resetTime: tracker.resetAt ? new Date(tracker.resetAt).toLocaleTimeString() : null,
71
+ });
72
+ }
73
+
74
+ function extractOperationName(query) {
75
+ if (!query || typeof query !== 'string') return 'unknown';
76
+ const compact = query.replace(/\s+/g, ' ').trim();
77
+ const match = compact.match(/^(query|mutation)\s+([a-zA-Z0-9_]+)/i);
78
+ return match?.[2] || 'anonymous';
79
+ }
80
+
81
+ /**
82
+ * Clear rate limit state if the window has expired
83
+ * @returns {{isRateLimited: boolean, resetAt: Date|null}}
84
+ */
85
+ export function checkAndClearRateLimit() {
86
+ if (!globalRateLimited || !globalRateLimitResetAt) {
87
+ return { isRateLimited: false, resetAt: null };
88
+ }
89
+
90
+ // Check if rate limit window has passed
91
+ if (Date.now() >= globalRateLimitResetAt) {
92
+ globalRateLimited = false;
93
+ globalRateLimitResetAt = null;
94
+ return { isRateLimited: false, resetAt: null };
95
+ }
96
+
97
+ return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
98
+ }
99
+
100
+ /**
101
+ * @deprecated Use checkAndClearRateLimit() instead
102
+ * @returns {{isRateLimited: boolean, resetAt: Date|null}}
103
+ */
104
+ export function isGloballyRateLimited() {
105
+ return checkAndClearRateLimit();
106
+ }
107
+
108
+ /**
109
+ * Mark that we've hit the rate limit
110
+ * @param {number} resetAt - Reset timestamp in milliseconds
111
+ */
112
+ export function markRateLimited(resetAt) {
113
+ globalRateLimited = true;
114
+ globalRateLimitResetAt = resetAt;
115
+ warn('[pi-linear-tools] Rate limit hit - will skip API calls until reset', {
116
+ resetAt: new Date(resetAt).toLocaleTimeString(),
117
+ });
118
+ }
119
+
14
120
  /**
15
- * Create a Linear SDK client
121
+ * Extract rate limit info from SDK client response
122
+ * The Linear SDK stores response metadata on the client after requests
123
+ * @param {LinearClient} client - Linear SDK client
124
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
125
+ */
126
+ export function getClientRateLimit(client) {
127
+ const trackerData = rateLimitTracker.get(client.apiKey || 'default');
128
+ if (trackerData) {
129
+ return {
130
+ remaining: trackerData.remaining,
131
+ resetAt: trackerData.resetAt,
132
+ resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
133
+ };
134
+ }
135
+
136
+ return { remaining: null, resetAt: null, resetTime: null };
137
+ }
138
+
139
+ /**
140
+ * Expose per-client request counters for diagnostics
141
+ */
142
+ export function getClientRequestMetrics(client) {
143
+ const key = client?.apiKey || 'default';
144
+ const metric = requestMetrics.get(key);
145
+ if (!metric) {
146
+ return {
147
+ total: 0,
148
+ success: 0,
149
+ failed: 0,
150
+ rateLimited: 0,
151
+ windowStart: null,
152
+ };
153
+ }
154
+
155
+ return {
156
+ total: metric.total,
157
+ success: metric.success,
158
+ failed: metric.failed,
159
+ rateLimited: metric.rateLimited,
160
+ windowStart: metric.windowStart,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Check and warn about low rate limit for a client
166
+ * Call this after making API requests to check if limits are getting low
167
+ * @param {LinearClient} client - Linear SDK client
168
+ * @returns {boolean} True if warning was issued
169
+ */
170
+ export function checkAndWarnRateLimit(client) {
171
+ const { remaining, resetTime } = getClientRateLimit(client);
172
+
173
+ if (remaining !== null && remaining <= 500) {
174
+ const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
175
+ warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
176
+ remaining,
177
+ resetTime,
178
+ usagePercent,
179
+ });
180
+ return true;
181
+ }
182
+
183
+ return false;
184
+ }
185
+
186
+ /**
187
+ * Create a Linear SDK client with rate limit tracking
16
188
  *
17
189
  * Supports two authentication methods:
18
190
  * 1. API Key: Pass as string or { apiKey: '...' }
@@ -30,31 +202,100 @@ export function createLinearClient(auth) {
30
202
  }
31
203
 
32
204
  let clientConfig;
205
+ let apiKey = null;
33
206
 
34
207
  // Handle different input formats
35
208
  if (typeof auth === 'string') {
36
- // Legacy: API key passed as string
37
209
  clientConfig = { apiKey: auth };
210
+ apiKey = auth;
38
211
  } else if (typeof auth === 'object' && auth !== null) {
39
- // Object format: { apiKey: '...' } or { accessToken: '...' }
40
212
  if (auth.accessToken) {
41
213
  clientConfig = { apiKey: auth.accessToken };
214
+ apiKey = auth.accessToken;
42
215
  debug('Creating Linear client with OAuth access token');
43
216
  } else if (auth.apiKey) {
44
217
  clientConfig = { apiKey: auth.apiKey };
218
+ apiKey = auth.apiKey;
45
219
  debug('Creating Linear client with API key');
46
220
  } else {
47
- throw new Error(
48
- 'Auth object must contain either apiKey or accessToken'
49
- );
221
+ throw new Error('Auth object must contain either apiKey or accessToken');
50
222
  }
51
223
  } else {
52
- throw new Error(
53
- 'Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken'
54
- );
224
+ throw new Error('Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken');
225
+ }
226
+
227
+ const client = new LinearClient(clientConfig);
228
+
229
+ const trackerKey = getTrackerKey(apiKey);
230
+ if (!rateLimitTracker.has(trackerKey)) {
231
+ rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
55
232
  }
233
+ getRequestMetric(trackerKey);
234
+
235
+ // Wrap internal rawRequest to capture request counts + rate limit metadata
236
+ const originalRawRequest = client.client.rawRequest.bind(client.client);
237
+ client.client.rawRequest = async function wrappedRawRequest(query, variables, requestHeaders) {
238
+ const metric = getRequestMetric(trackerKey);
239
+ metric.total += 1;
240
+
241
+ debug('[pi-linear-tools] Linear request', {
242
+ trackerKey,
243
+ requestNumber: metric.total,
244
+ operation: extractOperationName(query),
245
+ });
246
+
247
+ try {
248
+ const response = await originalRawRequest(query, variables, requestHeaders);
249
+ metric.success += 1;
250
+
251
+ if (response.headers) {
252
+ const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
253
+ const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
254
+
255
+ const tracker = rateLimitTracker.get(trackerKey);
256
+ if (tracker && remaining !== null) {
257
+ tracker.remaining = parseInt(remaining, 10);
258
+ }
259
+ if (tracker && resetAt !== null) {
260
+ tracker.resetAt = parseInt(resetAt, 10);
261
+ }
262
+ }
263
+
264
+ const tracker = rateLimitTracker.get(trackerKey);
265
+ if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
266
+ const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
267
+ warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
268
+ remaining: tracker.remaining,
269
+ resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
270
+ usagePercent,
271
+ });
272
+ }
273
+
274
+ maybeLogRequestSummary(trackerKey);
275
+ return response;
276
+ } catch (error) {
277
+ metric.failed += 1;
278
+
279
+ const message = String(error?.message || error || 'unknown');
280
+ const isRateLimited = error?.type === 'Ratelimited' || message.toLowerCase().includes('rate limit');
281
+
282
+ if (isRateLimited) {
283
+ metric.rateLimited += 1;
284
+ const resetAt = Number(error?.requestsResetAt) || Date.now() + 3600000;
285
+ const remaining = Number.isFinite(error?.requestsRemaining) ? error.requestsRemaining : 0;
286
+ rateLimitTracker.set(trackerKey, {
287
+ remaining,
288
+ resetAt,
289
+ });
290
+ markRateLimited(resetAt);
291
+ }
292
+
293
+ maybeLogRequestSummary(trackerKey);
294
+ throw error;
295
+ }
296
+ };
56
297
 
57
- return new LinearClient(clientConfig);
298
+ return client;
58
299
  }
59
300
 
60
301
  /**