@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/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
  }