@fink-andreas/pi-linear-tools 0.6.0 → 0.7.1

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,6 +21,7 @@ import {
20
21
  resolveMilestoneRef,
21
22
  getTeamWorkflowStates,
22
23
  fetchIssueDetails,
24
+ fetchIssueImages,
23
25
  fetchIssueActivity,
24
26
  formatIssueAsMarkdown,
25
27
  formatIssueActivityAsMarkdown,
@@ -53,6 +55,20 @@ function toTextResult(text, details = {}) {
53
55
  };
54
56
  }
55
57
 
58
+ const COMMENT_PREVIEW_LIMIT = 500;
59
+
60
+ function formatCommentPreview(body, limit = COMMENT_PREVIEW_LIMIT) {
61
+ const text = String(body || '').trim();
62
+ if (text.length <= limit) {
63
+ return { text, truncated: false };
64
+ }
65
+
66
+ return {
67
+ text: `${text.slice(0, limit).trimEnd()}...`,
68
+ truncated: true,
69
+ };
70
+ }
71
+
56
72
  function ensureNonEmpty(value, fieldName) {
57
73
  const text = String(value || '').trim();
58
74
  if (!text) throw new Error(`Missing required field: ${fieldName}`);
@@ -70,6 +86,210 @@ function parseRefList(value) {
70
86
  .filter(Boolean);
71
87
  }
72
88
 
89
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
90
+ const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
91
+
92
+ function hasValue(value) {
93
+ return value !== undefined && value !== null && String(value).trim() !== '';
94
+ }
95
+
96
+ function resolveSafeRelativeDirectory(directory, cwd = process.cwd()) {
97
+ const requested = ensureNonEmpty(directory, 'directory');
98
+ if (path.isAbsolute(requested)) {
99
+ throw new Error('Download directory must be a relative path');
100
+ }
101
+
102
+ const resolvedCwd = path.resolve(cwd);
103
+ const resolvedDirectory = path.resolve(resolvedCwd, requested);
104
+ const relative = path.relative(resolvedCwd, resolvedDirectory);
105
+
106
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
107
+ throw new Error('Download directory must stay within the current working directory');
108
+ }
109
+
110
+ return resolvedDirectory;
111
+ }
112
+
113
+ function sanitizeDownloadFilename(value) {
114
+ const text = String(value || '').trim();
115
+ if (!text) return '';
116
+
117
+ const basename = path.basename(text) || text;
118
+ const sanitized = basename
119
+ .replace(/[\\/\u0000-\u001f\u007f<>:"|?*]+/g, '_')
120
+ .replace(/^\.+$/, '')
121
+ .replace(/^\.+/, '')
122
+ .trim()
123
+ .slice(0, 180);
124
+
125
+ return sanitized || '';
126
+ }
127
+
128
+ function filenameFromAttachment(attachment, explicitFilename) {
129
+ const explicit = sanitizeDownloadFilename(explicitFilename);
130
+ if (explicit) return explicit;
131
+
132
+ const fromTitle = sanitizeDownloadFilename(attachment?.title);
133
+ if (fromTitle) return fromTitle;
134
+
135
+ try {
136
+ const url = new URL(attachment?.url || '');
137
+ const fromUrl = sanitizeDownloadFilename(url.pathname);
138
+ if (fromUrl) return fromUrl;
139
+ } catch {
140
+ // fall through to attachment id
141
+ }
142
+
143
+ const fromId = sanitizeDownloadFilename(attachment?.id);
144
+ if (fromId) return fromId;
145
+
146
+ throw new Error('Unable to derive a safe filename for attachment; provide filename explicitly');
147
+ }
148
+
149
+ function resolveSafeDestinationPath(directory, filename, cwd = process.cwd()) {
150
+ const resolvedDirectory = resolveSafeRelativeDirectory(directory, cwd);
151
+ const safeFilename = filenameFromAttachment({}, filename);
152
+ const destination = path.resolve(resolvedDirectory, safeFilename);
153
+ const relative = path.relative(resolvedDirectory, destination);
154
+
155
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
156
+ throw new Error('Download filename must not escape the destination directory');
157
+ }
158
+
159
+ return { directory: resolvedDirectory, filename: safeFilename, filePath: destination };
160
+ }
161
+
162
+ function selectIssueAttachment(attachments, params) {
163
+ const list = Array.isArray(attachments) ? attachments : [];
164
+ if (list.length === 0) {
165
+ throw new Error('Issue has no Linear attachments to download');
166
+ }
167
+
168
+ const selectors = [
169
+ ['attachmentId', params.attachmentId],
170
+ ['attachmentTitle', params.attachmentTitle],
171
+ ['attachmentUrl', params.attachmentUrl],
172
+ ['attachmentIndex', params.attachmentIndex],
173
+ ].filter(([, value]) => hasValue(value));
174
+
175
+ if (selectors.length > 1) {
176
+ throw new Error('Provide only one attachment selector: attachmentId, attachmentTitle, attachmentUrl, or attachmentIndex');
177
+ }
178
+
179
+ if (selectors.length === 0) {
180
+ if (list.length === 1) return list[0];
181
+ throw new Error('Multiple attachments found; provide attachmentId, attachmentTitle, attachmentUrl, or attachmentIndex');
182
+ }
183
+
184
+ const [selector, rawValue] = selectors[0];
185
+ const value = String(rawValue).trim();
186
+ let matches = [];
187
+
188
+ if (selector === 'attachmentId') {
189
+ matches = list.filter((attachment) => attachment.id === value);
190
+ } else if (selector === 'attachmentTitle') {
191
+ const normalized = value.toLowerCase();
192
+ matches = list.filter((attachment) => String(attachment.title || '').toLowerCase() === normalized);
193
+ } else if (selector === 'attachmentUrl') {
194
+ matches = list.filter((attachment) => attachment.url === value);
195
+ } else if (selector === 'attachmentIndex') {
196
+ const index = Number.parseInt(value, 10);
197
+ if (!Number.isInteger(index) || index < 1 || index > list.length) {
198
+ throw new Error(`attachmentIndex must be between 1 and ${list.length}`);
199
+ }
200
+ return list[index - 1];
201
+ }
202
+
203
+ if (matches.length === 0) {
204
+ throw new Error(`No attachment matched ${selector}: ${value}`);
205
+ }
206
+ if (matches.length > 1) {
207
+ throw new Error(`Multiple attachments matched ${selector}: ${value}; use attachmentId instead`);
208
+ }
209
+ return matches[0];
210
+ }
211
+
212
+ function normalizeMaxBytes(value) {
213
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_DOWNLOAD_BYTES;
214
+ const maxBytes = Number(value);
215
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
216
+ throw new Error('maxBytes must be a positive integer');
217
+ }
218
+ if (maxBytes > DEFAULT_MAX_DOWNLOAD_BYTES) {
219
+ throw new Error(`maxBytes cannot exceed ${DEFAULT_MAX_DOWNLOAD_BYTES} bytes`);
220
+ }
221
+ return maxBytes;
222
+ }
223
+
224
+ function normalizeImageMaxBytes(value) {
225
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_IMAGE_BYTES;
226
+ const maxBytes = Number(value);
227
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
228
+ throw new Error('maxBytes must be a positive integer');
229
+ }
230
+ if (maxBytes > DEFAULT_MAX_IMAGE_BYTES) {
231
+ throw new Error(`maxBytes cannot exceed ${DEFAULT_MAX_IMAGE_BYTES} bytes for images`);
232
+ }
233
+ return maxBytes;
234
+ }
235
+
236
+ async function writeResponseBodyToFile(response, filePath, options) {
237
+ const { overwrite, maxBytes } = options;
238
+ const flags = overwrite ? 'w' : 'wx';
239
+ let fileHandle;
240
+ let bytesWritten = 0;
241
+
242
+ try {
243
+ fileHandle = await open(filePath, flags);
244
+ } catch (err) {
245
+ if (err?.code === 'EEXIST') {
246
+ throw new Error(`Destination file already exists: ${filePath}`);
247
+ }
248
+ throw err;
249
+ }
250
+
251
+ try {
252
+ if (!response.body) {
253
+ const buffer = Buffer.from(await response.arrayBuffer());
254
+ if (buffer.length > maxBytes) {
255
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
256
+ }
257
+ await fileHandle.writeFile(buffer);
258
+ bytesWritten = buffer.length;
259
+ return bytesWritten;
260
+ }
261
+
262
+ const reader = response.body.getReader();
263
+ while (true) {
264
+ const { done, value } = await reader.read();
265
+ if (done) break;
266
+ const buffer = Buffer.from(value);
267
+ bytesWritten += buffer.length;
268
+ if (bytesWritten > maxBytes) {
269
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
270
+ }
271
+ await fileHandle.write(buffer);
272
+ }
273
+ return bytesWritten;
274
+ } catch (err) {
275
+ await fileHandle.close().catch(() => {});
276
+ await unlink(filePath).catch(() => {});
277
+ throw err;
278
+ } finally {
279
+ await fileHandle?.close?.().catch(() => {});
280
+ }
281
+ }
282
+
283
+ export const issueDownloadInternals = {
284
+ DEFAULT_MAX_DOWNLOAD_BYTES,
285
+ resolveSafeRelativeDirectory,
286
+ sanitizeDownloadFilename,
287
+ filenameFromAttachment,
288
+ resolveSafeDestinationPath,
289
+ selectIssueAttachment,
290
+ normalizeMaxBytes,
291
+ };
292
+
73
293
  // ===== GIT OPERATIONS (for issue start) =====
74
294
 
75
295
  /**
@@ -252,6 +472,126 @@ export async function executeIssueView(client, params) {
252
472
  };
253
473
  }
254
474
 
475
+ export async function executeIssueImages(client, params) {
476
+ const issue = ensureNonEmpty(params.issue, 'issue');
477
+ const includeComments = params.includeComments !== false;
478
+ const maxBytes = normalizeImageMaxBytes(params.maxBytes);
479
+ const imageData = await fetchIssueImages(client, issue, {
480
+ includeComments,
481
+ limit: params.limit || 10,
482
+ maxBytes,
483
+ });
484
+
485
+ const lines = [
486
+ `# Images for ${imageData.issue.identifier}: ${imageData.issue.title}`,
487
+ '',
488
+ ];
489
+
490
+ if (imageData.images.length === 0) {
491
+ lines.push('No fetchable markdown images found.');
492
+ } else {
493
+ lines.push(`Fetched ${imageData.images.length} image${imageData.images.length === 1 ? '' : 's'}.`);
494
+ lines.push('');
495
+ imageData.images.forEach((image, index) => {
496
+ lines.push(`- **Image ${index + 1}**${image.alt ? ` (${image.alt})` : ''}: ${image.url}`);
497
+ lines.push(` _${image.mimeType}, ${image.sizeBytes} bytes, source: ${image.source}_`);
498
+ });
499
+ }
500
+
501
+ if (imageData.failures.length > 0) {
502
+ lines.push('');
503
+ lines.push('## Failed image fetches');
504
+ lines.push('');
505
+ for (const failure of imageData.failures) {
506
+ lines.push(`- ${failure.url} — ${failure.error}`);
507
+ }
508
+ }
509
+
510
+ return {
511
+ content: [
512
+ { type: 'text', text: lines.join('\n') },
513
+ ...imageData.images.map((image) => ({
514
+ type: 'image',
515
+ data: image.data,
516
+ mimeType: image.mimeType,
517
+ })),
518
+ ],
519
+ details: {
520
+ issue: imageData.issue,
521
+ imageCount: imageData.images.length,
522
+ failureCount: imageData.failures.length,
523
+ totalCandidates: imageData.totalCandidates,
524
+ images: imageData.images.map(({ data, ...image }) => image),
525
+ failures: imageData.failures,
526
+ },
527
+ };
528
+ }
529
+
530
+ export async function executeIssueDownload(client, params, options = {}) {
531
+ const issue = ensureNonEmpty(params.issue, 'issue');
532
+ const directory = ensureNonEmpty(params.directory, 'directory');
533
+ const overwrite = params.overwrite === true;
534
+ const settings = options.settings || {};
535
+
536
+ if (overwrite && settings.allow_overwrite_files !== true) {
537
+ throw new Error('overwrite=true requires allow_overwrite_files=true. Enable it with /linear-tools-config --allow-overwrite-files true.');
538
+ }
539
+
540
+ const maxBytes = normalizeMaxBytes(params.maxBytes);
541
+ const issueData = await fetchIssueDetails(client, issue, { includeComments: false });
542
+ const attachment = selectIssueAttachment(issueData.attachments, params);
543
+ const filename = filenameFromAttachment(attachment, params.filename);
544
+ const destination = resolveSafeDestinationPath(directory, filename, options.cwd || process.cwd());
545
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
546
+
547
+ if (typeof fetchImpl !== 'function') {
548
+ throw new Error('Fetch API is not available in this Node.js runtime');
549
+ }
550
+
551
+ await mkdir(destination.directory, { recursive: true });
552
+
553
+ const response = await fetchImpl(attachment.url, { redirect: 'follow' });
554
+ if (!response?.ok) {
555
+ const status = response?.status ? `HTTP ${response.status}` : 'request failed';
556
+ throw new Error(`Failed to download attachment "${attachment.title}": ${status}`);
557
+ }
558
+
559
+ const contentLengthHeader = response.headers?.get?.('content-length');
560
+ if (contentLengthHeader) {
561
+ const contentLength = Number.parseInt(contentLengthHeader, 10);
562
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
563
+ throw new Error(`Download exceeds maxBytes (${maxBytes} bytes)`);
564
+ }
565
+ }
566
+
567
+ const bytesWritten = await writeResponseBodyToFile(response, destination.filePath, { overwrite, maxBytes });
568
+ const relativePath = path.relative(options.cwd || process.cwd(), destination.filePath) || destination.filename;
569
+
570
+ return {
571
+ content: [{
572
+ type: 'text',
573
+ text: [
574
+ `Downloaded **${attachment.title}** to \`${relativePath}\``,
575
+ '',
576
+ `- bytes: ${bytesWritten}`,
577
+ `- source: ${attachment.url}`,
578
+ ].join('\n'),
579
+ }],
580
+ details: {
581
+ issueId: issueData.id,
582
+ identifier: issueData.identifier,
583
+ attachmentId: attachment.id,
584
+ attachmentTitle: attachment.title,
585
+ sourceUrl: attachment.url,
586
+ filePath: destination.filePath,
587
+ relativePath,
588
+ bytesWritten,
589
+ overwritten: overwrite,
590
+ maxBytes,
591
+ },
592
+ };
593
+ }
594
+
255
595
  export async function executeIssueActivity(client, params) {
256
596
  const issue = ensureNonEmpty(params.issue, 'issue');
257
597
  const activityData = await fetchIssueActivity(client, issue, {
@@ -350,6 +690,22 @@ export async function executeIssueCreate(client, params, options = {}) {
350
690
  createInput.projectId = resolvedProject.id;
351
691
  }
352
692
 
693
+ if (params.projectMilestoneId !== undefined && params.projectMilestoneId !== null) {
694
+ createInput.projectMilestoneId = params.projectMilestoneId;
695
+ } else if (params.milestone !== undefined && params.milestone !== null) {
696
+ const milestoneRef = String(params.milestone || '').trim();
697
+ const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
698
+
699
+ if (!clearMilestoneValues.has(milestoneRef.toLowerCase())) {
700
+ if (!resolvedProject?.id) {
701
+ throw new Error('Missing required field: project. Provide project when assigning milestone by name during issue create, or use projectMilestoneId.');
702
+ }
703
+
704
+ const milestone = await resolveMilestoneRef(client, milestoneRef, resolvedProject.id);
705
+ createInput.projectMilestoneId = milestone.id;
706
+ }
707
+ }
708
+
353
709
  const issue = await createIssue(client, createInput);
354
710
 
355
711
  const identifier = issue.identifier || issue.id || 'unknown';
@@ -360,8 +716,11 @@ export async function executeIssueCreate(client, params, options = {}) {
360
716
  const stateLabel = issue.state?.name || 'Unknown';
361
717
  const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
362
718
 
719
+ const milestoneLabel = issue.projectMilestone?.name || null;
720
+
363
721
  const metaParts = [`Team: ${team.name}`, `Project: ${projectLabel}`, `State: ${stateLabel}`, `Assignee: ${assigneeLabel}`];
364
722
  if (priorityLabel) metaParts.push(`Priority: ${priorityLabel}`);
723
+ if (milestoneLabel) metaParts.push(`Milestone: ${milestoneLabel}`);
365
724
 
366
725
  return toTextResult(
367
726
  `Created issue **${identifier}**: ${issue.title}\n${metaParts.join(' | ')}`,
@@ -373,6 +732,7 @@ export async function executeIssueCreate(client, params, options = {}) {
373
732
  project: issue.project,
374
733
  state: issue.state,
375
734
  assignee: issue.assignee,
735
+ projectMilestone: issue.projectMilestone,
376
736
  url: issue.url,
377
737
  }
378
738
  );
@@ -508,13 +868,18 @@ export async function executeIssueComment(client, params) {
508
868
  const issue = ensureNonEmpty(params.issue, 'issue');
509
869
  const body = ensureNonEmpty(params.body, 'body');
510
870
  const result = await addIssueComment(client, issue, body, params.parentCommentId);
871
+ const commentBody = String(result.comment?.body || body).trim();
872
+ const preview = formatCommentPreview(commentBody);
511
873
 
512
874
  return toTextResult(
513
- `Added comment to issue ${result.issue.identifier}`,
875
+ `Added comment to issue ${result.issue.identifier}\n\n${preview.text}`,
514
876
  {
515
877
  issueId: result.issue.id,
516
878
  identifier: result.issue.identifier,
517
879
  commentId: result.comment.id,
880
+ commentBody,
881
+ commentPreview: preview.text,
882
+ commentPreviewTruncated: preview.truncated,
518
883
  }
519
884
  );
520
885
  }
@@ -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
  }