@fink-andreas/pi-linear-tools 0.6.0 → 0.7.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/src/handlers.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import path from 'path';
9
+ import { mkdir, open, unlink } from 'node:fs/promises';
9
10
  import {
10
11
  prepareIssueStart,
11
12
  setIssueState,
@@ -20,9 +21,11 @@ import {
20
21
  resolveMilestoneRef,
21
22
  getTeamWorkflowStates,
22
23
  fetchIssueDetails,
24
+ fetchIssueImages,
23
25
  fetchIssueActivity,
24
26
  formatIssueAsMarkdown,
25
27
  formatIssueActivityAsMarkdown,
28
+ fetchIssues,
26
29
  fetchIssuesByProject,
27
30
  fetchProjectMilestones,
28
31
  fetchMilestoneDetails,
@@ -53,6 +56,20 @@ function toTextResult(text, details = {}) {
53
56
  };
54
57
  }
55
58
 
59
+ const COMMENT_PREVIEW_LIMIT = 500;
60
+
61
+ function formatCommentPreview(body, limit = COMMENT_PREVIEW_LIMIT) {
62
+ const text = String(body || '').trim();
63
+ if (text.length <= limit) {
64
+ return { text, truncated: false };
65
+ }
66
+
67
+ return {
68
+ text: `${text.slice(0, limit).trimEnd()}...`,
69
+ truncated: true,
70
+ };
71
+ }
72
+
56
73
  function ensureNonEmpty(value, fieldName) {
57
74
  const text = String(value || '').trim();
58
75
  if (!text) throw new Error(`Missing required field: ${fieldName}`);
@@ -70,6 +87,210 @@ function parseRefList(value) {
70
87
  .filter(Boolean);
71
88
  }
72
89
 
90
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
91
+ const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
92
+
93
+ function hasValue(value) {
94
+ return value !== undefined && value !== null && String(value).trim() !== '';
95
+ }
96
+
97
+ function resolveSafeRelativeDirectory(directory, cwd = process.cwd()) {
98
+ const requested = ensureNonEmpty(directory, 'directory');
99
+ if (path.isAbsolute(requested)) {
100
+ throw new Error('Download directory must be a relative path');
101
+ }
102
+
103
+ const resolvedCwd = path.resolve(cwd);
104
+ const resolvedDirectory = path.resolve(resolvedCwd, requested);
105
+ const relative = path.relative(resolvedCwd, resolvedDirectory);
106
+
107
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
108
+ throw new Error('Download directory must stay within the current working directory');
109
+ }
110
+
111
+ return resolvedDirectory;
112
+ }
113
+
114
+ function sanitizeDownloadFilename(value) {
115
+ const text = String(value || '').trim();
116
+ if (!text) return '';
117
+
118
+ const basename = path.basename(text) || text;
119
+ const sanitized = basename
120
+ .replace(/[\\/\u0000-\u001f\u007f<>:"|?*]+/g, '_')
121
+ .replace(/^\.+$/, '')
122
+ .replace(/^\.+/, '')
123
+ .trim()
124
+ .slice(0, 180);
125
+
126
+ return sanitized || '';
127
+ }
128
+
129
+ function filenameFromAttachment(attachment, explicitFilename) {
130
+ const explicit = sanitizeDownloadFilename(explicitFilename);
131
+ if (explicit) return explicit;
132
+
133
+ const fromTitle = sanitizeDownloadFilename(attachment?.title);
134
+ if (fromTitle) return fromTitle;
135
+
136
+ try {
137
+ const url = new URL(attachment?.url || '');
138
+ const fromUrl = sanitizeDownloadFilename(url.pathname);
139
+ if (fromUrl) return fromUrl;
140
+ } catch {
141
+ // fall through to attachment id
142
+ }
143
+
144
+ const fromId = sanitizeDownloadFilename(attachment?.id);
145
+ if (fromId) return fromId;
146
+
147
+ throw new Error('Unable to derive a safe filename for attachment; provide filename explicitly');
148
+ }
149
+
150
+ function resolveSafeDestinationPath(directory, filename, cwd = process.cwd()) {
151
+ const resolvedDirectory = resolveSafeRelativeDirectory(directory, cwd);
152
+ const safeFilename = filenameFromAttachment({}, filename);
153
+ const destination = path.resolve(resolvedDirectory, safeFilename);
154
+ const relative = path.relative(resolvedDirectory, destination);
155
+
156
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
157
+ throw new Error('Download filename must not escape the destination directory');
158
+ }
159
+
160
+ return { directory: resolvedDirectory, filename: safeFilename, filePath: destination };
161
+ }
162
+
163
+ function selectIssueAttachment(attachments, params) {
164
+ const list = Array.isArray(attachments) ? attachments : [];
165
+ if (list.length === 0) {
166
+ throw new Error('Issue has no Linear attachments to download');
167
+ }
168
+
169
+ const selectors = [
170
+ ['attachmentId', params.attachmentId],
171
+ ['attachmentTitle', params.attachmentTitle],
172
+ ['attachmentUrl', params.attachmentUrl],
173
+ ['attachmentIndex', params.attachmentIndex],
174
+ ].filter(([, value]) => hasValue(value));
175
+
176
+ if (selectors.length > 1) {
177
+ throw new Error('Provide only one attachment selector: attachmentId, attachmentTitle, attachmentUrl, or attachmentIndex');
178
+ }
179
+
180
+ if (selectors.length === 0) {
181
+ if (list.length === 1) return list[0];
182
+ throw new Error('Multiple attachments found; provide attachmentId, attachmentTitle, attachmentUrl, or attachmentIndex');
183
+ }
184
+
185
+ const [selector, rawValue] = selectors[0];
186
+ const value = String(rawValue).trim();
187
+ let matches = [];
188
+
189
+ if (selector === 'attachmentId') {
190
+ matches = list.filter((attachment) => attachment.id === value);
191
+ } else if (selector === 'attachmentTitle') {
192
+ const normalized = value.toLowerCase();
193
+ matches = list.filter((attachment) => String(attachment.title || '').toLowerCase() === normalized);
194
+ } else if (selector === 'attachmentUrl') {
195
+ matches = list.filter((attachment) => attachment.url === value);
196
+ } else if (selector === 'attachmentIndex') {
197
+ const index = Number.parseInt(value, 10);
198
+ if (!Number.isInteger(index) || index < 1 || index > list.length) {
199
+ throw new Error(`attachmentIndex must be between 1 and ${list.length}`);
200
+ }
201
+ return list[index - 1];
202
+ }
203
+
204
+ if (matches.length === 0) {
205
+ throw new Error(`No attachment matched ${selector}: ${value}`);
206
+ }
207
+ if (matches.length > 1) {
208
+ throw new Error(`Multiple attachments matched ${selector}: ${value}; use attachmentId instead`);
209
+ }
210
+ return matches[0];
211
+ }
212
+
213
+ function normalizeMaxBytes(value) {
214
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_DOWNLOAD_BYTES;
215
+ const maxBytes = Number(value);
216
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
217
+ throw new Error('maxBytes must be a positive integer');
218
+ }
219
+ if (maxBytes > DEFAULT_MAX_DOWNLOAD_BYTES) {
220
+ throw new Error(`maxBytes cannot exceed ${DEFAULT_MAX_DOWNLOAD_BYTES} bytes`);
221
+ }
222
+ return maxBytes;
223
+ }
224
+
225
+ function normalizeImageMaxBytes(value) {
226
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_IMAGE_BYTES;
227
+ const maxBytes = Number(value);
228
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
229
+ throw new Error('maxBytes must be a positive integer');
230
+ }
231
+ if (maxBytes > DEFAULT_MAX_IMAGE_BYTES) {
232
+ throw new Error(`maxBytes cannot exceed ${DEFAULT_MAX_IMAGE_BYTES} bytes for images`);
233
+ }
234
+ return maxBytes;
235
+ }
236
+
237
+ async function writeResponseBodyToFile(response, filePath, options) {
238
+ const { overwrite, maxBytes } = options;
239
+ const flags = overwrite ? 'w' : 'wx';
240
+ let fileHandle;
241
+ let bytesWritten = 0;
242
+
243
+ try {
244
+ fileHandle = await open(filePath, flags);
245
+ } catch (err) {
246
+ if (err?.code === 'EEXIST') {
247
+ throw new Error(`Destination file already exists: ${filePath}`);
248
+ }
249
+ throw err;
250
+ }
251
+
252
+ try {
253
+ if (!response.body) {
254
+ const buffer = Buffer.from(await response.arrayBuffer());
255
+ if (buffer.length > maxBytes) {
256
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
257
+ }
258
+ await fileHandle.writeFile(buffer);
259
+ bytesWritten = buffer.length;
260
+ return bytesWritten;
261
+ }
262
+
263
+ const reader = response.body.getReader();
264
+ while (true) {
265
+ const { done, value } = await reader.read();
266
+ if (done) break;
267
+ const buffer = Buffer.from(value);
268
+ bytesWritten += buffer.length;
269
+ if (bytesWritten > maxBytes) {
270
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
271
+ }
272
+ await fileHandle.write(buffer);
273
+ }
274
+ return bytesWritten;
275
+ } catch (err) {
276
+ await fileHandle.close().catch(() => {});
277
+ await unlink(filePath).catch(() => {});
278
+ throw err;
279
+ } finally {
280
+ await fileHandle?.close?.().catch(() => {});
281
+ }
282
+ }
283
+
284
+ export const issueDownloadInternals = {
285
+ DEFAULT_MAX_DOWNLOAD_BYTES,
286
+ resolveSafeRelativeDirectory,
287
+ sanitizeDownloadFilename,
288
+ filenameFromAttachment,
289
+ resolveSafeDestinationPath,
290
+ selectIssueAttachment,
291
+ normalizeMaxBytes,
292
+ };
293
+
73
294
  // ===== GIT OPERATIONS (for issue start) =====
74
295
 
75
296
  /**
@@ -169,12 +390,24 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
169
390
  export async function executeIssueList(client, params) {
170
391
  return withHandlerErrorHandling(async () => {
171
392
  let projectRef = params.project;
172
- if (!projectRef) {
173
- projectRef = path.basename(process.cwd());
393
+ let projectMode = false;
394
+
395
+ if (projectRef) {
396
+ // Explicit project specified - use project-scoped listing
397
+ projectMode = true;
398
+ } else {
399
+ // No explicit project - try cwd fallback
400
+ const cwdProject = path.basename(process.cwd());
401
+ try {
402
+ const resolved = await resolveProjectRef(client, cwdProject);
403
+ projectRef = resolved.name; // use resolved name for display
404
+ projectMode = true;
405
+ } catch {
406
+ // cwd doesn't match any project - fall back to workspace-level listing
407
+ projectMode = false;
408
+ }
174
409
  }
175
410
 
176
- const resolved = await resolveProjectRef(client, projectRef);
177
-
178
411
  let assigneeId = null;
179
412
  if (params.assignee === 'me') {
180
413
  const viewer = await getViewer(client);
@@ -188,21 +421,50 @@ export async function executeIssueList(client, params) {
188
421
  teamId = team.id;
189
422
  }
190
423
 
191
- const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
192
- assigneeId,
193
- teamId,
194
- limit: params.limit || 20,
195
- });
424
+ const limit = params.limit || 20;
196
425
 
197
- if (issues.length === 0) {
198
- return toTextResult(`No issues found in project "${resolved.name}"`, {
199
- projectId: resolved.id,
200
- projectName: resolved.name,
201
- issueCount: 0,
426
+ let issues;
427
+ let truncated;
428
+ let projectId = null;
429
+ let projectName = null;
430
+
431
+ if (projectMode) {
432
+ // Project-scoped listing
433
+ const resolved = await resolveProjectRef(client, projectRef);
434
+ projectId = resolved.id;
435
+ projectName = resolved.name;
436
+
437
+ const result = await fetchIssuesByProject(client, resolved.id, params.states || null, {
438
+ assigneeId,
439
+ teamId,
440
+ limit,
202
441
  });
442
+ issues = result.issues;
443
+ truncated = result.truncated;
444
+ } else {
445
+ // Workspace-level listing (no project filter)
446
+ // Default to open states if none specified
447
+ const workspaceStates = params.states || ['Backlog', 'Triage', 'In Progress', 'In Review'];
448
+ const result = await fetchIssues(client, assigneeId, workspaceStates, limit);
449
+ issues = result.issues;
450
+ truncated = result.truncated;
451
+ }
452
+
453
+ if (issues.length === 0) {
454
+ return toTextResult(
455
+ projectMode
456
+ ? `No issues found in project "${projectName}"`
457
+ : 'No issues found in workspace',
458
+ {
459
+ projectId,
460
+ projectName,
461
+ issueCount: 0,
462
+ }
463
+ );
203
464
  }
204
465
 
205
- const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
466
+ const scopeLabel = projectMode ? `project "${projectName}"` : 'workspace';
467
+ const lines = [`## Issues in ${scopeLabel} (${issues.length}${truncated ? '+' : ''})\n`];
206
468
 
207
469
  for (const issue of issues) {
208
470
  const stateLabel = issue.state?.name || 'Unknown';
@@ -222,8 +484,8 @@ export async function executeIssueList(client, params) {
222
484
  }
223
485
 
224
486
  return toTextResult(lines.join('\n'), {
225
- projectId: resolved.id,
226
- projectName: resolved.name,
487
+ projectId,
488
+ projectName,
227
489
  issueCount: issues.length,
228
490
  truncated,
229
491
  });
@@ -252,6 +514,126 @@ export async function executeIssueView(client, params) {
252
514
  };
253
515
  }
254
516
 
517
+ export async function executeIssueImages(client, params) {
518
+ const issue = ensureNonEmpty(params.issue, 'issue');
519
+ const includeComments = params.includeComments !== false;
520
+ const maxBytes = normalizeImageMaxBytes(params.maxBytes);
521
+ const imageData = await fetchIssueImages(client, issue, {
522
+ includeComments,
523
+ limit: params.limit || 10,
524
+ maxBytes,
525
+ });
526
+
527
+ const lines = [
528
+ `# Images for ${imageData.issue.identifier}: ${imageData.issue.title}`,
529
+ '',
530
+ ];
531
+
532
+ if (imageData.images.length === 0) {
533
+ lines.push('No fetchable markdown images found.');
534
+ } else {
535
+ lines.push(`Fetched ${imageData.images.length} image${imageData.images.length === 1 ? '' : 's'}.`);
536
+ lines.push('');
537
+ imageData.images.forEach((image, index) => {
538
+ lines.push(`- **Image ${index + 1}**${image.alt ? ` (${image.alt})` : ''}: ${image.url}`);
539
+ lines.push(` _${image.mimeType}, ${image.sizeBytes} bytes, source: ${image.source}_`);
540
+ });
541
+ }
542
+
543
+ if (imageData.failures.length > 0) {
544
+ lines.push('');
545
+ lines.push('## Failed image fetches');
546
+ lines.push('');
547
+ for (const failure of imageData.failures) {
548
+ lines.push(`- ${failure.url} — ${failure.error}`);
549
+ }
550
+ }
551
+
552
+ return {
553
+ content: [
554
+ { type: 'text', text: lines.join('\n') },
555
+ ...imageData.images.map((image) => ({
556
+ type: 'image',
557
+ data: image.data,
558
+ mimeType: image.mimeType,
559
+ })),
560
+ ],
561
+ details: {
562
+ issue: imageData.issue,
563
+ imageCount: imageData.images.length,
564
+ failureCount: imageData.failures.length,
565
+ totalCandidates: imageData.totalCandidates,
566
+ images: imageData.images.map(({ data, ...image }) => image),
567
+ failures: imageData.failures,
568
+ },
569
+ };
570
+ }
571
+
572
+ export async function executeIssueDownload(client, params, options = {}) {
573
+ const issue = ensureNonEmpty(params.issue, 'issue');
574
+ const directory = ensureNonEmpty(params.directory, 'directory');
575
+ const overwrite = params.overwrite === true;
576
+ const settings = options.settings || {};
577
+
578
+ if (overwrite && settings.allow_overwrite_files !== true) {
579
+ throw new Error('overwrite=true requires allow_overwrite_files=true. Enable it with /linear-tools-config --allow-overwrite-files true.');
580
+ }
581
+
582
+ const maxBytes = normalizeMaxBytes(params.maxBytes);
583
+ const issueData = await fetchIssueDetails(client, issue, { includeComments: false });
584
+ const attachment = selectIssueAttachment(issueData.attachments, params);
585
+ const filename = filenameFromAttachment(attachment, params.filename);
586
+ const destination = resolveSafeDestinationPath(directory, filename, options.cwd || process.cwd());
587
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
588
+
589
+ if (typeof fetchImpl !== 'function') {
590
+ throw new Error('Fetch API is not available in this Node.js runtime');
591
+ }
592
+
593
+ await mkdir(destination.directory, { recursive: true });
594
+
595
+ const response = await fetchImpl(attachment.url, { redirect: 'follow' });
596
+ if (!response?.ok) {
597
+ const status = response?.status ? `HTTP ${response.status}` : 'request failed';
598
+ throw new Error(`Failed to download attachment "${attachment.title}": ${status}`);
599
+ }
600
+
601
+ const contentLengthHeader = response.headers?.get?.('content-length');
602
+ if (contentLengthHeader) {
603
+ const contentLength = Number.parseInt(contentLengthHeader, 10);
604
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
605
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
606
+ }
607
+ }
608
+
609
+ const bytesWritten = await writeResponseBodyToFile(response, destination.filePath, { overwrite, maxBytes });
610
+ const relativePath = path.relative(options.cwd || process.cwd(), destination.filePath) || destination.filename;
611
+
612
+ return {
613
+ content: [{
614
+ type: 'text',
615
+ text: [
616
+ `Downloaded **${attachment.title}** to \`${relativePath}\``,
617
+ '',
618
+ `- bytes: ${bytesWritten}`,
619
+ `- source: ${attachment.url}`,
620
+ ].join('\n'),
621
+ }],
622
+ details: {
623
+ issueId: issueData.id,
624
+ identifier: issueData.identifier,
625
+ attachmentId: attachment.id,
626
+ attachmentTitle: attachment.title,
627
+ sourceUrl: attachment.url,
628
+ filePath: destination.filePath,
629
+ relativePath,
630
+ bytesWritten,
631
+ overwritten: overwrite,
632
+ maxBytes,
633
+ },
634
+ };
635
+ }
636
+
255
637
  export async function executeIssueActivity(client, params) {
256
638
  const issue = ensureNonEmpty(params.issue, 'issue');
257
639
  const activityData = await fetchIssueActivity(client, issue, {
@@ -508,13 +890,18 @@ export async function executeIssueComment(client, params) {
508
890
  const issue = ensureNonEmpty(params.issue, 'issue');
509
891
  const body = ensureNonEmpty(params.body, 'body');
510
892
  const result = await addIssueComment(client, issue, body, params.parentCommentId);
893
+ const commentBody = String(result.comment?.body || body).trim();
894
+ const preview = formatCommentPreview(commentBody);
511
895
 
512
896
  return toTextResult(
513
- `Added comment to issue ${result.issue.identifier}`,
897
+ `Added comment to issue ${result.issue.identifier}\n\n${preview.text}`,
514
898
  {
515
899
  issueId: result.issue.id,
516
900
  identifier: result.issue.identifier,
517
901
  commentId: result.comment.id,
902
+ commentBody,
903
+ commentPreview: preview.text,
904
+ commentPreviewTruncated: preview.truncated,
518
905
  }
519
906
  );
520
907
  }
@@ -11,7 +11,11 @@ import { debug, warn, info } 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 */
14
+ const DEFAULT_REQUEST_LIMIT = 5000;
15
+ const LOW_RATE_LIMIT_THRESHOLD = 0.10;
16
+ const RATE_LIMIT_WARN_MIN_MS = 30000;
17
+
18
+ /** @type {Map<string, {limit: number, remaining: number, resetAt: number, lastWarnAt?: number}>} Per-client request rate limit tracking */
15
19
  const rateLimitTracker = new Map();
16
20
 
17
21
  /** @type {Map<string, {total: number, success: number, failed: number, rateLimited: number, windowStart: number, lastSummaryAt: number}>} */
@@ -32,6 +36,33 @@ function getTrackerKeyFromClient(client) {
32
36
  return client?.__piLinearTrackerKey || client?.apiKey || 'default';
33
37
  }
34
38
 
39
+ function parseHeaderNumber(value) {
40
+ if (value === null || value === undefined) return null;
41
+ const parsed = Number.parseInt(String(value), 10);
42
+ return Number.isFinite(parsed) ? parsed : null;
43
+ }
44
+
45
+ function getTrackerLimit(tracker) {
46
+ return tracker?.limit || tracker?.total || DEFAULT_REQUEST_LIMIT;
47
+ }
48
+
49
+ function getUsedRequests(tracker) {
50
+ const limit = getTrackerLimit(tracker);
51
+ const remaining = tracker?.remaining;
52
+ if (!Number.isFinite(remaining)) return 0;
53
+ return Math.max(0, limit - remaining);
54
+ }
55
+
56
+ function getUsagePercent(tracker) {
57
+ const limit = getTrackerLimit(tracker);
58
+ if (!limit) return null;
59
+ return Math.round((getUsedRequests(tracker) / limit) * 100);
60
+ }
61
+
62
+ function getLowRequestThreshold(tracker) {
63
+ return Math.max(1, Math.floor(getTrackerLimit(tracker) * LOW_RATE_LIMIT_THRESHOLD));
64
+ }
65
+
35
66
  function getRequestMetric(trackerKey) {
36
67
  let metric = requestMetrics.get(trackerKey);
37
68
  if (!metric) {
@@ -60,7 +91,8 @@ function maybeLogRequestSummary(trackerKey) {
60
91
  if (!shouldLogByCount && !shouldLogByTime) return;
61
92
 
62
93
  metric.lastSummaryAt = now;
63
- const used = Math.max(0, 5000 - (tracker.remaining ?? 5000));
94
+ const used = getUsedRequests(tracker);
95
+ const limit = getTrackerLimit(tracker);
64
96
 
65
97
  info('[pi-linear-tools] Linear API usage summary', {
66
98
  trackerKey,
@@ -68,6 +100,7 @@ function maybeLogRequestSummary(trackerKey) {
68
100
  requestsSuccess: metric.success,
69
101
  requestsFailed: metric.failed,
70
102
  requestsRateLimited: metric.rateLimited,
103
+ requestsLimit: limit,
71
104
  requestsRemaining: tracker.remaining,
72
105
  requestsUsed: used,
73
106
  resetAt: tracker.resetAt,
@@ -125,19 +158,20 @@ export function markRateLimited(resetAt) {
125
158
  * Extract rate limit info from SDK client response
126
159
  * The Linear SDK stores response metadata on the client after requests
127
160
  * @param {LinearClient} client - Linear SDK client
128
- * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
161
+ * @returns {{limit: number|null, remaining: number|null, resetAt: number|null, resetTime: string|null}}
129
162
  */
130
163
  export function getClientRateLimit(client) {
131
164
  const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
132
165
  if (trackerData) {
133
166
  return {
167
+ limit: getTrackerLimit(trackerData),
134
168
  remaining: trackerData.remaining,
135
169
  resetAt: trackerData.resetAt,
136
170
  resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
137
171
  };
138
172
  }
139
173
 
140
- return { remaining: null, resetAt: null, resetTime: null };
174
+ return { limit: null, remaining: null, resetAt: null, resetTime: null };
141
175
  }
142
176
 
143
177
  /**
@@ -147,12 +181,12 @@ export function getClientRateLimit(client) {
147
181
  */
148
182
  export function getClientRateLimitInfo(client) {
149
183
  const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
150
- const total = 5000; // Linear's hourly request limit
184
+ const total = getTrackerLimit(trackerData);
151
185
 
152
186
  if (trackerData && trackerData.remaining !== undefined) {
153
187
  const remaining = trackerData.remaining;
154
- const used = Math.max(0, total - remaining);
155
- const usagePercent = Math.round((used / total) * 100);
188
+ const used = getUsedRequests(trackerData);
189
+ const usagePercent = getUsagePercent(trackerData);
156
190
 
157
191
  return {
158
192
  remaining,
@@ -206,11 +240,12 @@ export function getClientRequestMetrics(client) {
206
240
  * @returns {boolean} True if warning was issued
207
241
  */
208
242
  export function checkAndWarnRateLimit(client) {
209
- const { remaining, resetTime } = getClientRateLimit(client);
243
+ const rateLimitInfo = getClientRateLimitInfo(client);
244
+ const { remaining, resetTime, usagePercent } = rateLimitInfo;
210
245
 
211
- if (remaining !== null && remaining <= 500) {
212
- const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
246
+ if (remaining !== null && remaining <= getLowRequestThreshold(rateLimitInfo)) {
213
247
  warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
248
+ limit: rateLimitInfo.total,
214
249
  remaining,
215
250
  resetTime,
216
251
  usagePercent,
@@ -267,7 +302,12 @@ export function createLinearClient(auth) {
267
302
  const trackerKey = getTrackerKey(apiKey);
268
303
  client.__piLinearTrackerKey = trackerKey;
269
304
  if (!rateLimitTracker.has(trackerKey)) {
270
- rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
305
+ rateLimitTracker.set(trackerKey, {
306
+ limit: DEFAULT_REQUEST_LIMIT,
307
+ remaining: DEFAULT_REQUEST_LIMIT,
308
+ resetAt: Date.now() + 3600000,
309
+ lastWarnAt: 0,
310
+ });
271
311
  }
272
312
  getRequestMetric(trackerKey);
273
313
 
@@ -288,22 +328,34 @@ export function createLinearClient(auth) {
288
328
  metric.success += 1;
289
329
 
290
330
  if (response.headers) {
291
- const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
292
- const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
331
+ const limit = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Limit'));
332
+ const remaining = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Remaining'));
333
+ const resetAt = parseHeaderNumber(response.headers.get('X-RateLimit-Requests-Reset'));
293
334
 
294
335
  const tracker = rateLimitTracker.get(trackerKey);
336
+ if (tracker && limit !== null) {
337
+ tracker.limit = limit;
338
+ }
295
339
  if (tracker && remaining !== null) {
296
- tracker.remaining = parseInt(remaining, 10);
340
+ tracker.remaining = remaining;
297
341
  }
298
342
  if (tracker && resetAt !== null) {
299
- tracker.resetAt = parseInt(resetAt, 10);
343
+ tracker.resetAt = resetAt;
300
344
  }
301
345
  }
302
346
 
303
347
  const tracker = rateLimitTracker.get(trackerKey);
304
- if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
305
- const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
348
+ const now = Date.now();
349
+ if (
350
+ tracker &&
351
+ tracker.remaining <= getLowRequestThreshold(tracker) &&
352
+ tracker.remaining > 0 &&
353
+ now - (tracker.lastWarnAt || 0) >= RATE_LIMIT_WARN_MIN_MS
354
+ ) {
355
+ tracker.lastWarnAt = now;
356
+ const usagePercent = getUsagePercent(tracker);
306
357
  warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
358
+ limit: getTrackerLimit(tracker),
307
359
  remaining: tracker.remaining,
308
360
  resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
309
361
  usagePercent,
@@ -322,9 +374,12 @@ export function createLinearClient(auth) {
322
374
  metric.rateLimited += 1;
323
375
  const resetAt = Number(error?.requestsResetAt) || Date.now() + 3600000;
324
376
  const remaining = Number.isFinite(error?.requestsRemaining) ? error.requestsRemaining : 0;
377
+ const previousTracker = rateLimitTracker.get(trackerKey);
325
378
  rateLimitTracker.set(trackerKey, {
379
+ limit: getTrackerLimit(previousTracker),
326
380
  remaining,
327
381
  resetAt,
382
+ lastWarnAt: previousTracker?.lastWarnAt || 0,
328
383
  });
329
384
  markRateLimited(resetAt);
330
385
  }