@aaronshaf/plane 0.1.2

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.
Files changed (41) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/.github/workflows/publish.yml +36 -0
  3. package/.husky/pre-commit +7 -0
  4. package/README.md +120 -0
  5. package/bin/plane +2 -0
  6. package/bun.lock +244 -0
  7. package/package.json +57 -0
  8. package/scripts/check-coverage.ts +54 -0
  9. package/scripts/check-file-size.ts +36 -0
  10. package/src/api.ts +81 -0
  11. package/src/bin.ts +72 -0
  12. package/src/commands/cycles.ts +108 -0
  13. package/src/commands/init.ts +72 -0
  14. package/src/commands/intake.ts +85 -0
  15. package/src/commands/issue.ts +409 -0
  16. package/src/commands/issues.ts +51 -0
  17. package/src/commands/labels.ts +54 -0
  18. package/src/commands/members.ts +20 -0
  19. package/src/commands/modules.ts +129 -0
  20. package/src/commands/pages.ts +63 -0
  21. package/src/commands/projects.ts +24 -0
  22. package/src/commands/states.ts +28 -0
  23. package/src/config.ts +217 -0
  24. package/src/format.ts +11 -0
  25. package/src/resolve.ts +76 -0
  26. package/tests/api.test.ts +169 -0
  27. package/tests/cycles-extended.test.ts +183 -0
  28. package/tests/format.test.ts +60 -0
  29. package/tests/helpers/mock-api.ts +27 -0
  30. package/tests/intake.test.ts +157 -0
  31. package/tests/issue-activity.test.ts +167 -0
  32. package/tests/issue-commands.test.ts +322 -0
  33. package/tests/issue-comments-worklogs.test.ts +291 -0
  34. package/tests/issue-links.test.ts +206 -0
  35. package/tests/modules.test.ts +255 -0
  36. package/tests/new-schemas.test.ts +201 -0
  37. package/tests/new-schemas2.test.ts +205 -0
  38. package/tests/pages.test.ts +124 -0
  39. package/tests/resolve.test.ts +178 -0
  40. package/tests/schemas.test.ts +203 -0
  41. package/tsconfig.json +21 -0
@@ -0,0 +1,409 @@
1
+ import { Command, Options, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import {
5
+ IssueSchema,
6
+ ActivitiesResponseSchema,
7
+ IssueLinksResponseSchema,
8
+ IssueLinkSchema,
9
+ CommentsResponseSchema,
10
+ WorklogsResponseSchema,
11
+ WorklogSchema,
12
+ } from "../config.js"
13
+ import { parseIssueRef, findIssueBySeq, getStateId, resolveProject } from "../resolve.js"
14
+
15
+ const refArg = Args.text({ name: "ref" }).pipe(
16
+ Args.withDescription("Issue reference, e.g. PROJ-29"),
17
+ )
18
+
19
+ // --- issue get ---
20
+
21
+ export const issueGet = Command.make("get", { ref: refArg }, ({ ref }) =>
22
+ Effect.gen(function* () {
23
+ const { projectId, seq } = yield* parseIssueRef(ref)
24
+ const issue = yield* findIssueBySeq(projectId, seq)
25
+ yield* Console.log(JSON.stringify(issue, null, 2))
26
+ }),
27
+ ).pipe(
28
+ Command.withDescription(
29
+ "Print full JSON for an issue. Useful for inspecting all fields (state, priority, assignees, labels, etc.).",
30
+ ),
31
+ )
32
+
33
+ // --- issue update ---
34
+
35
+ const stateOption = Options.optional(Options.text("state")).pipe(
36
+ Options.withDescription("State group or name (e.g. backlog, completed)"),
37
+ )
38
+
39
+ const priorityOption = Options.optional(
40
+ Options.choice("priority", ["urgent", "high", "medium", "low", "none"]),
41
+ ).pipe(Options.withDescription("Issue priority"))
42
+
43
+ export const issueUpdate = Command.make(
44
+ "update",
45
+ { state: stateOption, priority: priorityOption, ref: refArg },
46
+ ({ ref, state, priority }) =>
47
+ Effect.gen(function* () {
48
+ const { projectId, seq } = yield* parseIssueRef(ref)
49
+ const issue = yield* findIssueBySeq(projectId, seq)
50
+
51
+ const body: Record<string, unknown> = {}
52
+
53
+ if (state._tag === "Some") {
54
+ body["state"] = yield* getStateId(projectId, state.value)
55
+ }
56
+ if (priority._tag === "Some") {
57
+ body["priority"] = priority.value
58
+ }
59
+
60
+ if (Object.keys(body).length === 0) {
61
+ yield* Effect.fail(new Error("Nothing to update. Specify --state or --priority"))
62
+ }
63
+
64
+ const raw = yield* api.patch(`projects/${projectId}/issues/${issue.id}/`, body)
65
+ const updated = yield* decodeOrFail(IssueSchema, raw)
66
+ yield* Console.log(
67
+ `Updated ${ref}: state=${String(updated.state)} priority=${updated.priority}`,
68
+ )
69
+ }),
70
+ ).pipe(
71
+ Command.withDescription(
72
+ "Update an issue's state or priority. 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 --state started --priority medium OPS-3",
73
+ ),
74
+ )
75
+
76
+ // --- issue comment ---
77
+
78
+ const textArg = Args.text({ name: "text" }).pipe(
79
+ Args.withDescription("Comment text to add"),
80
+ )
81
+
82
+ export const issueComment = Command.make(
83
+ "comment",
84
+ { ref: refArg, text: textArg },
85
+ ({ ref, text }) =>
86
+ Effect.gen(function* () {
87
+ const { projectId, seq } = yield* parseIssueRef(ref)
88
+ const issue = yield* findIssueBySeq(projectId, seq)
89
+ const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;")
90
+ yield* api.post(`projects/${projectId}/issues/${issue.id}/comments/`, {
91
+ comment_html: `<p>${escaped}</p>`,
92
+ })
93
+ yield* Console.log(`Comment added to ${ref}`)
94
+ }),
95
+ ).pipe(
96
+ Command.withDescription(
97
+ "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\"",
98
+ ),
99
+ )
100
+
101
+ // --- issue create ---
102
+
103
+ const titleArg = Args.text({ name: "title" }).pipe(
104
+ Args.withDescription("Issue title"),
105
+ )
106
+ const projectRefArg = Args.text({ name: "project" }).pipe(
107
+ Args.withDescription("Project identifier (e.g. PROJ)"),
108
+ )
109
+
110
+ const createPriorityOption = Options.optional(
111
+ Options.choice("priority", ["urgent", "high", "medium", "low", "none"]),
112
+ ).pipe(Options.withDescription("Issue priority"))
113
+
114
+ const createStateOption = Options.optional(Options.text("state")).pipe(
115
+ Options.withDescription("Initial state group or name"),
116
+ )
117
+
118
+ export const issueCreate = Command.make(
119
+ "create",
120
+ { priority: createPriorityOption, state: createStateOption, project: projectRefArg, title: titleArg },
121
+ ({ project, title, priority, state }) =>
122
+ Effect.gen(function* () {
123
+ const { key, id: projectId } = yield* resolveProject(project)
124
+ const body: Record<string, unknown> = { name: title }
125
+ if (priority._tag === "Some") body["priority"] = priority.value
126
+ if (state._tag === "Some") body["state"] = yield* getStateId(projectId, state.value)
127
+ const raw = yield* api.post(`projects/${projectId}/issues/`, body)
128
+ const created = yield* decodeOrFail(IssueSchema, raw)
129
+ yield* Console.log(`Created ${key}-${created.sequence_id}: ${created.name}`)
130
+ }),
131
+ ).pipe(
132
+ Command.withDescription(
133
+ "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\"",
134
+ ),
135
+ )
136
+
137
+ // --- issue activity ---
138
+
139
+ export const issueActivity = Command.make("activity", { ref: refArg }, ({ ref }) =>
140
+ Effect.gen(function* () {
141
+ const { projectId, seq } = yield* parseIssueRef(ref)
142
+ const issue = yield* findIssueBySeq(projectId, seq)
143
+ const raw = yield* api.get(`projects/${projectId}/issues/${issue.id}/activities/`)
144
+ const { results } = yield* decodeOrFail(ActivitiesResponseSchema, raw)
145
+ if (results.length === 0) {
146
+ yield* Console.log("No activity found")
147
+ return
148
+ }
149
+ const lines = results.map((a) => {
150
+ const who = a.actor_detail?.display_name ?? "?"
151
+ const when = a.created_at.slice(0, 16).replace("T", " ")
152
+ if (a.field) {
153
+ const from = a.old_value ?? "—"
154
+ const to = a.new_value ?? "—"
155
+ return `${when} ${who} ${a.field}: ${from} → ${to}`
156
+ }
157
+ return `${when} ${who} ${a.verb ?? "updated"}`
158
+ })
159
+ yield* Console.log(lines.join("\n"))
160
+ }),
161
+ ).pipe(
162
+ Command.withDescription(
163
+ "Show audit trail for an issue — who changed what and when.\n\nExample:\n plane issue activity PROJ-29",
164
+ ),
165
+ )
166
+
167
+ // --- issue link list ---
168
+
169
+ export const issueLinkList = Command.make("list", { ref: refArg }, ({ ref }) =>
170
+ Effect.gen(function* () {
171
+ const { projectId, seq } = yield* parseIssueRef(ref)
172
+ const issue = yield* findIssueBySeq(projectId, seq)
173
+ const raw = yield* api.get(`projects/${projectId}/issues/${issue.id}/issue-links/`)
174
+ const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw)
175
+ if (results.length === 0) {
176
+ yield* Console.log("No links")
177
+ return
178
+ }
179
+ const lines = results.map((l) => `${l.id} ${l.title ?? "(no title)"} ${l.url}`)
180
+ yield* Console.log(lines.join("\n"))
181
+ }),
182
+ ).pipe(Command.withDescription("List URL links attached to an issue."))
183
+
184
+ // --- issue link add ---
185
+
186
+ const urlArg = Args.text({ name: "url" }).pipe(Args.withDescription("URL to link"))
187
+ const linkTitleOption = Options.optional(Options.text("title")).pipe(
188
+ Options.withDescription("Human-readable title for the link"),
189
+ )
190
+
191
+ export const issueLinkAdd = Command.make(
192
+ "add",
193
+ { title: linkTitleOption, ref: refArg, url: urlArg },
194
+ ({ ref, url, title }) =>
195
+ Effect.gen(function* () {
196
+ const { projectId, seq } = yield* parseIssueRef(ref)
197
+ const issue = yield* findIssueBySeq(projectId, seq)
198
+ const body: Record<string, string> = { url }
199
+ if (title._tag === "Some") body["title"] = title.value
200
+ const raw = yield* api.post(
201
+ `projects/${projectId}/issues/${issue.id}/issue-links/`,
202
+ body,
203
+ )
204
+ const link = yield* decodeOrFail(IssueLinkSchema, raw)
205
+ yield* Console.log(`Link added: ${link.id} ${link.url}`)
206
+ }),
207
+ ).pipe(
208
+ Command.withDescription(
209
+ "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",
210
+ ),
211
+ )
212
+
213
+ // --- issue link remove ---
214
+
215
+ const linkIdArg = Args.text({ name: "link-id" }).pipe(
216
+ Args.withDescription("Link ID (from 'plane issue link list')"),
217
+ )
218
+
219
+ export const issueLinkRemove = Command.make(
220
+ "remove",
221
+ { ref: refArg, linkId: linkIdArg },
222
+ ({ ref, linkId }) =>
223
+ Effect.gen(function* () {
224
+ const { projectId, seq } = yield* parseIssueRef(ref)
225
+ const issue = yield* findIssueBySeq(projectId, seq)
226
+ yield* api.delete(`projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`)
227
+ yield* Console.log(`Link ${linkId} removed from ${ref}`)
228
+ }),
229
+ ).pipe(Command.withDescription("Remove a URL link from an issue by link ID."))
230
+
231
+ // --- issue link (parent) ---
232
+
233
+ export const issueLink = Command.make("link").pipe(
234
+ Command.withDescription(
235
+ "Manage URL links on an issue. Subcommands: list, add, remove",
236
+ ),
237
+ Command.withSubcommands([issueLinkList, issueLinkAdd, issueLinkRemove]),
238
+ )
239
+
240
+ // --- issue comments list ---
241
+
242
+ export const issueCommentsList = Command.make("list", { ref: refArg }, ({ ref }) =>
243
+ Effect.gen(function* () {
244
+ const { projectId, seq } = yield* parseIssueRef(ref)
245
+ const issue = yield* findIssueBySeq(projectId, seq)
246
+ const raw = yield* api.get(`projects/${projectId}/issues/${issue.id}/comments/`)
247
+ const { results } = yield* decodeOrFail(CommentsResponseSchema, raw)
248
+ if (results.length === 0) {
249
+ yield* Console.log("No comments")
250
+ return
251
+ }
252
+ const lines = results.map((c) => {
253
+ const who = c.actor_detail?.display_name ?? "?"
254
+ const when = c.created_at.slice(0, 16).replace("T", " ")
255
+ const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim()
256
+ return `${c.id} ${when} ${who}: ${text}`
257
+ })
258
+ yield* Console.log(lines.join("\n"))
259
+ }),
260
+ ).pipe(
261
+ Command.withDescription(
262
+ "List comments on an issue. Shows comment ID, timestamp, author, and plain text.\n\nExample:\n plane issue comments list PROJ-29",
263
+ ),
264
+ )
265
+
266
+ // --- issue comment update ---
267
+
268
+ const commentIdArg = Args.text({ name: "comment-id" }).pipe(
269
+ Args.withDescription("Comment ID (from 'plane issue comments list')"),
270
+ )
271
+
272
+ export const issueCommentUpdate = Command.make(
273
+ "update",
274
+ { ref: refArg, commentId: commentIdArg, text: textArg },
275
+ ({ ref, commentId, text }) =>
276
+ Effect.gen(function* () {
277
+ const { projectId, seq } = yield* parseIssueRef(ref)
278
+ const issue = yield* findIssueBySeq(projectId, seq)
279
+ const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;")
280
+ yield* api.patch(
281
+ `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
282
+ { comment_html: `<p>${escaped}</p>` },
283
+ )
284
+ yield* Console.log(`Comment ${commentId} updated`)
285
+ }),
286
+ ).pipe(
287
+ Command.withDescription(
288
+ "Edit an existing comment.\n\nExample:\n plane issue comments update PROJ-29 <comment-id> \"Updated text\"",
289
+ ),
290
+ )
291
+
292
+ // --- issue comment delete ---
293
+
294
+ export const issueCommentDelete = Command.make(
295
+ "delete",
296
+ { ref: refArg, commentId: commentIdArg },
297
+ ({ ref, commentId }) =>
298
+ Effect.gen(function* () {
299
+ const { projectId, seq } = yield* parseIssueRef(ref)
300
+ const issue = yield* findIssueBySeq(projectId, seq)
301
+ yield* api.delete(
302
+ `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`,
303
+ )
304
+ yield* Console.log(`Comment ${commentId} deleted`)
305
+ }),
306
+ ).pipe(Command.withDescription("Delete a comment from an issue."))
307
+
308
+ // --- issue comments (parent) ---
309
+
310
+ export const issueComments = Command.make("comments").pipe(
311
+ Command.withDescription("Manage comments on an issue. Subcommands: list, update, delete\n\nNote: use 'plane issue comment REF TEXT' to add a new comment."),
312
+ Command.withSubcommands([issueCommentsList, issueCommentUpdate, issueCommentDelete]),
313
+ )
314
+
315
+ // --- issue worklogs list ---
316
+
317
+ export const issueWorklogsList = Command.make("list", { ref: refArg }, ({ ref }) =>
318
+ Effect.gen(function* () {
319
+ const { projectId, seq } = yield* parseIssueRef(ref)
320
+ const issue = yield* findIssueBySeq(projectId, seq)
321
+ const raw = yield* api.get(`projects/${projectId}/issues/${issue.id}/worklogs/`)
322
+ const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw)
323
+ if (results.length === 0) {
324
+ yield* Console.log("No worklogs")
325
+ return
326
+ }
327
+ const lines = results.map((w) => {
328
+ const who = w.logged_by_detail?.display_name ?? "?"
329
+ const when = w.created_at.slice(0, 10)
330
+ const hrs = (w.duration / 60).toFixed(1)
331
+ const desc = w.description ?? ""
332
+ return `${w.id} ${when} ${who} ${hrs}h ${desc}`
333
+ })
334
+ yield* Console.log(lines.join("\n"))
335
+ }),
336
+ ).pipe(
337
+ Command.withDescription(
338
+ "List time log entries for an issue. Duration shown in hours.\n\nExample:\n plane issue worklogs list PROJ-29",
339
+ ),
340
+ )
341
+
342
+ // --- issue worklogs add ---
343
+
344
+ const durationArg = Args.integer({ name: "minutes" }).pipe(
345
+ Args.withDescription("Time spent in minutes"),
346
+ )
347
+ const worklogDescOption = Options.optional(Options.text("description")).pipe(
348
+ Options.withDescription("Optional description of work done"),
349
+ )
350
+
351
+ export const issueWorklogsAdd = Command.make(
352
+ "add",
353
+ { description: worklogDescOption, ref: refArg, duration: durationArg },
354
+ ({ ref, duration, description }) =>
355
+ Effect.gen(function* () {
356
+ const { projectId, seq } = yield* parseIssueRef(ref)
357
+ const issue = yield* findIssueBySeq(projectId, seq)
358
+ const body: Record<string, unknown> = { duration }
359
+ if (description._tag === "Some") body["description"] = description.value
360
+ const raw = yield* api.post(
361
+ `projects/${projectId}/issues/${issue.id}/worklogs/`,
362
+ body,
363
+ )
364
+ const log = yield* decodeOrFail(WorklogSchema, raw)
365
+ const hrs = (log.duration / 60).toFixed(1)
366
+ yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`)
367
+ }),
368
+ ).pipe(
369
+ Command.withDescription(
370
+ "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",
371
+ ),
372
+ )
373
+
374
+ // --- issue worklogs (parent) ---
375
+
376
+ export const issueWorklogs = Command.make("worklogs").pipe(
377
+ Command.withDescription("Manage time logs for an issue. Subcommands: list, add"),
378
+ Command.withSubcommands([issueWorklogsList, issueWorklogsAdd]),
379
+ )
380
+
381
+ // --- issue delete ---
382
+
383
+ export const issueDelete = Command.make("delete", { ref: refArg }, ({ ref }) =>
384
+ Effect.gen(function* () {
385
+ const { projectId, seq } = yield* parseIssueRef(ref)
386
+ const issue = yield* findIssueBySeq(projectId, seq)
387
+ yield* api.delete(`projects/${projectId}/issues/${issue.id}/`)
388
+ yield* Console.log(`Deleted ${ref}`)
389
+ }),
390
+ ).pipe(Command.withDescription("Permanently delete an issue. This cannot be undone."))
391
+
392
+ // --- issue (parent) ---
393
+
394
+ export const issue = Command.make("issue").pipe(
395
+ Command.withDescription(
396
+ "Manage individual issues. Use 'plane issue <subcommand> --help' for details.\n\nSubcommands: get, create, update, delete, comment, activity, link, comments, worklogs",
397
+ ),
398
+ Command.withSubcommands([
399
+ issueGet,
400
+ issueCreate,
401
+ issueUpdate,
402
+ issueDelete,
403
+ issueComment,
404
+ issueActivity,
405
+ issueLink,
406
+ issueComments,
407
+ issueWorklogs,
408
+ ]),
409
+ )
@@ -0,0 +1,51 @@
1
+ import { Command, Options, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { IssuesResponseSchema } from "../config.js"
5
+ import { formatIssue } from "../format.js"
6
+ import { resolveProject } from "../resolve.js"
7
+ import type { State } from "../config.js"
8
+
9
+ const projectArg = Args.text({ name: "project" }).pipe(
10
+ Args.withDescription("Project identifier — see 'plane projects list' for available identifiers"),
11
+ )
12
+
13
+ const stateOption = Options.optional(Options.text("state")).pipe(
14
+ Options.withDescription(
15
+ "Filter by state group (backlog | unstarted | started | completed | cancelled) or exact state name",
16
+ ),
17
+ )
18
+
19
+ export const issuesList = Command.make(
20
+ "list",
21
+ { state: stateOption, project: projectArg },
22
+ ({ project, state }) =>
23
+ Effect.gen(function* () {
24
+ const { key, id } = yield* resolveProject(project)
25
+ const raw = yield* api.get(`projects/${id}/issues/?order_by=sequence_id`)
26
+ const { results } = yield* decodeOrFail(IssuesResponseSchema, raw)
27
+
28
+ const filtered =
29
+ state._tag === "Some"
30
+ ? results.filter((i) => {
31
+ const s = i.state as State | string
32
+ if (typeof s !== "object") return false
33
+ const val = state.value.toLowerCase()
34
+ return s.group === val || s.name.toLowerCase() === val
35
+ })
36
+ : results
37
+
38
+ yield* Console.log(filtered.map((i) => formatIssue(i, key)).join("\n"))
39
+ }),
40
+ ).pipe(
41
+ Command.withDescription(
42
+ "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title",
43
+ ),
44
+ )
45
+
46
+ export const issues = Command.make("issues").pipe(
47
+ Command.withDescription(
48
+ "List and filter issues. Use 'plane issues list --help' for filtering options.",
49
+ ),
50
+ Command.withSubcommands([issuesList]),
51
+ )
@@ -0,0 +1,54 @@
1
+ import { Command, Options, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { LabelsResponseSchema, LabelSchema } from "../config.js"
5
+ import { resolveProject } from "../resolve.js"
6
+
7
+ const projectArg = Args.text({ name: "project" }).pipe(
8
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
9
+ )
10
+
11
+ // --- labels list ---
12
+
13
+ export const labelsList = Command.make("list", { project: projectArg }, ({ project }) =>
14
+ Effect.gen(function* () {
15
+ const { id } = yield* resolveProject(project)
16
+ const raw = yield* api.get(`projects/${id}/labels/`)
17
+ const { results } = yield* decodeOrFail(LabelsResponseSchema, raw)
18
+ if (results.length === 0) {
19
+ yield* Console.log("No labels found")
20
+ return
21
+ }
22
+ const lines = results.map(
23
+ (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`,
24
+ )
25
+ yield* Console.log(lines.join("\n"))
26
+ }),
27
+ )
28
+
29
+ // --- labels create ---
30
+
31
+ const nameArg = Args.text({ name: "name" }).pipe(
32
+ Args.withDescription("Label name"),
33
+ )
34
+ const colorOption = Options.optional(Options.text("color")).pipe(
35
+ Options.withDescription("Hex color e.g. #ff0000"),
36
+ )
37
+
38
+ export const labelsCreate = Command.make(
39
+ "create",
40
+ { color: colorOption, project: projectArg, name: nameArg },
41
+ ({ project, name, color }) =>
42
+ Effect.gen(function* () {
43
+ const { id } = yield* resolveProject(project)
44
+ const body: Record<string, unknown> = { name }
45
+ if (color._tag === "Some") body["color"] = color.value
46
+ const raw = yield* api.post(`projects/${id}/labels/`, body)
47
+ const label = yield* decodeOrFail(LabelSchema, raw)
48
+ yield* Console.log(`Created label: ${label.name} (${label.id})`)
49
+ }),
50
+ )
51
+
52
+ export const labels = Command.make("labels").pipe(
53
+ Command.withSubcommands([labelsList, labelsCreate]),
54
+ )
@@ -0,0 +1,20 @@
1
+ import { Command } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { MembersResponseSchema } from "../config.js"
5
+
6
+ export const membersList = Command.make("list", {}, () =>
7
+ Effect.gen(function* () {
8
+ const raw = yield* api.get("members/")
9
+ const results = yield* decodeOrFail(MembersResponseSchema, raw)
10
+ const lines = results.map((m) => {
11
+ const email = m.email ? ` <${m.email}>` : ""
12
+ return `${m.display_name.padEnd(24)}${email}`
13
+ })
14
+ yield* Console.log(lines.join("\n"))
15
+ }),
16
+ )
17
+
18
+ export const members = Command.make("members").pipe(
19
+ Command.withSubcommands([membersList]),
20
+ )
@@ -0,0 +1,129 @@
1
+ import { Command, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { ModulesResponseSchema, ModuleIssuesResponseSchema } from "../config.js"
5
+ import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"
6
+
7
+ const projectArg = Args.text({ name: "project" }).pipe(
8
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
9
+ )
10
+
11
+ const moduleIdArg = Args.text({ name: "module-id" }).pipe(
12
+ Args.withDescription("Module UUID (from 'plane modules list PROJECT')"),
13
+ )
14
+
15
+ // --- modules list ---
16
+
17
+ export const modulesList = Command.make("list", { project: projectArg }, ({ project }) =>
18
+ Effect.gen(function* () {
19
+ const { id } = yield* resolveProject(project)
20
+ const raw = yield* api.get(`projects/${id}/modules/`)
21
+ const { results } = yield* decodeOrFail(ModulesResponseSchema, raw)
22
+ if (results.length === 0) {
23
+ yield* Console.log("No modules found")
24
+ return
25
+ }
26
+ const lines = results.map((m) => {
27
+ const status = (m.status ?? "?").padEnd(12)
28
+ return `${m.id} ${status} ${m.name}`
29
+ })
30
+ yield* Console.log(lines.join("\n"))
31
+ }),
32
+ ).pipe(
33
+ Command.withDescription(
34
+ "List modules for a project. Shows module UUID, status, and name.\n\nExample:\n plane modules list PROJ",
35
+ ),
36
+ )
37
+
38
+ // --- modules issues list ---
39
+
40
+ export const moduleIssuesList = Command.make(
41
+ "list",
42
+ { project: projectArg, moduleId: moduleIdArg },
43
+ ({ project, moduleId }) =>
44
+ Effect.gen(function* () {
45
+ const { key, id } = yield* resolveProject(project)
46
+ const raw = yield* api.get(`projects/${id}/modules/${moduleId}/module-issues/`)
47
+ const { results } = yield* decodeOrFail(ModuleIssuesResponseSchema, raw)
48
+ if (results.length === 0) {
49
+ yield* Console.log("No issues in module")
50
+ return
51
+ }
52
+ const lines = results.map((mi) => {
53
+ if (mi.issue_detail) {
54
+ const seq = String(mi.issue_detail.sequence_id).padStart(3, " ")
55
+ return `${key}-${seq} ${mi.issue_detail.name} (${mi.id})`
56
+ }
57
+ return `${mi.issue} (module-issue: ${mi.id})`
58
+ })
59
+ yield* Console.log(lines.join("\n"))
60
+ }),
61
+ ).pipe(
62
+ Command.withDescription(
63
+ "List issues in a module.\n\nExample:\n plane modules issues list PROJ <module-id>",
64
+ ),
65
+ )
66
+
67
+ // --- modules issues add ---
68
+
69
+ const issueRefArg = Args.text({ name: "ref" }).pipe(
70
+ Args.withDescription("Issue reference to add (e.g. PROJ-29)"),
71
+ )
72
+
73
+ export const moduleIssuesAdd = Command.make(
74
+ "add",
75
+ { project: projectArg, moduleId: moduleIdArg, ref: issueRefArg },
76
+ ({ project, moduleId, ref }) =>
77
+ Effect.gen(function* () {
78
+ const { id: projectId } = yield* resolveProject(project)
79
+ const { seq } = yield* parseIssueRef(ref)
80
+ const issue = yield* findIssueBySeq(projectId, seq)
81
+ yield* api.post(`projects/${projectId}/modules/${moduleId}/module-issues/`, {
82
+ issues: [issue.id],
83
+ })
84
+ yield* Console.log(`Added ${ref} to module ${moduleId}`)
85
+ }),
86
+ ).pipe(
87
+ Command.withDescription(
88
+ "Add an issue to a module.\n\nExample:\n plane modules issues add PROJ <module-id> PROJ-29",
89
+ ),
90
+ )
91
+
92
+ // --- modules issues remove ---
93
+
94
+ const moduleIssueIdArg = Args.text({ name: "module-issue-id" }).pipe(
95
+ Args.withDescription("Module-issue join ID (from 'plane modules issues list')"),
96
+ )
97
+
98
+ export const moduleIssuesRemove = Command.make(
99
+ "remove",
100
+ { project: projectArg, moduleId: moduleIdArg, moduleIssueId: moduleIssueIdArg },
101
+ ({ project, moduleId, moduleIssueId }) =>
102
+ Effect.gen(function* () {
103
+ const { id } = yield* resolveProject(project)
104
+ yield* api.delete(
105
+ `projects/${id}/modules/${moduleId}/module-issues/${moduleIssueId}/`,
106
+ )
107
+ yield* Console.log(`Removed module-issue ${moduleIssueId} from module ${moduleId}`)
108
+ }),
109
+ ).pipe(
110
+ Command.withDescription(
111
+ "Remove an issue from a module using the module-issue join ID.\n\nExample:\n plane modules issues remove PROJ <module-id> <module-issue-id>",
112
+ ),
113
+ )
114
+
115
+ // --- modules issues (parent) ---
116
+
117
+ export const moduleIssues = Command.make("issues").pipe(
118
+ Command.withDescription("Manage issues within a module. Subcommands: list, add, remove"),
119
+ Command.withSubcommands([moduleIssuesList, moduleIssuesAdd, moduleIssuesRemove]),
120
+ )
121
+
122
+ // --- modules (parent) ---
123
+
124
+ export const modules = Command.make("modules").pipe(
125
+ Command.withDescription(
126
+ "Manage modules (groups of related issues). Subcommands: list, issues\n\nExamples:\n plane modules list PROJ\n plane modules issues list PROJ <module-id>\n plane modules issues add PROJ <module-id> PROJ-29",
127
+ ),
128
+ Command.withSubcommands([modulesList, moduleIssues]),
129
+ )