@ysicing/plane-cli 0.1.0 → 1.0.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.
@@ -1,9 +1,12 @@
1
1
  import { IssueClient } from "../api/issue-client.js";
2
+ import { ProjectClient } from "../api/project-client.js";
2
3
  import { resolveRuntimeConfig } from "../core/config.js";
3
4
  import { CliError } from "../core/errors.js";
4
5
  import { PlaneClient } from "../core/http.js";
5
6
  import { ensureValue, parseCommandArgs, pickDefined, splitCsv } from "../core/options.js";
6
7
  import { printData, printTable } from "../core/output.js";
8
+ import { basename, extname } from "node:path";
9
+ import { readFile, stat } from "node:fs/promises";
7
10
 
8
11
  function renderIssueList(data) {
9
12
  const rows = Array.isArray(data) ? data : data.results || [];
@@ -16,6 +19,164 @@ function renderIssueList(data) {
16
19
  ]);
17
20
  }
18
21
 
22
+ function renderIssueSearch(data) {
23
+ const rows = Array.isArray(data?.issues) ? data.issues : Array.isArray(data) ? data : [];
24
+ printTable(rows, [
25
+ { label: "ID", get: (row) => row.id },
26
+ { label: "Key", get: (row) => `${row.project__identifier || row.project_identifier || ""}-${row.sequence_id || ""}` },
27
+ { label: "Project", get: (row) => row.project__identifier || row.project_identifier || "" },
28
+ { label: "Name", get: (row) => row.name || "" },
29
+ ]);
30
+ }
31
+
32
+ function renderIssueLabels(data) {
33
+ const rows = Array.isArray(data) ? data : data.results || [];
34
+ printTable(rows, [
35
+ { label: "ID", get: (row) => row.id || "" },
36
+ { label: "Name", get: (row) => row.name || "" },
37
+ { label: "Color", get: (row) => row.color || "" },
38
+ { label: "Description", get: (row) => row.description || "" },
39
+ ]);
40
+ }
41
+
42
+ function renderIssueComments(data) {
43
+ const rows = Array.isArray(data) ? data : data.results || [];
44
+ printTable(rows, [
45
+ { label: "ID", get: (row) => row.id || "" },
46
+ { label: "Actor", get: (row) => row.actor || "" },
47
+ { label: "Created", get: (row) => row.created_at || "" },
48
+ { label: "Comment", get: (row) => String(row.comment_html || "").replace(/\s+/g, " ").trim() },
49
+ ]);
50
+ }
51
+
52
+ function renderIssueActivities(data) {
53
+ const rows = Array.isArray(data) ? data : data.results || [];
54
+ printTable(rows, [
55
+ { label: "ID", get: (row) => row.id || "" },
56
+ { label: "Field", get: (row) => row.field || "" },
57
+ { label: "Actor", get: (row) => row.actor || "" },
58
+ { label: "Created", get: (row) => row.created_at || "" },
59
+ ]);
60
+ }
61
+
62
+ function renderIssueLinks(data) {
63
+ const rows = Array.isArray(data) ? data : data.results || [];
64
+ printTable(rows, [
65
+ { label: "ID", get: (row) => row.id || "" },
66
+ { label: "Title", get: (row) => row.title || "" },
67
+ { label: "URL", get: (row) => row.url || "" },
68
+ ]);
69
+ }
70
+
71
+ function renderIssueRelations(data) {
72
+ const rows = Object.entries(data || {}).flatMap(([relationType, ids]) =>
73
+ Array.isArray(ids) ? ids.map((id) => ({ relationType, id })) : []
74
+ );
75
+ printTable(rows, [
76
+ { label: "Relation", get: (row) => row.relationType },
77
+ { label: "Issue ID", get: (row) => row.id },
78
+ ]);
79
+ }
80
+
81
+ function renderIssueAttachments(data) {
82
+ const rows = Array.isArray(data) ? data : [];
83
+ printTable(rows, [
84
+ { label: "ID", get: (row) => row.id || "" },
85
+ { label: "Name", get: (row) => row.attributes?.name || "" },
86
+ { label: "Type", get: (row) => row.attributes?.type || "" },
87
+ { label: "Size", get: (row) => row.attributes?.size || row.size || "" },
88
+ { label: "Uploaded", get: (row) => (row.is_uploaded ? "yes" : "no") },
89
+ ]);
90
+ }
91
+
92
+ const MIME_BY_EXTENSION = {
93
+ ".jpg": "image/jpeg",
94
+ ".jpeg": "image/jpeg",
95
+ ".png": "image/png",
96
+ ".gif": "image/gif",
97
+ ".svg": "image/svg+xml",
98
+ ".webp": "image/webp",
99
+ ".tif": "image/tiff",
100
+ ".tiff": "image/tiff",
101
+ ".bmp": "image/bmp",
102
+ ".pdf": "application/pdf",
103
+ ".doc": "application/msword",
104
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
105
+ ".xls": "application/vnd.ms-excel",
106
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
107
+ ".ppt": "application/vnd.ms-powerpoint",
108
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
109
+ ".txt": "text/plain",
110
+ ".md": "text/markdown",
111
+ ".rtf": "application/rtf",
112
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
113
+ ".odt": "application/vnd.oasis.opendocument.text",
114
+ ".odp": "application/vnd.oasis.opendocument.presentation",
115
+ ".odg": "application/vnd.oasis.opendocument.graphics",
116
+ ".vsd": "application/vnd.visio",
117
+ ".mp3": "audio/mpeg",
118
+ ".wav": "audio/wav",
119
+ ".ogg": "audio/ogg",
120
+ ".mid": "audio/midi",
121
+ ".midi": "audio/midi",
122
+ ".aac": "audio/aac",
123
+ ".flac": "audio/flac",
124
+ ".m4a": "audio/x-m4a",
125
+ ".mp4": "video/mp4",
126
+ ".mpeg": "video/mpeg",
127
+ ".mpg": "video/mpeg",
128
+ ".webm": "video/webm",
129
+ ".mov": "video/quicktime",
130
+ ".avi": "video/x-msvideo",
131
+ ".wmv": "video/x-ms-wmv",
132
+ ".zip": "application/zip",
133
+ ".rar": "application/x-rar-compressed",
134
+ ".tar": "application/x-tar",
135
+ ".gz": "application/gzip",
136
+ ".7z": "application/x-7z-compressed",
137
+ };
138
+
139
+ export function parseIssueKey(value) {
140
+ const trimmed = String(value || "").trim();
141
+ const match = /^([A-Za-z0-9_]+)-(\d+)$/.exec(trimmed);
142
+
143
+ if (!match) {
144
+ throw new CliError("Issue key must look like `PROJECT-123`.");
145
+ }
146
+
147
+ return {
148
+ projectIdentifier: match[1],
149
+ issueIdentifier: match[2],
150
+ };
151
+ }
152
+
153
+ function isIssueKey(value) {
154
+ return /^([A-Za-z0-9_]+)-(\d+)$/.test(String(value || "").trim());
155
+ }
156
+
157
+ async function resolveIssueTarget(issueClient, projectId, issueRef) {
158
+ ensureValue(issueRef, "Issue ID is required.");
159
+
160
+ if (projectId) {
161
+ return {
162
+ projectId,
163
+ issueId: issueRef,
164
+ };
165
+ }
166
+
167
+ if (!isIssueKey(issueRef)) {
168
+ throw new CliError("Project ID is required unless the issue is provided as a key like `GAEA-25`.");
169
+ }
170
+
171
+ const { projectIdentifier, issueIdentifier } = parseIssueKey(issueRef);
172
+ const issue = await issueClient.getByKey(projectIdentifier, issueIdentifier);
173
+
174
+ return {
175
+ projectId: issue.project,
176
+ issueId: issue.id,
177
+ };
178
+ }
179
+
19
180
  export function buildIssuePayload(values) {
20
181
  return pickDefined({
21
182
  name: values.name,
@@ -31,15 +192,510 @@ export function buildIssuePayload(values) {
31
192
  });
32
193
  }
33
194
 
195
+ export function buildIssueLabelPayload(values) {
196
+ return pickDefined({
197
+ name: values.name,
198
+ color: values.color,
199
+ description: values.description,
200
+ parent: values.parent,
201
+ sort_order: values["sort-order"],
202
+ });
203
+ }
204
+
205
+ export function buildIssueCommentPayload(values) {
206
+ return pickDefined({
207
+ comment_html: values.html,
208
+ access: values.access,
209
+ });
210
+ }
211
+
212
+ export function buildIssueLinkPayload(values, issueId) {
213
+ return pickDefined({
214
+ title: values.title,
215
+ url: values.url,
216
+ issue_id: issueId,
217
+ });
218
+ }
219
+
220
+ export function buildIssueRelationPayload(values) {
221
+ return pickDefined({
222
+ relation_type: values["relation-type"],
223
+ issues: splitCsv(values.issues),
224
+ });
225
+ }
226
+
227
+ export function inferAttachmentMimeType(filePath) {
228
+ return MIME_BY_EXTENSION[extname(filePath).toLowerCase()] || null;
229
+ }
230
+
231
+ export function resolveAssigneeRefs(refs, members) {
232
+ const resolved = [];
233
+
234
+ for (const ref of refs) {
235
+ const value = String(ref).trim();
236
+ const lowered = value.toLowerCase();
237
+ const match = members.find((member) => {
238
+ const memberId = String(member.id || "");
239
+ const email = String(member.email || "").toLowerCase();
240
+ const displayName = `${member.first_name || ""} ${member.last_name || ""}`.trim().toLowerCase();
241
+ return memberId === value || email === lowered || (displayName && displayName === lowered);
242
+ });
243
+
244
+ if (!match) {
245
+ throw new CliError(`Assignee not found in workspace members: ${value}`, {
246
+ details: {
247
+ hint: "Use `plane project members workspace` to inspect available members.",
248
+ },
249
+ });
250
+ }
251
+
252
+ resolved.push(String(match.id));
253
+ }
254
+
255
+ return resolved;
256
+ }
257
+
34
258
  function printHelp() {
35
259
  console.log(`Usage:
36
260
  plane issue ls --project <project-id> [--limit <n>] [--cursor <cursor>] [--order-by <field>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--expand <field1,field2>]
37
261
  plane issue get --project <project-id> <issue-id>
38
- plane issue create --project <project-id> --name <name> [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--labels <id1,id2>]
39
- plane issue update --project <project-id> <issue-id> [--name <name>] [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--labels <id1,id2>]
262
+ plane issue get GAEA-25
263
+ plane issue key <PROJECT-123> [--expand <field1,field2>] [--fields <field1,field2>]
264
+ plane issue search --query <text> [--project <project-id>] [--limit <n>] [--workspace-search]
265
+ plane issue labels ls --project <project-id>
266
+ plane issue labels create --project <project-id> --name <name> [--color <hex>] [--description <text>] [--parent <label-id>] [--sort-order <n>]
267
+ plane issue comments ls --project <project-id> <issue-id>
268
+ plane issue comments ls GAEA-25
269
+ plane issue comments add --project <project-id> <issue-id> --html '<p>comment</p>' [--access <value>]
270
+ plane issue comments add GAEA-25 --html '<p>comment</p>' [--access <value>]
271
+ plane issue comments update --project <project-id> <issue-id> <comment-id> --html '<p>comment</p>' [--access <value>]
272
+ plane issue comments update GAEA-25 <comment-id> --html '<p>comment</p>' [--access <value>]
273
+ plane issue activities ls --project <project-id> <issue-id>
274
+ plane issue activities ls GAEA-25
275
+ plane issue links ls --project <project-id> <issue-id>
276
+ plane issue links ls GAEA-25
277
+ plane issue links add --project <project-id> <issue-id> --url <url> [--title <text>]
278
+ plane issue links add GAEA-25 --url <url> [--title <text>]
279
+ plane issue links update --project <project-id> <issue-id> <link-id> --url <url> [--title <text>]
280
+ plane issue links update GAEA-25 <link-id> --url <url> [--title <text>]
281
+ plane issue relations ls --project <project-id> <issue-id>
282
+ plane issue relations ls GAEA-25
283
+ plane issue relations add --project <project-id> <issue-id> --relation-type <blocking|blocked_by|duplicate|relates_to|start_before|start_after|finish_before|finish_after> --issues <id1,id2>
284
+ plane issue relations add GAEA-25 --relation-type <blocking|blocked_by|duplicate|relates_to|start_before|start_after|finish_before|finish_after> --issues <id1,id2>
285
+ plane issue attachments ls --project <project-id> <issue-id>
286
+ plane issue attachments ls GAEA-25
287
+ plane issue attachments upload --project <project-id> <issue-id> --file <path> [--name <filename>] [--type <mime>]
288
+ plane issue attachments upload GAEA-25 --file <path> [--name <filename>] [--type <mime>]
289
+ plane issue create --project <project-id> --name <name> [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id-or-email1,id-or-email2>] [--labels <id1,id2>]
290
+ plane issue update --project <project-id> <issue-id> [--name <name>] [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id-or-email1,id-or-email2>] [--labels <id1,id2>]
40
291
  `);
41
292
  }
42
293
 
294
+ async function runIssueLabelsCommand(issueClient, args, context) {
295
+ const [subcommand, ...rest] = args;
296
+
297
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
298
+ printHelp();
299
+ return;
300
+ }
301
+
302
+ if (subcommand === "ls") {
303
+ const parsed = parseCommandArgs(
304
+ rest,
305
+ {
306
+ project: { type: "string" },
307
+ limit: { type: "string" },
308
+ cursor: { type: "string" },
309
+ },
310
+ false
311
+ );
312
+
313
+ ensureValue(parsed.values.project, "Project ID is required.");
314
+ const result = await issueClient.listLabels(
315
+ parsed.values.project,
316
+ pickDefined({
317
+ per_page: parsed.values.limit,
318
+ cursor: parsed.values.cursor,
319
+ })
320
+ );
321
+
322
+ printData(result, {
323
+ ...context.output,
324
+ render: renderIssueLabels,
325
+ });
326
+ return;
327
+ }
328
+
329
+ if (subcommand === "create") {
330
+ const parsed = parseCommandArgs(
331
+ rest,
332
+ {
333
+ project: { type: "string" },
334
+ name: { type: "string" },
335
+ color: { type: "string" },
336
+ description: { type: "string" },
337
+ parent: { type: "string" },
338
+ "sort-order": { type: "string" },
339
+ },
340
+ false
341
+ );
342
+
343
+ ensureValue(parsed.values.project, "Project ID is required.");
344
+ ensureValue(parsed.values.name, "Label name is required.");
345
+ const result = await issueClient.createLabel(parsed.values.project, buildIssueLabelPayload(parsed.values));
346
+ printData(result, context.output);
347
+ return;
348
+ }
349
+
350
+ throw new CliError(`Unknown issue labels subcommand: ${subcommand}`);
351
+ }
352
+
353
+ async function runIssueCommentsCommand(issueClient, args, context) {
354
+ const [subcommand, ...rest] = args;
355
+
356
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
357
+ printHelp();
358
+ return;
359
+ }
360
+
361
+ if (subcommand === "ls") {
362
+ const parsed = parseCommandArgs(
363
+ rest,
364
+ {
365
+ project: { type: "string" },
366
+ limit: { type: "string" },
367
+ cursor: { type: "string" },
368
+ "order-by": { type: "string" },
369
+ }
370
+ );
371
+
372
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
373
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
374
+ const result = await issueClient.listComments(
375
+ issueRef.projectId,
376
+ issueRef.issueId,
377
+ pickDefined({
378
+ per_page: parsed.values.limit,
379
+ cursor: parsed.values.cursor,
380
+ order_by: parsed.values["order-by"],
381
+ })
382
+ );
383
+ printData(result, {
384
+ ...context.output,
385
+ render: renderIssueComments,
386
+ });
387
+ return;
388
+ }
389
+
390
+ if (subcommand === "add") {
391
+ const parsed = parseCommandArgs(
392
+ rest,
393
+ {
394
+ project: { type: "string" },
395
+ html: { type: "string" },
396
+ access: { type: "string" },
397
+ }
398
+ );
399
+
400
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
401
+ ensureValue(parsed.values.html, "Comment HTML is required.");
402
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
403
+ const result = await issueClient.createComment(
404
+ issueRef.projectId,
405
+ issueRef.issueId,
406
+ buildIssueCommentPayload(parsed.values)
407
+ );
408
+ printData(result, context.output);
409
+ return;
410
+ }
411
+
412
+ if (subcommand === "update") {
413
+ const parsed = parseCommandArgs(
414
+ rest,
415
+ {
416
+ project: { type: "string" },
417
+ html: { type: "string" },
418
+ access: { type: "string" },
419
+ }
420
+ );
421
+
422
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
423
+ ensureValue(parsed.positionals[1], "Comment ID is required.");
424
+ ensureValue(parsed.values.html, "Comment HTML is required.");
425
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
426
+ const result = await issueClient.updateComment(
427
+ issueRef.projectId,
428
+ issueRef.issueId,
429
+ parsed.positionals[1],
430
+ buildIssueCommentPayload(parsed.values)
431
+ );
432
+ printData(result, context.output);
433
+ return;
434
+ }
435
+
436
+ throw new CliError(`Unknown issue comments subcommand: ${subcommand}`);
437
+ }
438
+
439
+ async function runIssueActivitiesCommand(issueClient, args, context) {
440
+ const [subcommand, ...rest] = args;
441
+
442
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
443
+ printHelp();
444
+ return;
445
+ }
446
+
447
+ if (subcommand !== "ls") {
448
+ throw new CliError(`Unknown issue activities subcommand: ${subcommand}`);
449
+ }
450
+
451
+ const parsed = parseCommandArgs(
452
+ rest,
453
+ {
454
+ project: { type: "string" },
455
+ limit: { type: "string" },
456
+ cursor: { type: "string" },
457
+ "order-by": { type: "string" },
458
+ }
459
+ );
460
+
461
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
462
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
463
+ const result = await issueClient.listActivities(
464
+ issueRef.projectId,
465
+ issueRef.issueId,
466
+ pickDefined({
467
+ per_page: parsed.values.limit,
468
+ cursor: parsed.values.cursor,
469
+ order_by: parsed.values["order-by"],
470
+ })
471
+ );
472
+ printData(result, {
473
+ ...context.output,
474
+ render: renderIssueActivities,
475
+ });
476
+ }
477
+
478
+ async function runIssueLinksCommand(issueClient, args, context) {
479
+ const [subcommand, ...rest] = args;
480
+
481
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
482
+ printHelp();
483
+ return;
484
+ }
485
+
486
+ if (subcommand === "ls") {
487
+ const parsed = parseCommandArgs(
488
+ rest,
489
+ {
490
+ project: { type: "string" },
491
+ }
492
+ );
493
+
494
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
495
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
496
+ const result = await issueClient.listLinks(issueRef.projectId, issueRef.issueId);
497
+ printData(result, {
498
+ ...context.output,
499
+ render: renderIssueLinks,
500
+ });
501
+ return;
502
+ }
503
+
504
+ if (subcommand === "add") {
505
+ const parsed = parseCommandArgs(
506
+ rest,
507
+ {
508
+ project: { type: "string" },
509
+ url: { type: "string" },
510
+ title: { type: "string" },
511
+ }
512
+ );
513
+
514
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
515
+ ensureValue(parsed.values.url, "Link URL is required.");
516
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
517
+ const result = await issueClient.createLink(
518
+ issueRef.projectId,
519
+ issueRef.issueId,
520
+ buildIssueLinkPayload(parsed.values, issueRef.issueId)
521
+ );
522
+ printData(result, context.output);
523
+ return;
524
+ }
525
+
526
+ if (subcommand === "update") {
527
+ const parsed = parseCommandArgs(
528
+ rest,
529
+ {
530
+ project: { type: "string" },
531
+ url: { type: "string" },
532
+ title: { type: "string" },
533
+ }
534
+ );
535
+
536
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
537
+ ensureValue(parsed.positionals[1], "Link ID is required.");
538
+ ensureValue(parsed.values.url, "Link URL is required.");
539
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
540
+ const result = await issueClient.updateLink(
541
+ issueRef.projectId,
542
+ issueRef.issueId,
543
+ parsed.positionals[1],
544
+ buildIssueLinkPayload(parsed.values, issueRef.issueId)
545
+ );
546
+ printData(result, context.output);
547
+ return;
548
+ }
549
+
550
+ throw new CliError(`Unknown issue links subcommand: ${subcommand}`);
551
+ }
552
+
553
+ async function runIssueRelationsCommand(issueClient, args, context) {
554
+ const [subcommand, ...rest] = args;
555
+
556
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
557
+ printHelp();
558
+ return;
559
+ }
560
+
561
+ if (subcommand === "ls") {
562
+ const parsed = parseCommandArgs(
563
+ rest,
564
+ {
565
+ project: { type: "string" },
566
+ }
567
+ );
568
+
569
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
570
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
571
+ const result = await issueClient.listRelations(issueRef.projectId, issueRef.issueId);
572
+ printData(result, {
573
+ ...context.output,
574
+ render: renderIssueRelations,
575
+ });
576
+ return;
577
+ }
578
+
579
+ if (subcommand === "add") {
580
+ const parsed = parseCommandArgs(
581
+ rest,
582
+ {
583
+ project: { type: "string" },
584
+ "relation-type": { type: "string" },
585
+ issues: { type: "string" },
586
+ }
587
+ );
588
+
589
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
590
+ ensureValue(parsed.values["relation-type"], "Relation type is required.");
591
+ ensureValue(parsed.values.issues, "At least one related issue ID is required.");
592
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
593
+ const result = await issueClient.createRelation(
594
+ issueRef.projectId,
595
+ issueRef.issueId,
596
+ buildIssueRelationPayload(parsed.values)
597
+ );
598
+ printData(result, context.output);
599
+ return;
600
+ }
601
+
602
+ throw new CliError(`Unknown issue relations subcommand: ${subcommand}`);
603
+ }
604
+
605
+ async function uploadAttachmentBinary(uploadData, filePath, fileName, mimeType) {
606
+ const buffer = await readFile(filePath);
607
+ const form = new FormData();
608
+
609
+ for (const [key, value] of Object.entries(uploadData.fields || {})) {
610
+ form.append(key, value);
611
+ }
612
+
613
+ form.append("file", new Blob([buffer], { type: mimeType }), fileName);
614
+
615
+ const response = await fetch(uploadData.url, {
616
+ method: "POST",
617
+ body: form,
618
+ });
619
+
620
+ if (!response.ok) {
621
+ throw new CliError(`Attachment upload failed: ${response.status} ${response.statusText}`);
622
+ }
623
+ }
624
+
625
+ async function runIssueAttachmentsCommand(issueClient, args, context) {
626
+ const [subcommand, ...rest] = args;
627
+
628
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
629
+ printHelp();
630
+ return;
631
+ }
632
+
633
+ if (subcommand === "ls") {
634
+ const parsed = parseCommandArgs(
635
+ rest,
636
+ {
637
+ project: { type: "string" },
638
+ }
639
+ );
640
+
641
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
642
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
643
+ const result = await issueClient.listAttachments(issueRef.projectId, issueRef.issueId);
644
+ printData(result, {
645
+ ...context.output,
646
+ render: renderIssueAttachments,
647
+ });
648
+ return;
649
+ }
650
+
651
+ if (subcommand === "upload") {
652
+ const parsed = parseCommandArgs(
653
+ rest,
654
+ {
655
+ project: { type: "string" },
656
+ file: { type: "string" },
657
+ name: { type: "string" },
658
+ type: { type: "string" },
659
+ }
660
+ );
661
+
662
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
663
+ ensureValue(parsed.values.file, "File path is required.");
664
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
665
+
666
+ const filePath = parsed.values.file;
667
+ const fileInfo = await stat(filePath);
668
+ const fileName = parsed.values.name || basename(filePath);
669
+ const mimeType = parsed.values.type || inferAttachmentMimeType(filePath);
670
+
671
+ if (!mimeType) {
672
+ throw new CliError("Could not infer attachment MIME type. Pass --type explicitly.");
673
+ }
674
+
675
+ const uploadSession = await issueClient.createAttachmentUpload(issueRef.projectId, issueRef.issueId, {
676
+ name: fileName,
677
+ type: mimeType,
678
+ size: fileInfo.size,
679
+ });
680
+
681
+ await uploadAttachmentBinary(uploadSession.upload_data, filePath, fileName, mimeType);
682
+ await issueClient.confirmAttachmentUpload(issueRef.projectId, issueRef.issueId, uploadSession.asset_id);
683
+
684
+ printData(
685
+ {
686
+ assetId: uploadSession.asset_id,
687
+ assetUrl: uploadSession.asset_url,
688
+ attachment: uploadSession.attachment,
689
+ uploaded: true,
690
+ },
691
+ context.output
692
+ );
693
+ return;
694
+ }
695
+
696
+ throw new CliError(`Unknown issue attachments subcommand: ${subcommand}`);
697
+ }
698
+
43
699
  export async function runIssueCommand(args, context) {
44
700
  const [subcommand, ...rest] = args;
45
701
 
@@ -49,7 +705,39 @@ export async function runIssueCommand(args, context) {
49
705
  }
50
706
 
51
707
  const config = await resolveRuntimeConfig();
52
- const issueClient = new IssueClient(new PlaneClient(config));
708
+ const planeClient = new PlaneClient(config);
709
+ const issueClient = new IssueClient(planeClient);
710
+ const projectClient = new ProjectClient(planeClient);
711
+
712
+ if (subcommand === "labels") {
713
+ await runIssueLabelsCommand(issueClient, rest, context);
714
+ return;
715
+ }
716
+
717
+ if (subcommand === "comments") {
718
+ await runIssueCommentsCommand(issueClient, rest, context);
719
+ return;
720
+ }
721
+
722
+ if (subcommand === "activities") {
723
+ await runIssueActivitiesCommand(issueClient, rest, context);
724
+ return;
725
+ }
726
+
727
+ if (subcommand === "links") {
728
+ await runIssueLinksCommand(issueClient, rest, context);
729
+ return;
730
+ }
731
+
732
+ if (subcommand === "relations") {
733
+ await runIssueRelationsCommand(issueClient, rest, context);
734
+ return;
735
+ }
736
+
737
+ if (subcommand === "attachments") {
738
+ await runIssueAttachmentsCommand(issueClient, rest, context);
739
+ return;
740
+ }
53
741
 
54
742
  if (subcommand === "ls") {
55
743
  const parsed = parseCommandArgs(
@@ -101,14 +789,66 @@ export async function runIssueCommand(args, context) {
101
789
  }
102
790
  );
103
791
 
104
- ensureValue(parsed.values.project, "Project ID is required.");
105
792
  ensureValue(parsed.positionals[0], "Issue ID is required.");
793
+ const issueRef = await resolveIssueTarget(issueClient, parsed.values.project, parsed.positionals[0]);
794
+ const result = await issueClient.get(issueRef.projectId, issueRef.issueId);
795
+ printData(result, context.output);
796
+ return;
797
+ }
106
798
 
107
- const result = await issueClient.get(parsed.values.project, parsed.positionals[0]);
799
+ if (subcommand === "key") {
800
+ const parsed = parseCommandArgs(
801
+ rest,
802
+ {
803
+ expand: { type: "string" },
804
+ fields: { type: "string" },
805
+ }
806
+ );
807
+
808
+ ensureValue(parsed.positionals[0], "Issue key is required.");
809
+ const { projectIdentifier, issueIdentifier } = parseIssueKey(parsed.positionals[0]);
810
+ const result = await issueClient.getByKey(
811
+ projectIdentifier,
812
+ issueIdentifier,
813
+ pickDefined({
814
+ expand: parsed.values.expand,
815
+ fields: parsed.values.fields,
816
+ })
817
+ );
108
818
  printData(result, context.output);
109
819
  return;
110
820
  }
111
821
 
822
+ if (subcommand === "search") {
823
+ const parsed = parseCommandArgs(
824
+ rest,
825
+ {
826
+ query: { type: "string" },
827
+ project: { type: "string" },
828
+ limit: { type: "string" },
829
+ "workspace-search": { type: "boolean" },
830
+ },
831
+ false
832
+ );
833
+
834
+ ensureValue(parsed.values.query, "Search query is required.");
835
+
836
+ const result = await issueClient.search(
837
+ pickDefined({
838
+ search: parsed.values.query,
839
+ project_id: parsed.values.project,
840
+ limit: parsed.values.limit,
841
+ workspace_search: parsed.values["workspace-search"] ? "true" : parsed.values.project ? "false" : "true",
842
+ })
843
+ );
844
+
845
+ printData(result, {
846
+ ...context.output,
847
+ render: renderIssueSearch,
848
+ });
849
+ return;
850
+ }
851
+
112
852
  if (subcommand === "create") {
113
853
  const parsed = parseCommandArgs(
114
854
  rest,
@@ -131,7 +871,13 @@ export async function runIssueCommand(args, context) {
131
871
  ensureValue(parsed.values.project, "Project ID is required.");
132
872
  ensureValue(parsed.values.name, "Issue name is required.");
133
873
 
134
- const result = await issueClient.create(parsed.values.project, buildIssuePayload(parsed.values));
874
+ const payload = buildIssuePayload(parsed.values);
875
+ if (payload.assignees) {
876
+ const members = await projectClient.listWorkspaceMembers();
877
+ payload.assignees = resolveAssigneeRefs(payload.assignees, members);
878
+ }
879
+
880
+ const result = await issueClient.create(parsed.values.project, payload);
135
881
  printData(result, context.output);
136
882
  return;
137
883
  }
@@ -162,6 +908,11 @@ export async function runIssueCommand(args, context) {
162
908
  throw new CliError("At least one update field is required.");
163
909
  }
164
910
 
911
+ if (payload.assignees) {
912
+ const members = await projectClient.listWorkspaceMembers();
913
+ payload.assignees = resolveAssigneeRefs(payload.assignees, members);
914
+ }
915
+
165
916
  const result = await issueClient.update(parsed.values.project, parsed.positionals[0], payload);
166
917
  printData(result, context.output);
167
918
  return;