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