@aaronshaf/plane 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.5",
6
+ "version": "0.1.6",
7
7
  "description": "CLI for the Plane project management API",
8
8
  "keywords": [
9
9
  "plane",
@@ -11,9 +11,10 @@ import {
11
11
  WorklogSchema,
12
12
  } from "../config.js";
13
13
  import {
14
- parseIssueRef,
15
14
  findIssueBySeq,
15
+ getMemberId,
16
16
  getStateId,
17
+ parseIssueRef,
17
18
  resolveProject,
18
19
  } from "../resolve.js";
19
20
  import { jsonMode, xmlMode, toXml } from "../output.js";
@@ -51,15 +52,26 @@ const descriptionOption = Options.optional(Options.text("description")).pipe(
51
52
  Options.withDescription("Issue description (plain text, stored as HTML)"),
52
53
  );
53
54
 
55
+ const assigneeOption = Options.optional(Options.text("assignee")).pipe(
56
+ Options.withDescription("Assign to a member (display name, email, or UUID)"),
57
+ );
58
+
59
+ const noAssigneeOption = Options.boolean("no-assignee").pipe(
60
+ Options.withDescription("Clear all assignees"),
61
+ Options.withDefault(false),
62
+ );
63
+
54
64
  export const issueUpdate = Command.make(
55
65
  "update",
56
66
  {
57
67
  state: stateOption,
58
68
  priority: priorityOption,
59
69
  description: descriptionOption,
70
+ assignee: assigneeOption,
71
+ noAssignee: noAssigneeOption,
60
72
  ref: refArg,
61
73
  },
62
- ({ ref, state, priority, description }) =>
74
+ ({ ref, state, priority, description, assignee, noAssignee }) =>
63
75
  Effect.gen(function* () {
64
76
  const { projectId, seq } = yield* parseIssueRef(ref);
65
77
  const issue = yield* findIssueBySeq(projectId, seq);
@@ -76,11 +88,17 @@ export const issueUpdate = Command.make(
76
88
  const escaped = escapeHtmlText(description.value);
77
89
  body["description_html"] = `<p>${escaped}</p>`;
78
90
  }
91
+ if (noAssignee) {
92
+ body["assignees"] = [];
93
+ } else if (assignee._tag === "Some") {
94
+ const memberId = yield* getMemberId(assignee.value);
95
+ body["assignees"] = [memberId];
96
+ }
79
97
 
80
98
  if (Object.keys(body).length === 0) {
81
99
  yield* Effect.fail(
82
100
  new Error(
83
- "Nothing to update. Specify --state, --priority, or --description",
101
+ "Nothing to update. Specify --state, --priority, --description, --assignee, or --no-assignee",
84
102
  ),
85
103
  );
86
104
  }
@@ -96,7 +114,7 @@ export const issueUpdate = Command.make(
96
114
  }),
97
115
  ).pipe(
98
116
  Command.withDescription(
99
- 'Update an issue\'s state, priority, or description. Options must come before the REF argument.\n\nExamples:\n plane issue update --state completed PROJ-29\n plane issue update --priority high WEB-5\n plane issue update --description "New description" PROJ-29\n plane issue update --state started --priority medium OPS-3',
117
+ 'Update an issue\'s state, priority, description, or assignee. Options must come before the REF argument.\n\nExamples:\n plane issue update --state completed PROJ-29\n plane issue update --priority high WEB-5\n plane issue update --assignee "Jane Doe" PROJ-29\n plane issue update --no-assignee PROJ-29\n plane issue update --description "New description" PROJ-29',
100
118
  ),
101
119
  );
102
120
 
@@ -148,16 +166,21 @@ const createDescriptionOption = Options.optional(
148
166
  Options.withDescription("Issue description (plain text, stored as HTML)"),
149
167
  );
150
168
 
169
+ const createAssigneeOption = Options.optional(Options.text("assignee")).pipe(
170
+ Options.withDescription("Assign to a member (display name, email, or UUID)"),
171
+ );
172
+
151
173
  export const issueCreate = Command.make(
152
174
  "create",
153
175
  {
154
176
  priority: createPriorityOption,
155
177
  state: createStateOption,
156
178
  description: createDescriptionOption,
179
+ assignee: createAssigneeOption,
157
180
  project: projectRefArg,
158
181
  title: titleArg,
159
182
  },
160
- ({ project, title, priority, state, description }) =>
183
+ ({ project, title, priority, state, description, assignee }) =>
161
184
  Effect.gen(function* () {
162
185
  const { key, id: projectId } = yield* resolveProject(project);
163
186
  const body: Record<string, unknown> = { name: title };
@@ -168,6 +191,10 @@ export const issueCreate = Command.make(
168
191
  const escaped = escapeHtmlText(description.value);
169
192
  body["description_html"] = `<p>${escaped}</p>`;
170
193
  }
194
+ if (assignee._tag === "Some") {
195
+ const memberId = yield* getMemberId(assignee.value);
196
+ body["assignees"] = [memberId];
197
+ }
171
198
  const raw = yield* api.post(`projects/${projectId}/issues/`, body);
172
199
  const created = yield* decodeOrFail(IssueSchema, raw);
173
200
  yield* Console.log(
@@ -176,7 +203,7 @@ export const issueCreate = Command.make(
176
203
  }),
177
204
  ).pipe(
178
205
  Command.withDescription(
179
- 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"',
206
+ 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"',
180
207
  ),
181
208
  );
182
209
 
package/src/resolve.ts CHANGED
@@ -2,8 +2,9 @@ import { Effect } from "effect";
2
2
  import { api, decodeOrFail } from "./api.js";
3
3
  import {
4
4
  IssuesResponseSchema,
5
- StatesResponseSchema,
5
+ MembersResponseSchema,
6
6
  ProjectsResponseSchema,
7
+ StatesResponseSchema,
7
8
  } from "./config.js";
8
9
 
9
10
  // Cache project list within a process invocation
@@ -74,6 +75,27 @@ export function findIssueBySeq(projectId: string, seq: number) {
74
75
  });
75
76
  }
76
77
 
78
+ export function getMemberId(nameEmailOrId: string) {
79
+ return Effect.gen(function* () {
80
+ const results = yield* decodeOrFail(
81
+ MembersResponseSchema,
82
+ yield* api.get("members/"),
83
+ );
84
+ const lower = nameEmailOrId.toLowerCase();
85
+ const member = results.find(
86
+ (m) =>
87
+ m.id === nameEmailOrId ||
88
+ m.display_name.toLowerCase() === lower ||
89
+ (m.email ?? "").toLowerCase() === lower,
90
+ );
91
+ if (!member)
92
+ return yield* Effect.fail(
93
+ new Error(`Member not found: ${nameEmailOrId}`),
94
+ );
95
+ return member.id;
96
+ });
97
+ }
98
+
77
99
  export function getStateId(projectId: string, nameOrGroup: string) {
78
100
  return Effect.gen(function* () {
79
101
  const raw = yield* api.get(`projects/${projectId}/states/`);
@@ -41,6 +41,15 @@ const STATES = [
41
41
  { id: "s-todo", name: "Todo", group: "unstarted" },
42
42
  ];
43
43
 
44
+ const MEMBERS = [
45
+ {
46
+ id: "m-alice",
47
+ display_name: "Alice",
48
+ email: "alice@example.com",
49
+ },
50
+ { id: "m-bob", display_name: "Bob", email: "bob@example.com" },
51
+ ];
52
+
44
53
  const server = setupServer(
45
54
  http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
46
55
  HttpResponse.json({ results: PROJECTS }),
@@ -51,6 +60,9 @@ const server = setupServer(
51
60
  http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () =>
52
61
  HttpResponse.json({ results: STATES }),
53
62
  ),
63
+ http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () =>
64
+ HttpResponse.json(MEMBERS),
65
+ ),
54
66
  );
55
67
 
56
68
  beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
@@ -120,6 +132,8 @@ describe("issueUpdate", () => {
120
132
  state: { _tag: "Some", value: "completed" },
121
133
  priority: { _tag: "None" },
122
134
  description: { _tag: "None" },
135
+ assignee: { _tag: "None" },
136
+ noAssignee: false,
123
137
  }),
124
138
  );
125
139
  } finally {
@@ -158,6 +172,8 @@ describe("issueUpdate", () => {
158
172
  state: { _tag: "None" },
159
173
  priority: { _tag: "Some", value: "urgent" },
160
174
  description: { _tag: "None" },
175
+ assignee: { _tag: "None" },
176
+ noAssignee: false,
161
177
  }),
162
178
  );
163
179
  } finally {
@@ -176,6 +192,8 @@ describe("issueUpdate", () => {
176
192
  state: { _tag: "None" },
177
193
  priority: { _tag: "None" },
178
194
  description: { _tag: "None" },
195
+ assignee: { _tag: "None" },
196
+ noAssignee: false,
179
197
  }),
180
198
  ),
181
199
  );
@@ -278,6 +296,7 @@ describe("issueCreate", () => {
278
296
  priority: { _tag: "None" },
279
297
  state: { _tag: "None" },
280
298
  description: { _tag: "None" },
299
+ assignee: { _tag: "None" },
281
300
  }),
282
301
  );
283
302
  } finally {
@@ -318,6 +337,7 @@ describe("issueCreate", () => {
318
337
  priority: { _tag: "Some", value: "high" },
319
338
  state: { _tag: "Some", value: "completed" },
320
339
  description: { _tag: "None" },
340
+ assignee: { _tag: "None" },
321
341
  }),
322
342
  );
323
343
  } finally {
@@ -355,6 +375,7 @@ describe("issueCreate description", () => {
355
375
  priority: { _tag: "None" },
356
376
  state: { _tag: "None" },
357
377
  description: { _tag: "Some", value: "Some context here" },
378
+ assignee: { _tag: "None" },
358
379
  }),
359
380
  );
360
381
 
@@ -389,6 +410,7 @@ describe("issueCreate description", () => {
389
410
  priority: { _tag: "None" },
390
411
  state: { _tag: "None" },
391
412
  description: { _tag: "Some", value: "<script>alert(1)</script>" },
413
+ assignee: { _tag: "None" },
392
414
  }),
393
415
  );
394
416
 
@@ -423,6 +445,8 @@ describe("issueUpdate description", () => {
423
445
  state: { _tag: "None" },
424
446
  priority: { _tag: "None" },
425
447
  description: { _tag: "Some", value: "Updated description" },
448
+ assignee: { _tag: "None" },
449
+ noAssignee: false,
426
450
  }),
427
451
  );
428
452
 
@@ -456,6 +480,8 @@ describe("issueUpdate description", () => {
456
480
  state: { _tag: "None" },
457
481
  priority: { _tag: "None" },
458
482
  description: { _tag: "Some", value: "<b>bold</b>" },
483
+ assignee: { _tag: "None" },
484
+ noAssignee: false,
459
485
  }),
460
486
  );
461
487
 
@@ -464,6 +490,142 @@ describe("issueUpdate description", () => {
464
490
  });
465
491
  });
466
492
 
493
+ describe("issueUpdate assignee", () => {
494
+ it("sets assignee by display name", async () => {
495
+ let patchedBody: unknown;
496
+ server.use(
497
+ http.patch(
498
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
499
+ async ({ request }) => {
500
+ patchedBody = await request.json();
501
+ return HttpResponse.json({
502
+ id: "i1",
503
+ sequence_id: 29,
504
+ name: "Migrate Button",
505
+ priority: "high",
506
+ state: "s1",
507
+ });
508
+ },
509
+ ),
510
+ );
511
+
512
+ const { issueUpdate } = await import("@/commands/issue");
513
+ await Effect.runPromise(
514
+ (issueUpdate as any).handler({
515
+ ref: "ACME-29",
516
+ state: { _tag: "None" },
517
+ priority: { _tag: "None" },
518
+ description: { _tag: "None" },
519
+ assignee: { _tag: "Some", value: "Alice" },
520
+ noAssignee: false,
521
+ }),
522
+ );
523
+
524
+ expect((patchedBody as any).assignees).toEqual(["m-alice"]);
525
+ });
526
+
527
+ it("clears assignees with --no-assignee", async () => {
528
+ let patchedBody: unknown;
529
+ server.use(
530
+ http.patch(
531
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
532
+ async ({ request }) => {
533
+ patchedBody = await request.json();
534
+ return HttpResponse.json({
535
+ id: "i1",
536
+ sequence_id: 29,
537
+ name: "Migrate Button",
538
+ priority: "high",
539
+ state: "s1",
540
+ });
541
+ },
542
+ ),
543
+ );
544
+
545
+ const { issueUpdate } = await import("@/commands/issue");
546
+ await Effect.runPromise(
547
+ (issueUpdate as any).handler({
548
+ ref: "ACME-29",
549
+ state: { _tag: "None" },
550
+ priority: { _tag: "None" },
551
+ description: { _tag: "None" },
552
+ assignee: { _tag: "None" },
553
+ noAssignee: true,
554
+ }),
555
+ );
556
+
557
+ expect((patchedBody as any).assignees).toEqual([]);
558
+ });
559
+
560
+ it("resolves assignee by email", async () => {
561
+ let patchedBody: unknown;
562
+ server.use(
563
+ http.patch(
564
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
565
+ async ({ request }) => {
566
+ patchedBody = await request.json();
567
+ return HttpResponse.json({
568
+ id: "i1",
569
+ sequence_id: 29,
570
+ name: "Migrate Button",
571
+ priority: "high",
572
+ state: "s1",
573
+ });
574
+ },
575
+ ),
576
+ );
577
+
578
+ const { issueUpdate } = await import("@/commands/issue");
579
+ await Effect.runPromise(
580
+ (issueUpdate as any).handler({
581
+ ref: "ACME-29",
582
+ state: { _tag: "None" },
583
+ priority: { _tag: "None" },
584
+ description: { _tag: "None" },
585
+ assignee: { _tag: "Some", value: "bob@example.com" },
586
+ noAssignee: false,
587
+ }),
588
+ );
589
+
590
+ expect((patchedBody as any).assignees).toEqual(["m-bob"]);
591
+ });
592
+ });
593
+
594
+ describe("issueCreate assignee", () => {
595
+ it("sets assignee by display name on create", async () => {
596
+ let postedBody: unknown;
597
+ server.use(
598
+ http.post(
599
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
600
+ async ({ request }) => {
601
+ postedBody = await request.json();
602
+ return HttpResponse.json({
603
+ id: "new-assignee",
604
+ sequence_id: 300,
605
+ name: (postedBody as any).name,
606
+ priority: "none",
607
+ state: "s1",
608
+ });
609
+ },
610
+ ),
611
+ );
612
+
613
+ const { issueCreate } = await import("@/commands/issue");
614
+ await Effect.runPromise(
615
+ (issueCreate as any).handler({
616
+ project: "ACME",
617
+ title: "Assigned issue",
618
+ priority: { _tag: "None" },
619
+ state: { _tag: "None" },
620
+ description: { _tag: "None" },
621
+ assignee: { _tag: "Some", value: "Alice" },
622
+ }),
623
+ );
624
+
625
+ expect((postedBody as any).assignees).toEqual(["m-alice"]);
626
+ });
627
+ });
628
+
467
629
  describe("issueDelete", () => {
468
630
  it("deletes an issue", async () => {
469
631
  let deleted = false;