@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/CHANGELOG.md +31 -0
- package/README.md +20 -2
- package/extensions/pi-linear-tools.js +107 -18
- package/package.json +2 -2
- package/settings.json.example +6 -2
- package/src/cli.js +101 -8
- package/src/handlers.js +366 -1
- package/src/linear-client.js +72 -17
- package/src/linear.js +207 -21
- package/src/settings.js +11 -0
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
|
}
|
package/src/linear-client.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
184
|
+
const total = getTrackerLimit(trackerData);
|
|
151
185
|
|
|
152
186
|
if (trackerData && trackerData.remaining !== undefined) {
|
|
153
187
|
const remaining = trackerData.remaining;
|
|
154
|
-
const used =
|
|
155
|
-
const usagePercent =
|
|
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
|
|
243
|
+
const rateLimitInfo = getClientRateLimitInfo(client);
|
|
244
|
+
const { remaining, resetTime, usagePercent } = rateLimitInfo;
|
|
210
245
|
|
|
211
|
-
if (remaining !== null && remaining <=
|
|
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, {
|
|
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
|
|
292
|
-
const
|
|
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 =
|
|
340
|
+
tracker.remaining = remaining;
|
|
297
341
|
}
|
|
298
342
|
if (tracker && resetAt !== null) {
|
|
299
|
-
tracker.resetAt =
|
|
343
|
+
tracker.resetAt = resetAt;
|
|
300
344
|
}
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
const tracker = rateLimitTracker.get(trackerKey);
|
|
304
|
-
|
|
305
|
-
|
|
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
|
}
|