@fink-andreas/pi-linear-tools 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
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",
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
  },
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "pi": {
42
42
  "extensions": [
43
- "./extensions"
43
+ "./index.js"
44
44
  ]
45
45
  },
46
46
  "license": "MIT",
@@ -73,7 +73,7 @@ async function writeTokensToFile(tokenData) {
73
73
  await mkdir(parentDir, { recursive: true, mode: 0o700 });
74
74
  await writeFile(tokenFilePath, tokenData, { encoding: 'utf-8', mode: 0o600 });
75
75
 
76
- warn('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
76
+ debug('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
77
77
  path: tokenFilePath,
78
78
  });
79
79
  }
@@ -94,7 +94,7 @@ async function isKeytarAvailable() {
94
94
  debug('keytar module loaded successfully');
95
95
  return true;
96
96
  } catch (error) {
97
- warn('keytar module not available, using fallback storage', { error: error.message });
97
+ debug('keytar module not available, using fallback storage', { error: error.message });
98
98
  keytarModule = false;
99
99
  return false;
100
100
  }
@@ -153,7 +153,7 @@ export async function storeTokens(tokens) {
153
153
  // Clean up fallback file if keychain works again
154
154
  await unlink(getTokenFilePath()).catch(() => {});
155
155
  } catch (error) {
156
- warn('Failed to store tokens in keychain, falling back to file storage', {
156
+ debug('Failed to store tokens in keychain, falling back to file storage', {
157
157
  error: error.message,
158
158
  });
159
159
  await writeTokensToFile(tokenData);
@@ -235,7 +235,7 @@ export async function getTokens() {
235
235
 
236
236
  debug('No tokens found in keychain');
237
237
  } catch (error) {
238
- warn('Failed to retrieve tokens from keychain, trying fallback storage', {
238
+ debug('Failed to retrieve tokens from keychain, trying fallback storage', {
239
239
  error: error.message,
240
240
  });
241
241
  }
@@ -277,7 +277,7 @@ export async function clearTokens() {
277
277
  const message = String(error?.message || 'Unknown error');
278
278
  // Keychain providers (e.g. DBus Secret Service) may be unavailable at runtime.
279
279
  // Clearing is best-effort and we still clear fallback file/in-memory tokens below.
280
- warn('Skipping keychain token clear; keychain backend unavailable', {
280
+ debug('Skipping keychain token clear; keychain backend unavailable', {
281
281
  error: message,
282
282
  });
283
283
  if (/org\.freedesktop\.secrets/i.test(message)) {
package/src/cli.js CHANGED
@@ -143,15 +143,17 @@ Usage:
143
143
  pi-linear-tools <command> [options]
144
144
 
145
145
  Commands:
146
+ issue <action> [options] Manage issues
147
+ project <action> [options] Manage projects
148
+ team <action> [options] Manage teams
149
+ milestone <action> [options] Manage milestones
150
+
151
+ Other commands:
146
152
  help Show this help message
147
153
  auth <action> Manage authentication (OAuth 2.0)
148
154
  config Show current configuration
149
155
  config --api-key <key> Set Linear API key (legacy)
150
156
  config --default-team <key> Set default team
151
- issue <action> [options] Manage issues
152
- project <action> [options] Manage projects
153
- team <action> [options] Manage teams
154
- milestone <action> [options] Manage milestones
155
157
 
156
158
  Auth Actions:
157
159
  login Authenticate with Linear via OAuth 2.0
@@ -165,7 +167,7 @@ Issue Actions:
165
167
  update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
166
168
  [--assignee me|ID] [--milestone X] [--sub-issue-of X]
167
169
  comment <issue> --body X
168
- start <issue> [--branch X] [--from-ref X] [--on-branch-exists switch|suffix]
170
+ start <issue> [--from-ref X] [--on-branch-exists switch|suffix]
169
171
  delete <issue>
170
172
 
171
173
  Project Actions:
@@ -201,10 +203,10 @@ Examples:
201
203
  pi-linear-tools config --api-key lin_xxx
202
204
 
203
205
  Authentication:
204
- OAuth 2.0 is the recommended authentication method.
205
- Run 'pi-linear-tools auth login' to authenticate.
206
- For CI/headless environments, set environment variables:
207
- LINEAR_ACCESS_TOKEN, LINEAR_REFRESH_TOKEN, LINEAR_EXPIRES_AT
206
+ API key is the recommended authentication method (supports milestones).
207
+ Run 'pi-linear-tools config --api-key <key>' to authenticate.
208
+ For CI/headless environments, set the LINEAR_API_KEY environment variable.
209
+ OAuth 2.0 is also available via 'pi-linear-tools auth login'.
208
210
  `);
209
211
  }
210
212
 
@@ -258,7 +260,6 @@ Comment Options:
258
260
 
259
261
  Start Options:
260
262
  <issue> Issue key or ID
261
- --branch X Custom branch name (default: issue's branch name)
262
263
  --from-ref X Git ref to branch from (default: HEAD)
263
264
  --on-branch-exists X "switch" or "suffix" (default: switch)
264
265
 
@@ -642,7 +643,6 @@ async function handleIssueStart(args) {
642
643
 
643
644
  const params = {
644
645
  issue: positional[0],
645
- branch: readFlag(args, '--branch'),
646
646
  fromRef: readFlag(args, '--from-ref'),
647
647
  onBranchExists: readFlag(args, '--on-branch-exists'),
648
648
  };
package/src/handlers.js CHANGED
@@ -27,8 +27,9 @@ import {
27
27
  updateProjectMilestone,
28
28
  deleteProjectMilestone,
29
29
  deleteIssue,
30
+ withHandlerErrorHandling,
30
31
  } from './linear.js';
31
- import { debug } from './logger.js';
32
+ import { debug, warn } from './logger.js';
32
33
 
33
34
  function toTextResult(text, details = {}) {
34
35
  return {
@@ -128,57 +129,59 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
128
129
  * List issues in a project
129
130
  */
130
131
  export async function executeIssueList(client, params) {
131
- let projectRef = params.project;
132
- if (!projectRef) {
133
- projectRef = process.cwd().split('/').pop();
134
- }
132
+ return withHandlerErrorHandling(async () => {
133
+ let projectRef = params.project;
134
+ if (!projectRef) {
135
+ projectRef = process.cwd().split('/').pop();
136
+ }
135
137
 
136
- const resolved = await resolveProjectRef(client, projectRef);
138
+ const resolved = await resolveProjectRef(client, projectRef);
137
139
 
138
- let assigneeId = null;
139
- if (params.assignee === 'me') {
140
- const viewer = await client.viewer;
141
- assigneeId = viewer.id;
142
- }
143
-
144
- const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
145
- assigneeId,
146
- limit: params.limit || 50,
147
- });
140
+ let assigneeId = null;
141
+ if (params.assignee === 'me') {
142
+ const viewer = await client.viewer;
143
+ assigneeId = viewer.id;
144
+ }
148
145
 
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,
146
+ const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
147
+ assigneeId,
148
+ limit: params.limit || 20,
154
149
  });
155
- }
156
150
 
157
- const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
151
+ if (issues.length === 0) {
152
+ return toTextResult(`No issues found in project "${resolved.name}"`, {
153
+ projectId: resolved.id,
154
+ projectName: resolved.name,
155
+ issueCount: 0,
156
+ });
157
+ }
158
158
 
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;
159
+ const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
165
160
 
166
- const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
167
- if (priorityLabel) metaParts.push(priorityLabel);
161
+ for (const issue of issues) {
162
+ const stateLabel = issue.state?.name || 'Unknown';
163
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
164
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
165
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
166
+ : null;
168
167
 
169
- lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
170
- }
168
+ const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
169
+ if (priorityLabel) metaParts.push(priorityLabel);
171
170
 
172
- if (truncated) {
173
- lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
174
- }
171
+ lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
172
+ }
175
173
 
176
- return toTextResult(lines.join('\n'), {
177
- projectId: resolved.id,
178
- projectName: resolved.name,
179
- issueCount: issues.length,
180
- truncated,
181
- });
174
+ if (truncated) {
175
+ lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
176
+ }
177
+
178
+ return toTextResult(lines.join('\n'), {
179
+ projectId: resolved.id,
180
+ projectName: resolved.name,
181
+ issueCount: issues.length,
182
+ truncated,
183
+ });
184
+ }, 'executeIssueList');
182
185
  }
183
186
 
184
187
  /**
@@ -447,28 +450,31 @@ export async function executeIssueStart(client, params, options = {}) {
447
450
  const issue = ensureNonEmpty(params.issue, 'issue');
448
451
  const prepared = await prepareIssueStart(client, issue);
449
452
 
450
- const desiredBranch = params.branch || prepared.branchName;
451
- if (!desiredBranch) {
453
+ // Always use Linear's suggested branchName - it cannot be changed via API
454
+ // and using a custom branch would break Linear's branch-to-issue linking
455
+ const branchName = prepared.branchName;
456
+ if (!branchName) {
452
457
  throw new Error(
453
- `No branch name resolved for issue ${prepared.issue.identifier}. Provide the 'branch' parameter explicitly.`
458
+ `No branch name available for issue ${prepared.issue.identifier}. The issue may not have a team assigned.`
454
459
  );
455
460
  }
456
461
 
457
462
  let gitResult;
458
463
  if (gitExecutor) {
459
464
  // Use provided git executor (e.g., pi.exec)
460
- gitResult = await gitExecutor(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
465
+ gitResult = await gitExecutor(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
461
466
  } else {
462
467
  // Use built-in child_process git operations
463
- gitResult = await startGitBranch(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
468
+ gitResult = await startGitBranch(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
464
469
  }
465
470
 
466
471
  const updatedIssue = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
467
472
 
473
+ const identifier = updatedIssue.identifier || prepared.issue.identifier;
468
474
  const compactTitle = String(updatedIssue.title || prepared.issue?.title || '').trim().toLowerCase();
469
475
  const summary = compactTitle
470
- ? `Started issue ${updatedIssue.identifier} (${compactTitle})`
471
- : `Started issue ${updatedIssue.identifier}`;
476
+ ? `Started issue ${identifier} (${compactTitle})`
477
+ : `Started issue ${identifier}`;
472
478
 
473
479
  return toTextResult(summary, {
474
480
  issueId: updatedIssue.id,
@@ -731,11 +737,11 @@ export async function executeMilestoneCreate(client, params) {
731
737
  export async function executeMilestoneUpdate(client, params) {
732
738
  const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
733
739
 
740
+ // Note: status is not included as it's a computed/read-only field in Linear's API
734
741
  const result = await updateProjectMilestone(client, milestoneId, {
735
742
  name: params.name,
736
743
  description: params.description,
737
744
  targetDate: params.targetDate,
738
- status: params.status,
739
745
  });
740
746
 
741
747
  const friendlyChanges = result.changed;
@@ -11,8 +11,88 @@ import { debug, warn, error as logError } from './logger.js';
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
+ /** Track globally if we've detected a rate limit error */
18
+ let globalRateLimited = false;
19
+ let globalRateLimitResetAt = null;
20
+
21
+ /**
22
+ * Check if we know we're rate limited and should skip API calls
23
+ * @returns {{isRateLimited: boolean, resetAt: Date|null}}
24
+ */
25
+ export function isGloballyRateLimited() {
26
+ if (!globalRateLimited || !globalRateLimitResetAt) {
27
+ return { isRateLimited: false, resetAt: null };
28
+ }
29
+
30
+ // Check if rate limit window has passed
31
+ if (Date.now() >= globalRateLimitResetAt) {
32
+ globalRateLimited = false;
33
+ globalRateLimitResetAt = null;
34
+ return { isRateLimited: false, resetAt: null };
35
+ }
36
+
37
+ return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
38
+ }
39
+
14
40
  /**
15
- * Create a Linear SDK client
41
+ * Mark that we've hit the rate limit
42
+ * @param {number} resetAt - Reset timestamp in milliseconds
43
+ */
44
+ export function markRateLimited(resetAt) {
45
+ globalRateLimited = true;
46
+ globalRateLimitResetAt = resetAt;
47
+ warn('[pi-linear-tools] Rate limit hit - will skip API calls until reset', {
48
+ resetAt: new Date(resetAt).toLocaleTimeString(),
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Extract rate limit info from SDK client response
54
+ * The Linear SDK stores response metadata on the client after requests
55
+ * @param {LinearClient} client - Linear SDK client
56
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
57
+ */
58
+ export function getClientRateLimit(client) {
59
+ // Try to get from tracker first
60
+ const trackerData = rateLimitTracker.get(client.apiKey || 'default');
61
+ if (trackerData) {
62
+ return {
63
+ remaining: trackerData.remaining,
64
+ resetAt: trackerData.resetAt,
65
+ resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
66
+ };
67
+ }
68
+
69
+ return { remaining: null, resetAt: null, resetTime: null };
70
+ }
71
+
72
+ /**
73
+ * Check and warn about low rate limit for a client
74
+ * Call this after making API requests to check if limits are getting low
75
+ * @param {LinearClient} client - Linear SDK client
76
+ * @returns {boolean} True if warning was issued
77
+ */
78
+ export function checkAndWarnRateLimit(client) {
79
+ const { remaining, resetTime } = getClientRateLimit(client);
80
+
81
+ if (remaining !== null && remaining <= 500) {
82
+ const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
83
+ warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
84
+ remaining,
85
+ resetTime,
86
+ usagePercent,
87
+ });
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Create a Linear SDK client with rate limit tracking
16
96
  *
17
97
  * Supports two authentication methods:
18
98
  * 1. API Key: Pass as string or { apiKey: '...' }
@@ -30,18 +110,22 @@ export function createLinearClient(auth) {
30
110
  }
31
111
 
32
112
  let clientConfig;
113
+ let apiKey = null;
33
114
 
34
115
  // Handle different input formats
35
116
  if (typeof auth === 'string') {
36
117
  // Legacy: API key passed as string
37
118
  clientConfig = { apiKey: auth };
119
+ apiKey = auth;
38
120
  } else if (typeof auth === 'object' && auth !== null) {
39
121
  // Object format: { apiKey: '...' } or { accessToken: '...' }
40
122
  if (auth.accessToken) {
41
123
  clientConfig = { apiKey: auth.accessToken };
124
+ apiKey = auth.accessToken;
42
125
  debug('Creating Linear client with OAuth access token');
43
126
  } else if (auth.apiKey) {
44
127
  clientConfig = { apiKey: auth.apiKey };
128
+ apiKey = auth.apiKey;
45
129
  debug('Creating Linear client with API key');
46
130
  } else {
47
131
  throw new Error(
@@ -54,7 +138,54 @@ export function createLinearClient(auth) {
54
138
  );
55
139
  }
56
140
 
57
- return new LinearClient(clientConfig);
141
+ const client = new LinearClient(clientConfig);
142
+
143
+ // Initialize rate limit tracking for this client
144
+ const trackerKey = apiKey || 'default';
145
+ if (!rateLimitTracker.has(trackerKey)) {
146
+ rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
147
+ }
148
+
149
+ // Wrap the rawRequest to capture rate limit headers
150
+ // rawRequest is on client.client (internal GraphQL client)
151
+ const originalRawRequest = client.client.rawRequest.bind(client.client);
152
+ client.client.rawRequest = async function wrappedRawRequest(query, variables, requestHeaders) {
153
+ const response = await originalRawRequest(query, variables, requestHeaders);
154
+
155
+ // Extract rate limit headers from response
156
+ if (response.headers) {
157
+ const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
158
+ const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
159
+
160
+ if (remaining !== null) {
161
+ const tracker = rateLimitTracker.get(trackerKey);
162
+ if (tracker) {
163
+ tracker.remaining = parseInt(remaining, 10);
164
+ }
165
+ }
166
+ if (resetAt !== null) {
167
+ const tracker = rateLimitTracker.get(trackerKey);
168
+ if (tracker) {
169
+ tracker.resetAt = parseInt(resetAt, 10);
170
+ }
171
+ }
172
+ }
173
+
174
+ // Check if we should warn about low rate limits
175
+ const tracker = rateLimitTracker.get(trackerKey);
176
+ if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
177
+ const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
178
+ warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
179
+ remaining: tracker.remaining,
180
+ resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
181
+ usagePercent,
182
+ });
183
+ }
184
+
185
+ return response;
186
+ };
187
+
188
+ return client;
58
189
  }
59
190
 
60
191
  /**