@fink-andreas/pi-linear-tools 0.5.1 → 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/CHANGELOG.md +38 -0
- package/README.md +44 -16
- package/extensions/pi-linear-tools.js +210 -27
- package/package.json +2 -2
- package/settings.json.example +6 -2
- package/src/cli.js +101 -8
- package/src/handlers.js +405 -18
- package/src/linear-client.js +120 -14
- package/src/linear.js +319 -26
- package/src/settings.js +22 -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,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
|
-
|
|
173
|
-
|
|
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
|
|
192
|
-
assigneeId,
|
|
193
|
-
teamId,
|
|
194
|
-
limit: params.limit || 20,
|
|
195
|
-
});
|
|
424
|
+
const limit = params.limit || 20;
|
|
196
425
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
|
226
|
-
projectName
|
|
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
|
}
|