@aaronshaf/plane 0.1.8 → 0.1.10

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,5 +1,5 @@
1
1
  import { Command, Options, Args } from "@effect/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { api, decodeOrFail } from "../api.js";
4
4
  import {
5
5
  IssueSchema,
@@ -12,7 +12,6 @@ import {
12
12
  } from "../config.js";
13
13
  import {
14
14
  findIssueBySeq,
15
- getEstimatePointId,
16
15
  getLabelId,
17
16
  getMemberId,
18
17
  getStateId,
@@ -25,23 +24,48 @@ import { escapeHtmlText } from "../format.js";
25
24
  const refArg = Args.text({ name: "ref" }).pipe(
26
25
  Args.withDescription("Issue reference, e.g. PROJ-29"),
27
26
  );
28
-
27
+ // --- Typed payload interfaces ---
28
+ interface IssueUpdatePayload {
29
+ state?: string;
30
+ priority?: string;
31
+ name?: string;
32
+ description_html?: string;
33
+ assignees?: string[];
34
+ label_ids?: string[];
35
+ }
36
+
37
+ interface IssueCreatePayload {
38
+ name: string;
39
+ priority?: string;
40
+ state?: string;
41
+ description_html?: string;
42
+ assignees?: string[];
43
+ label_ids?: string[];
44
+ }
45
+
46
+ interface WorklogPayload {
47
+ duration: number;
48
+ description?: string;
49
+ }
29
50
  // --- issue get ---
30
-
31
- export const issueGet = Command.make("get", { ref: refArg }, ({ ref }) =>
32
- Effect.gen(function* () {
51
+ export function issueGetHandler({ ref }: { ref: string }) {
52
+ return Effect.gen(function* () {
33
53
  const { projectId, seq } = yield* parseIssueRef(ref);
34
54
  const issue = yield* findIssueBySeq(projectId, seq);
35
55
  yield* Console.log(JSON.stringify(issue, null, 2));
36
- }),
56
+ });
57
+ }
58
+
59
+ export const issueGet = Command.make(
60
+ "get",
61
+ { ref: refArg },
62
+ issueGetHandler,
37
63
  ).pipe(
38
64
  Command.withDescription(
39
65
  "Print full JSON for an issue. Useful for inspecting all fields (state, priority, assignees, labels, etc.).",
40
66
  ),
41
67
  );
42
-
43
68
  // --- issue update ---
44
-
45
69
  const stateOption = Options.optional(Options.text("state")).pipe(
46
70
  Options.withDescription("State group or name (e.g. backlog, completed)"),
47
71
  );
@@ -66,17 +90,81 @@ const labelOption = Options.optional(Options.text("label")).pipe(
66
90
  Options.withDescription("Set issue label by name"),
67
91
  );
68
92
 
69
- const estimateOption = Options.optional(Options.text("estimate")).pipe(
70
- Options.withDescription(
71
- "Estimate point value (e.g. '3', 'Medium', 'L') — resolved to UUID from the project's estimate scheme",
72
- ),
73
- );
74
-
75
93
  const noAssigneeOption = Options.boolean("no-assignee").pipe(
76
94
  Options.withDescription("Clear all assignees"),
77
95
  Options.withDefault(false),
78
96
  );
79
97
 
98
+ export function issueUpdateHandler({
99
+ ref,
100
+ state,
101
+ priority,
102
+ title,
103
+ description,
104
+ assignee,
105
+ label,
106
+ noAssignee,
107
+ }: {
108
+ ref: string;
109
+ state: Option.Option<string>;
110
+ priority: Option.Option<string>;
111
+ title: Option.Option<string>;
112
+ description: Option.Option<string>;
113
+ assignee: Option.Option<string>;
114
+ label: Option.Option<string>;
115
+ noAssignee: boolean;
116
+ }) {
117
+ return Effect.gen(function* () {
118
+ const { projectId, seq } = yield* parseIssueRef(ref);
119
+ const issue = yield* findIssueBySeq(projectId, seq);
120
+
121
+ const body: IssueUpdatePayload = {};
122
+
123
+ if (state._tag === "Some") {
124
+ body.state = yield* getStateId(projectId, state.value);
125
+ }
126
+ if (priority._tag === "Some") {
127
+ body.priority = priority.value;
128
+ }
129
+ if (title._tag === "Some") {
130
+ body.name = title.value;
131
+ }
132
+ if (description._tag === "Some") {
133
+ const escaped = escapeHtmlText(description.value);
134
+ body.description_html = `<p>${escaped}</p>`;
135
+ }
136
+ if (noAssignee) {
137
+ body.assignees = [];
138
+ } else if (assignee._tag === "Some") {
139
+ const memberId = yield* getMemberId(assignee.value);
140
+ body.assignees = [memberId];
141
+ }
142
+ if (label._tag === "Some") {
143
+ const labelId = yield* getLabelId(projectId, label.value);
144
+ body.label_ids = [labelId];
145
+ }
146
+
147
+ if (Object.keys(body).length === 0) {
148
+ yield* Effect.fail(
149
+ new Error(
150
+ "Nothing to update. Specify --state, --priority, --title, --description, --assignee, --label, or --no-assignee",
151
+ ),
152
+ );
153
+ }
154
+
155
+ const raw = yield* api.patch(
156
+ `projects/${projectId}/issues/${issue.id}/`,
157
+ body,
158
+ );
159
+ const updated = yield* decodeOrFail(IssueSchema, raw);
160
+ const stateName =
161
+ typeof updated.state === "object" ? updated.state.name : updated.state;
162
+ yield* Console.log(
163
+ `Updated ${ref}: state=${stateName} priority=${updated.priority}`,
164
+ );
165
+ });
166
+ }
167
+
80
168
  export const issueUpdate = Command.make(
81
169
  "update",
82
170
  {
@@ -86,107 +174,48 @@ export const issueUpdate = Command.make(
86
174
  description: descriptionOption,
87
175
  assignee: assigneeOption,
88
176
  label: labelOption,
89
- estimate: estimateOption,
90
177
  noAssignee: noAssigneeOption,
91
178
  ref: refArg,
92
179
  },
93
- ({
94
- ref,
95
- state,
96
- priority,
97
- title,
98
- description,
99
- assignee,
100
- label,
101
- estimate,
102
- noAssignee,
103
- }) =>
104
- Effect.gen(function* () {
105
- const { projectId, seq } = yield* parseIssueRef(ref);
106
- const issue = yield* findIssueBySeq(projectId, seq);
107
-
108
- const body: Record<string, unknown> = {};
109
-
110
- if (state._tag === "Some") {
111
- body["state"] = yield* getStateId(projectId, state.value);
112
- }
113
- if (priority._tag === "Some") {
114
- body["priority"] = priority.value;
115
- }
116
- if (title._tag === "Some") {
117
- body["name"] = title.value;
118
- }
119
- if (description._tag === "Some") {
120
- const escaped = escapeHtmlText(description.value);
121
- body["description_html"] = `<p>${escaped}</p>`;
122
- }
123
- if (noAssignee) {
124
- body["assignees"] = [];
125
- } else if (assignee._tag === "Some") {
126
- const memberId = yield* getMemberId(assignee.value);
127
- body["assignees"] = [memberId];
128
- }
129
- if (label._tag === "Some") {
130
- const labelId = yield* getLabelId(projectId, label.value);
131
- body["label_ids"] = [labelId];
132
- }
133
- if (estimate._tag === "Some") {
134
- body["estimate_point"] = yield* getEstimatePointId(
135
- projectId,
136
- estimate.value,
137
- );
138
- }
139
-
140
- if (Object.keys(body).length === 0) {
141
- yield* Effect.fail(
142
- new Error(
143
- "Nothing to update. Specify --state, --priority, --title, --description, --assignee, --label, --estimate, or --no-assignee",
144
- ),
145
- );
146
- }
147
-
148
- const raw = yield* api.patch(
149
- `projects/${projectId}/issues/${issue.id}/`,
150
- body,
151
- );
152
- const updated = yield* decodeOrFail(IssueSchema, raw);
153
- yield* Console.log(
154
- `Updated ${ref}: state=${String(updated.state)} priority=${updated.priority}`,
155
- );
156
- }),
180
+ issueUpdateHandler,
157
181
  ).pipe(
158
182
  Command.withDescription(
159
183
  'Update an issue\'s state, priority, title, 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 --title "New issue title" PROJ-29\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',
160
184
  ),
161
185
  );
162
-
163
186
  // --- issue comment ---
164
-
165
187
  const textArg = Args.text({ name: "text" }).pipe(
166
188
  Args.withDescription("Comment text to add"),
167
189
  );
168
190
 
191
+ export function issueCommentHandler({
192
+ ref,
193
+ text,
194
+ }: {
195
+ ref: string;
196
+ text: string;
197
+ }) {
198
+ return Effect.gen(function* () {
199
+ const { projectId, seq } = yield* parseIssueRef(ref);
200
+ const issue = yield* findIssueBySeq(projectId, seq);
201
+ const escaped = escapeHtmlText(text);
202
+ yield* api.post(`projects/${projectId}/issues/${issue.id}/comments/`, {
203
+ comment_html: `<p>${escaped}</p>`,
204
+ });
205
+ yield* Console.log(`Comment added to ${ref}`);
206
+ });
207
+ }
208
+
169
209
  export const issueComment = Command.make(
170
210
  "comment",
171
211
  { ref: refArg, text: textArg },
172
- ({ ref, text }) =>
173
- Effect.gen(function* () {
174
- const { projectId, seq } = yield* parseIssueRef(ref);
175
- const issue = yield* findIssueBySeq(projectId, seq);
176
- const escaped = escapeHtmlText(text);
177
- yield* api.post(`projects/${projectId}/issues/${issue.id}/comments/`, {
178
- comment_html: `<p>${escaped}</p>`,
179
- });
180
- yield* Console.log(`Comment added to ${ref}`);
181
- }),
212
+ issueCommentHandler,
182
213
  ).pipe(
183
214
  Command.withDescription(
184
215
  'Add a comment to an issue. The text is wrapped in <p> tags and HTML-escaped.\n\nExample:\n plane issue comment PROJ-29 "Fixed in latest build"',
185
216
  ),
186
217
  );
187
-
188
218
  // --- issue create ---
189
-
190
219
  const titleArg = Args.text({ name: "title" }).pipe(
191
220
  Args.withDescription("Issue title"),
192
221
  );
@@ -216,6 +245,49 @@ const createLabelOption = Options.optional(Options.text("label")).pipe(
216
245
  Options.withDescription("Set issue label by name"),
217
246
  );
218
247
 
248
+ export function issueCreateHandler({
249
+ project,
250
+ title,
251
+ priority,
252
+ state,
253
+ description,
254
+ assignee,
255
+ label,
256
+ }: {
257
+ project: string;
258
+ title: string;
259
+ priority: Option.Option<string>;
260
+ state: Option.Option<string>;
261
+ description: Option.Option<string>;
262
+ assignee: Option.Option<string>;
263
+ label: Option.Option<string>;
264
+ }) {
265
+ return Effect.gen(function* () {
266
+ const { key, id: projectId } = yield* resolveProject(project);
267
+ const body: IssueCreatePayload = { name: title };
268
+ if (priority._tag === "Some") body.priority = priority.value;
269
+ if (state._tag === "Some")
270
+ body.state = yield* getStateId(projectId, state.value);
271
+ if (description._tag === "Some") {
272
+ const escaped = escapeHtmlText(description.value);
273
+ body.description_html = `<p>${escaped}</p>`;
274
+ }
275
+ if (assignee._tag === "Some") {
276
+ const memberId = yield* getMemberId(assignee.value);
277
+ body.assignees = [memberId];
278
+ }
279
+ if (label._tag === "Some") {
280
+ const labelId = yield* getLabelId(projectId, label.value);
281
+ body.label_ids = [labelId];
282
+ }
283
+ const raw = yield* api.post(`projects/${projectId}/issues/`, body);
284
+ const created = yield* decodeOrFail(IssueSchema, raw);
285
+ yield* Console.log(
286
+ `Created ${key}-${created.sequence_id}: ${created.name}`,
287
+ );
288
+ });
289
+ }
290
+
219
291
  export const issueCreate = Command.make(
220
292
  "create",
221
293
  {
@@ -224,103 +296,62 @@ export const issueCreate = Command.make(
224
296
  description: createDescriptionOption,
225
297
  assignee: createAssigneeOption,
226
298
  label: createLabelOption,
227
- estimate: estimateOption,
228
299
  project: projectRefArg,
229
300
  title: titleArg,
230
301
  },
231
- ({
232
- project,
233
- title,
234
- priority,
235
- state,
236
- description,
237
- assignee,
238
- label,
239
- estimate,
240
- }) =>
241
- Effect.gen(function* () {
242
- const { key, id: projectId } = yield* resolveProject(project);
243
- const body: Record<string, unknown> = { name: title };
244
- if (priority._tag === "Some") body["priority"] = priority.value;
245
- if (state._tag === "Some")
246
- body["state"] = yield* getStateId(projectId, state.value);
247
- if (description._tag === "Some") {
248
- const escaped = escapeHtmlText(description.value);
249
- body["description_html"] = `<p>${escaped}</p>`;
250
- }
251
- if (assignee._tag === "Some") {
252
- const memberId = yield* getMemberId(assignee.value);
253
- body["assignees"] = [memberId];
254
- }
255
- if (label._tag === "Some") {
256
- const labelId = yield* getLabelId(projectId, label.value);
257
- body["label_ids"] = [labelId];
258
- }
259
- if (estimate._tag === "Some") {
260
- body["estimate_point"] = yield* getEstimatePointId(
261
- projectId,
262
- estimate.value,
263
- );
264
- }
265
- const raw = yield* api.post(`projects/${projectId}/issues/`, body);
266
- const created = yield* decodeOrFail(IssueSchema, raw);
267
- yield* Console.log(
268
- `Created ${key}-${created.sequence_id}: ${created.name}`,
269
- );
270
- }),
302
+ issueCreateHandler,
271
303
  ).pipe(
272
304
  Command.withDescription(
273
305
  '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"',
274
306
  ),
275
307
  );
276
-
277
308
  // --- issue activity ---
309
+ export function issueActivityHandler({ ref }: { ref: string }) {
310
+ return Effect.gen(function* () {
311
+ const { projectId, seq } = yield* parseIssueRef(ref);
312
+ const issue = yield* findIssueBySeq(projectId, seq);
313
+ const raw = yield* api.get(
314
+ `projects/${projectId}/issues/${issue.id}/activities/`,
315
+ );
316
+ const { results } = yield* decodeOrFail(ActivitiesResponseSchema, raw);
317
+ if (jsonMode) {
318
+ yield* Console.log(JSON.stringify(results, null, 2));
319
+ return;
320
+ }
321
+ if (xmlMode) {
322
+ yield* Console.log(toXml(results));
323
+ return;
324
+ }
325
+ if (results.length === 0) {
326
+ yield* Console.log("No activity found");
327
+ return;
328
+ }
329
+ const lines = results.map((a) => {
330
+ const who = a.actor_detail?.display_name ?? "?";
331
+ const when = a.created_at.slice(0, 16).replace("T", " ");
332
+ if (a.field) {
333
+ const from = a.old_value ?? "—";
334
+ const to = a.new_value ?? "—";
335
+ return `${when} ${who} ${a.field}: ${from} → ${to}`;
336
+ }
337
+ return `${when} ${who} ${a.verb ?? "updated"}`;
338
+ });
339
+ yield* Console.log(lines.join("\n"));
340
+ });
341
+ }
278
342
 
279
343
  export const issueActivity = Command.make(
280
344
  "activity",
281
345
  { ref: refArg },
282
- ({ ref }) =>
283
- Effect.gen(function* () {
284
- const { projectId, seq } = yield* parseIssueRef(ref);
285
- const issue = yield* findIssueBySeq(projectId, seq);
286
- const raw = yield* api.get(
287
- `projects/${projectId}/issues/${issue.id}/activities/`,
288
- );
289
- const { results } = yield* decodeOrFail(ActivitiesResponseSchema, raw);
290
- if (jsonMode) {
291
- yield* Console.log(JSON.stringify(results, null, 2));
292
- return;
293
- }
294
- if (xmlMode) {
295
- yield* Console.log(toXml(results));
296
- return;
297
- }
298
- if (results.length === 0) {
299
- yield* Console.log("No activity found");
300
- return;
301
- }
302
- const lines = results.map((a) => {
303
- const who = a.actor_detail?.display_name ?? "?";
304
- const when = a.created_at.slice(0, 16).replace("T", " ");
305
- if (a.field) {
306
- const from = a.old_value ?? "—";
307
- const to = a.new_value ?? "—";
308
- return `${when} ${who} ${a.field}: ${from} → ${to}`;
309
- }
310
- return `${when} ${who} ${a.verb ?? "updated"}`;
311
- });
312
- yield* Console.log(lines.join("\n"));
313
- }),
346
+ issueActivityHandler,
314
347
  ).pipe(
315
348
  Command.withDescription(
316
349
  "Show audit trail for an issue — who changed what and when.\n\nExample:\n plane issue activity PROJ-29",
317
350
  ),
318
351
  );
319
-
320
352
  // --- issue link list ---
321
-
322
- export const issueLinkList = Command.make("list", { ref: refArg }, ({ ref }) =>
323
- Effect.gen(function* () {
353
+ export function issueLinkListHandler({ ref }: { ref: string }) {
354
+ return Effect.gen(function* () {
324
355
  const { projectId, seq } = yield* parseIssueRef(ref);
325
356
  const issue = yield* findIssueBySeq(projectId, seq);
326
357
  const raw = yield* api.get(
@@ -343,11 +374,15 @@ export const issueLinkList = Command.make("list", { ref: refArg }, ({ ref }) =>
343
374
  (l) => `${l.id} ${l.title ?? "(no title)"} ${l.url}`,
344
375
  );
345
376
  yield* Console.log(lines.join("\n"));
346
- }),
347
- ).pipe(Command.withDescription("List URL links attached to an issue."));
377
+ });
378
+ }
348
379
 
380
+ export const issueLinkList = Command.make(
381
+ "list",
382
+ { ref: refArg },
383
+ issueLinkListHandler,
384
+ ).pipe(Command.withDescription("List URL links attached to an issue."));
349
385
  // --- issue link add ---
350
-
351
386
  const urlArg = Args.text({ name: "url" }).pipe(
352
387
  Args.withDescription("URL to link"),
353
388
  );
@@ -355,140 +390,171 @@ const linkTitleOption = Options.optional(Options.text("title")).pipe(
355
390
  Options.withDescription("Human-readable title for the link"),
356
391
  );
357
392
 
393
+ export function issueLinkAddHandler({
394
+ ref,
395
+ url,
396
+ title,
397
+ }: {
398
+ ref: string;
399
+ url: string;
400
+ title: Option.Option<string>;
401
+ }) {
402
+ return Effect.gen(function* () {
403
+ const { projectId, seq } = yield* parseIssueRef(ref);
404
+ const issue = yield* findIssueBySeq(projectId, seq);
405
+ const body: Record<string, string> = { url };
406
+ if (title._tag === "Some") body["title"] = title.value;
407
+ const raw = yield* api.post(
408
+ `projects/${projectId}/issues/${issue.id}/issue-links/`,
409
+ body,
410
+ );
411
+ const link = yield* decodeOrFail(IssueLinkSchema, raw);
412
+ yield* Console.log(`Link added: ${link.id} ${link.url}`);
413
+ });
414
+ }
415
+
358
416
  export const issueLinkAdd = Command.make(
359
417
  "add",
360
418
  { title: linkTitleOption, ref: refArg, url: urlArg },
361
- ({ ref, url, title }) =>
362
- Effect.gen(function* () {
363
- const { projectId, seq } = yield* parseIssueRef(ref);
364
- const issue = yield* findIssueBySeq(projectId, seq);
365
- const body: Record<string, string> = { url };
366
- if (title._tag === "Some") body["title"] = title.value;
367
- const raw = yield* api.post(
368
- `projects/${projectId}/issues/${issue.id}/issue-links/`,
369
- body,
370
- );
371
- const link = yield* decodeOrFail(IssueLinkSchema, raw);
372
- yield* Console.log(`Link added: ${link.id} ${link.url}`);
373
- }),
419
+ issueLinkAddHandler,
374
420
  ).pipe(
375
421
  Command.withDescription(
376
422
  'Attach a URL link to an issue.\n\nExamples:\n plane issue link add PROJ-29 https://github.com/org/repo/pull/42\n plane issue link add --title "Design doc" PROJ-29 https://docs.example.com',
377
423
  ),
378
424
  );
379
-
380
425
  // --- issue link remove ---
381
-
382
426
  const linkIdArg = Args.text({ name: "link-id" }).pipe(
383
427
  Args.withDescription("Link ID (from 'plane issue link list')"),
384
428
  );
385
429
 
430
+ export function issueLinkRemoveHandler({
431
+ ref,
432
+ linkId,
433
+ }: {
434
+ ref: string;
435
+ linkId: string;
436
+ }) {
437
+ return Effect.gen(function* () {
438
+ const { projectId, seq } = yield* parseIssueRef(ref);
439
+ const issue = yield* findIssueBySeq(projectId, seq);
440
+ yield* api.delete(
441
+ `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`,
442
+ );
443
+ yield* Console.log(`Link ${linkId} removed from ${ref}`);
444
+ });
445
+ }
446
+
386
447
  export const issueLinkRemove = Command.make(
387
448
  "remove",
388
449
  { ref: refArg, linkId: linkIdArg },
389
- ({ ref, linkId }) =>
390
- Effect.gen(function* () {
391
- const { projectId, seq } = yield* parseIssueRef(ref);
392
- const issue = yield* findIssueBySeq(projectId, seq);
393
- yield* api.delete(
394
- `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`,
395
- );
396
- yield* Console.log(`Link ${linkId} removed from ${ref}`);
397
- }),
450
+ issueLinkRemoveHandler,
398
451
  ).pipe(Command.withDescription("Remove a URL link from an issue by link ID."));
399
-
400
452
  // --- issue link (parent) ---
401
-
402
453
  export const issueLink = Command.make("link").pipe(
403
454
  Command.withDescription(
404
455
  "Manage URL links on an issue. Subcommands: list, add, remove",
405
456
  ),
406
457
  Command.withSubcommands([issueLinkList, issueLinkAdd, issueLinkRemove]),
407
458
  );
408
-
409
459
  // --- issue comments list ---
460
+ export function issueCommentsListHandler({ ref }: { ref: string }) {
461
+ return Effect.gen(function* () {
462
+ const { projectId, seq } = yield* parseIssueRef(ref);
463
+ const issue = yield* findIssueBySeq(projectId, seq);
464
+ const raw = yield* api.get(
465
+ `projects/${projectId}/issues/${issue.id}/comments/`,
466
+ );
467
+ const { results } = yield* decodeOrFail(CommentsResponseSchema, raw);
468
+ if (jsonMode) {
469
+ yield* Console.log(JSON.stringify(results, null, 2));
470
+ return;
471
+ }
472
+ if (xmlMode) {
473
+ yield* Console.log(toXml(results));
474
+ return;
475
+ }
476
+ if (results.length === 0) {
477
+ yield* Console.log("No comments");
478
+ return;
479
+ }
480
+ const lines = results.map((c) => {
481
+ const who = c.actor_detail?.display_name ?? "?";
482
+ const when = c.created_at.slice(0, 16).replace("T", " ");
483
+ const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim();
484
+ return `${c.id} ${when} ${who}: ${text}`;
485
+ });
486
+ yield* Console.log(lines.join("\n"));
487
+ });
488
+ }
410
489
 
411
490
  export const issueCommentsList = Command.make(
412
491
  "list",
413
492
  { ref: refArg },
414
- ({ ref }) =>
415
- Effect.gen(function* () {
416
- const { projectId, seq } = yield* parseIssueRef(ref);
417
- const issue = yield* findIssueBySeq(projectId, seq);
418
- const raw = yield* api.get(
419
- `projects/${projectId}/issues/${issue.id}/comments/`,
420
- );
421
- const { results } = yield* decodeOrFail(CommentsResponseSchema, raw);
422
- if (jsonMode) {
423
- yield* Console.log(JSON.stringify(results, null, 2));
424
- return;
425
- }
426
- if (xmlMode) {
427
- yield* Console.log(toXml(results));
428
- return;
429
- }
430
- if (results.length === 0) {
431
- yield* Console.log("No comments");
432
- return;
433
- }
434
- const lines = results.map((c) => {
435
- const who = c.actor_detail?.display_name ?? "?";
436
- const when = c.created_at.slice(0, 16).replace("T", " ");
437
- const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim();
438
- return `${c.id} ${when} ${who}: ${text}`;
439
- });
440
- yield* Console.log(lines.join("\n"));
441
- }),
493
+ issueCommentsListHandler,
442
494
  ).pipe(
443
495
  Command.withDescription(
444
496
  "List comments on an issue. Shows comment ID, timestamp, author, and plain text.\n\nExample:\n plane issue comments list PROJ-29",
445
497
  ),
446
498
  );
447
-
448
499
  // --- issue comment update ---
449
-
450
500
  const commentIdArg = Args.text({ name: "comment-id" }).pipe(
451
501
  Args.withDescription("Comment ID (from 'plane issue comments list')"),
452
502
  );
453
503
 
504
+ export function issueCommentUpdateHandler({
505
+ ref,
506
+ commentId,
507
+ text,
508
+ }: {
509
+ ref: string;
510
+ commentId: string;
511
+ text: string;
512
+ }) {
513
+ return Effect.gen(function* () {
514
+ const { projectId, seq } = yield* parseIssueRef(ref);
515
+ const issue = yield* findIssueBySeq(projectId, seq);
516
+ const escaped = escapeHtmlText(text);
517
+ yield* api.patch(
518
+ `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
519
+ { comment_html: `<p>${escaped}</p>` },
520
+ );
521
+ yield* Console.log(`Comment ${commentId} updated`);
522
+ });
523
+ }
524
+
454
525
  export const issueCommentUpdate = Command.make(
455
526
  "update",
456
527
  { ref: refArg, commentId: commentIdArg, text: textArg },
457
- ({ ref, commentId, text }) =>
458
- Effect.gen(function* () {
459
- const { projectId, seq } = yield* parseIssueRef(ref);
460
- const issue = yield* findIssueBySeq(projectId, seq);
461
- const escaped = escapeHtmlText(text);
462
- yield* api.patch(
463
- `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
464
- { comment_html: `<p>${escaped}</p>` },
465
- );
466
- yield* Console.log(`Comment ${commentId} updated`);
467
- }),
528
+ issueCommentUpdateHandler,
468
529
  ).pipe(
469
530
  Command.withDescription(
470
531
  'Edit an existing comment.\n\nExample:\n plane issue comments update PROJ-29 <comment-id> "Updated text"',
471
532
  ),
472
533
  );
473
-
474
534
  // --- issue comment delete ---
535
+ export function issueCommentDeleteHandler({
536
+ ref,
537
+ commentId,
538
+ }: {
539
+ ref: string;
540
+ commentId: string;
541
+ }) {
542
+ return Effect.gen(function* () {
543
+ const { projectId, seq } = yield* parseIssueRef(ref);
544
+ const issue = yield* findIssueBySeq(projectId, seq);
545
+ yield* api.delete(
546
+ `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
547
+ );
548
+ yield* Console.log(`Comment ${commentId} deleted`);
549
+ });
550
+ }
475
551
 
476
552
  export const issueCommentDelete = Command.make(
477
553
  "delete",
478
554
  { ref: refArg, commentId: commentIdArg },
479
- ({ ref, commentId }) =>
480
- Effect.gen(function* () {
481
- const { projectId, seq } = yield* parseIssueRef(ref);
482
- const issue = yield* findIssueBySeq(projectId, seq);
483
- yield* api.delete(
484
- `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
485
- );
486
- yield* Console.log(`Comment ${commentId} deleted`);
487
- }),
555
+ issueCommentDeleteHandler,
488
556
  ).pipe(Command.withDescription("Delete a comment from an issue."));
489
-
490
557
  // --- issue comments (parent) ---
491
-
492
558
  export const issueComments = Command.make("comments").pipe(
493
559
  Command.withDescription(
494
560
  "Manage comments on an issue. Subcommands: list, update, delete\n\nNote: use 'plane issue comment REF TEXT' to add a new comment.",
@@ -499,49 +565,48 @@ export const issueComments = Command.make("comments").pipe(
499
565
  issueCommentDelete,
500
566
  ]),
501
567
  );
502
-
503
568
  // --- issue worklogs list ---
569
+ export function issueWorklogsListHandler({ ref }: { ref: string }) {
570
+ return Effect.gen(function* () {
571
+ const { projectId, seq } = yield* parseIssueRef(ref);
572
+ const issue = yield* findIssueBySeq(projectId, seq);
573
+ const raw = yield* api.get(
574
+ `projects/${projectId}/issues/${issue.id}/worklogs/`,
575
+ );
576
+ const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw);
577
+ if (jsonMode) {
578
+ yield* Console.log(JSON.stringify(results, null, 2));
579
+ return;
580
+ }
581
+ if (xmlMode) {
582
+ yield* Console.log(toXml(results));
583
+ return;
584
+ }
585
+ if (results.length === 0) {
586
+ yield* Console.log("No worklogs");
587
+ return;
588
+ }
589
+ const lines = results.map((w) => {
590
+ const who = w.logged_by_detail?.display_name ?? "?";
591
+ const when = w.created_at.slice(0, 10);
592
+ const hrs = (w.duration / 60).toFixed(1);
593
+ const desc = w.description ?? "";
594
+ return `${w.id} ${when} ${who} ${hrs}h ${desc}`;
595
+ });
596
+ yield* Console.log(lines.join("\n"));
597
+ });
598
+ }
504
599
 
505
600
  export const issueWorklogsList = Command.make(
506
601
  "list",
507
602
  { ref: refArg },
508
- ({ ref }) =>
509
- Effect.gen(function* () {
510
- const { projectId, seq } = yield* parseIssueRef(ref);
511
- const issue = yield* findIssueBySeq(projectId, seq);
512
- const raw = yield* api.get(
513
- `projects/${projectId}/issues/${issue.id}/worklogs/`,
514
- );
515
- const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw);
516
- if (jsonMode) {
517
- yield* Console.log(JSON.stringify(results, null, 2));
518
- return;
519
- }
520
- if (xmlMode) {
521
- yield* Console.log(toXml(results));
522
- return;
523
- }
524
- if (results.length === 0) {
525
- yield* Console.log("No worklogs");
526
- return;
527
- }
528
- const lines = results.map((w) => {
529
- const who = w.logged_by_detail?.display_name ?? "?";
530
- const when = w.created_at.slice(0, 10);
531
- const hrs = (w.duration / 60).toFixed(1);
532
- const desc = w.description ?? "";
533
- return `${w.id} ${when} ${who} ${hrs}h ${desc}`;
534
- });
535
- yield* Console.log(lines.join("\n"));
536
- }),
603
+ issueWorklogsListHandler,
537
604
  ).pipe(
538
605
  Command.withDescription(
539
606
  "List time log entries for an issue. Duration shown in hours.\n\nExample:\n plane issue worklogs list PROJ-29",
540
607
  ),
541
608
  );
542
-
543
609
  // --- issue worklogs add ---
544
-
545
610
  const durationArg = Args.integer({ name: "minutes" }).pipe(
546
611
  Args.withDescription("Time spent in minutes"),
547
612
  );
@@ -549,55 +614,66 @@ const worklogDescOption = Options.optional(Options.text("description")).pipe(
549
614
  Options.withDescription("Optional description of work done"),
550
615
  );
551
616
 
617
+ export function issueWorklogsAddHandler({
618
+ ref,
619
+ duration,
620
+ description,
621
+ }: {
622
+ ref: string;
623
+ duration: number;
624
+ description: Option.Option<string>;
625
+ }) {
626
+ return Effect.gen(function* () {
627
+ const { projectId, seq } = yield* parseIssueRef(ref);
628
+ const issue = yield* findIssueBySeq(projectId, seq);
629
+ const body: WorklogPayload = { duration };
630
+ if (description._tag === "Some") body.description = description.value;
631
+ const raw = yield* api.post(
632
+ `projects/${projectId}/issues/${issue.id}/worklogs/`,
633
+ body,
634
+ );
635
+ const log = yield* decodeOrFail(WorklogSchema, raw);
636
+ const hrs = (log.duration / 60).toFixed(1);
637
+ yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`);
638
+ });
639
+ }
640
+
552
641
  export const issueWorklogsAdd = Command.make(
553
642
  "add",
554
643
  { description: worklogDescOption, ref: refArg, duration: durationArg },
555
- ({ ref, duration, description }) =>
556
- Effect.gen(function* () {
557
- const { projectId, seq } = yield* parseIssueRef(ref);
558
- const issue = yield* findIssueBySeq(projectId, seq);
559
- const body: Record<string, unknown> = { duration };
560
- if (description._tag === "Some") body["description"] = description.value;
561
- const raw = yield* api.post(
562
- `projects/${projectId}/issues/${issue.id}/worklogs/`,
563
- body,
564
- );
565
- const log = yield* decodeOrFail(WorklogSchema, raw);
566
- const hrs = (log.duration / 60).toFixed(1);
567
- yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`);
568
- }),
644
+ issueWorklogsAddHandler,
569
645
  ).pipe(
570
646
  Command.withDescription(
571
647
  'Log time spent on an issue (duration in minutes).\n\nExamples:\n plane issue worklogs add PROJ-29 90\n plane issue worklogs add --description "code review" PROJ-29 30',
572
648
  ),
573
649
  );
574
-
575
650
  // --- issue worklogs (parent) ---
576
-
577
651
  export const issueWorklogs = Command.make("worklogs").pipe(
578
652
  Command.withDescription(
579
653
  "Manage time logs for an issue. Subcommands: list, add",
580
654
  ),
581
655
  Command.withSubcommands([issueWorklogsList, issueWorklogsAdd]),
582
656
  );
583
-
584
657
  // --- issue delete ---
585
-
586
- export const issueDelete = Command.make("delete", { ref: refArg }, ({ ref }) =>
587
- Effect.gen(function* () {
658
+ export function issueDeleteHandler({ ref }: { ref: string }) {
659
+ return Effect.gen(function* () {
588
660
  const { projectId, seq } = yield* parseIssueRef(ref);
589
661
  const issue = yield* findIssueBySeq(projectId, seq);
590
662
  yield* api.delete(`projects/${projectId}/issues/${issue.id}/`);
591
663
  yield* Console.log(`Deleted ${ref}`);
592
- }),
664
+ });
665
+ }
666
+
667
+ export const issueDelete = Command.make(
668
+ "delete",
669
+ { ref: refArg },
670
+ issueDeleteHandler,
593
671
  ).pipe(
594
672
  Command.withDescription(
595
673
  "Permanently delete an issue. This cannot be undone.",
596
674
  ),
597
675
  );
598
-
599
676
  // --- issue (parent) ---
600
-
601
677
  export const issue = Command.make("issue").pipe(
602
678
  Command.withDescription(
603
679
  "Manage individual issues. Use 'plane issue <subcommand> --help' for details.\n\nSubcommands: get, create, update, delete, comment, activity, link, comments, worklogs",