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