@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 +1 -1
- package/src/commands/issue.ts +33 -6
- package/src/resolve.ts +23 -1
- package/tests/issue-commands.test.ts +162 -0
package/package.json
CHANGED
package/src/commands/issue.ts
CHANGED
|
@@ -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 --
|
|
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
|
|
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
|
-
|
|
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;
|