@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,322 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { http, HttpResponse } from "msw"
4
+ import { setupServer } from "msw/node"
5
+ import { _clearProjectCache } from "@/resolve"
6
+
7
+ const BASE = "http://issue-cmd-test.local"
8
+ const WS = "testws"
9
+
10
+ const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme Project" }]
11
+ const ISSUES = [
12
+ { id: "i1", sequence_id: 29, name: "Migrate Button", priority: "high", state: "s1" },
13
+ { id: "i2", sequence_id: 30, name: "Migrate Input", priority: "medium", state: "s2" },
14
+ ]
15
+ const STATES = [
16
+ { id: "s-done", name: "Done", group: "completed" },
17
+ { id: "s-todo", name: "Todo", group: "unstarted" },
18
+ ]
19
+
20
+ const server = setupServer(
21
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
22
+ HttpResponse.json({ results: PROJECTS }),
23
+ ),
24
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
25
+ HttpResponse.json({ results: ISSUES }),
26
+ ),
27
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () =>
28
+ HttpResponse.json({ results: STATES }),
29
+ ),
30
+ )
31
+
32
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
33
+ afterAll(() => server.close())
34
+
35
+ beforeEach(() => {
36
+ _clearProjectCache()
37
+ process.env["PLANE_HOST"] = BASE
38
+ process.env["PLANE_WORKSPACE"] = WS
39
+ process.env["PLANE_API_TOKEN"] = "test-token"
40
+ })
41
+
42
+ afterEach(() => {
43
+ server.resetHandlers()
44
+ delete process.env["PLANE_HOST"]
45
+ delete process.env["PLANE_WORKSPACE"]
46
+ delete process.env["PLANE_API_TOKEN"]
47
+ })
48
+
49
+ describe("issueGet", () => {
50
+ it("prints full JSON for an issue", async () => {
51
+ const { issueGet } = await import("@/commands/issue")
52
+ const logs: string[] = []
53
+ const orig = console.log
54
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
55
+
56
+ try {
57
+ await Effect.runPromise((issueGet as any).handler({ ref: "ACME-29" }))
58
+ } finally {
59
+ console.log = orig
60
+ }
61
+
62
+ const output = logs.join("\n")
63
+ const parsed = JSON.parse(output)
64
+ expect(parsed.id).toBe("i1")
65
+ expect(parsed.name).toBe("Migrate Button")
66
+ })
67
+ })
68
+
69
+ describe("issueUpdate", () => {
70
+ it("updates state", async () => {
71
+ server.use(
72
+ http.patch(
73
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
74
+ async ({ request }) => {
75
+ const body = (await request.json()) as any
76
+ return HttpResponse.json({
77
+ id: "i1",
78
+ sequence_id: 29,
79
+ name: "Migrate Button",
80
+ priority: "high",
81
+ state: body.state ?? "s1",
82
+ })
83
+ },
84
+ ),
85
+ )
86
+
87
+ const { issueUpdate } = await import("@/commands/issue")
88
+ const logs: string[] = []
89
+ const orig = console.log
90
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
91
+
92
+ try {
93
+ await Effect.runPromise(
94
+ (issueUpdate as any).handler({
95
+ ref: "ACME-29",
96
+ state: { _tag: "Some", value: "completed" },
97
+ priority: { _tag: "None" },
98
+ }),
99
+ )
100
+ } finally {
101
+ console.log = orig
102
+ }
103
+
104
+ expect(logs.join("\n")).toContain("Updated ACME-29")
105
+ })
106
+
107
+ it("updates priority", async () => {
108
+ server.use(
109
+ http.patch(
110
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
111
+ async ({ request }) => {
112
+ const body = (await request.json()) as any
113
+ return HttpResponse.json({
114
+ id: "i1",
115
+ sequence_id: 29,
116
+ name: "Migrate Button",
117
+ priority: body.priority,
118
+ state: "s1",
119
+ })
120
+ },
121
+ ),
122
+ )
123
+
124
+ const { issueUpdate } = await import("@/commands/issue")
125
+ const logs: string[] = []
126
+ const orig = console.log
127
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
128
+
129
+ try {
130
+ await Effect.runPromise(
131
+ (issueUpdate as any).handler({
132
+ ref: "ACME-29",
133
+ state: { _tag: "None" },
134
+ priority: { _tag: "Some", value: "urgent" },
135
+ }),
136
+ )
137
+ } finally {
138
+ console.log = orig
139
+ }
140
+
141
+ expect(logs.join("\n")).toContain("urgent")
142
+ })
143
+
144
+ it("fails when nothing to update", async () => {
145
+ const { issueUpdate } = await import("@/commands/issue")
146
+ const result = await Effect.runPromise(
147
+ Effect.either(
148
+ (issueUpdate as any).handler({
149
+ ref: "ACME-29",
150
+ state: { _tag: "None" },
151
+ priority: { _tag: "None" },
152
+ }),
153
+ ),
154
+ )
155
+ expect(result._tag).toBe("Left")
156
+ if (result._tag === "Left") {
157
+ expect(result.left.message).toContain("Nothing to update")
158
+ }
159
+ })
160
+ })
161
+
162
+ describe("issueComment", () => {
163
+ it("adds a comment", async () => {
164
+ let postedBody: unknown
165
+ server.use(
166
+ http.post(
167
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
168
+ async ({ request }) => {
169
+ postedBody = await request.json()
170
+ return HttpResponse.json({ id: "c1" }, { status: 201 })
171
+ },
172
+ ),
173
+ )
174
+
175
+ const { issueComment } = await import("@/commands/issue")
176
+ const logs: string[] = []
177
+ const orig = console.log
178
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
179
+
180
+ try {
181
+ await Effect.runPromise(
182
+ (issueComment as any).handler({ ref: "ACME-29", text: "Fixed in latest build" }),
183
+ )
184
+ } finally {
185
+ console.log = orig
186
+ }
187
+
188
+ expect((postedBody as any).comment_html).toContain("Fixed in latest build")
189
+ expect(logs.join("\n")).toContain("Comment added to ACME-29")
190
+ })
191
+
192
+ it("HTML-escapes angle brackets in comment text", async () => {
193
+ let postedBody: unknown
194
+ server.use(
195
+ http.post(
196
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
197
+ async ({ request }) => {
198
+ postedBody = await request.json()
199
+ return HttpResponse.json({ id: "c2" }, { status: 201 })
200
+ },
201
+ ),
202
+ )
203
+
204
+ const { issueComment } = await import("@/commands/issue")
205
+ try {
206
+ await Effect.runPromise(
207
+ (issueComment as any).handler({ ref: "ACME-29", text: "<script>alert(1)</script>" }),
208
+ )
209
+ } finally {}
210
+
211
+ expect((postedBody as any).comment_html).toContain("&lt;script&gt;")
212
+ expect((postedBody as any).comment_html).not.toContain("<script>")
213
+ })
214
+ })
215
+
216
+ describe("issueCreate", () => {
217
+ it("creates an issue with just a title", async () => {
218
+ server.use(
219
+ http.post(
220
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
221
+ async ({ request }) => {
222
+ const body = (await request.json()) as any
223
+ return HttpResponse.json({
224
+ id: "new-i",
225
+ sequence_id: 99,
226
+ name: body.name,
227
+ priority: "none",
228
+ state: "s1",
229
+ })
230
+ },
231
+ ),
232
+ )
233
+
234
+ const { issueCreate } = await import("@/commands/issue")
235
+ const logs: string[] = []
236
+ const orig = console.log
237
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
238
+
239
+ try {
240
+ await Effect.runPromise(
241
+ (issueCreate as any).handler({
242
+ project: "ACME",
243
+ title: "New issue",
244
+ priority: { _tag: "None" },
245
+ state: { _tag: "None" },
246
+ }),
247
+ )
248
+ } finally {
249
+ console.log = orig
250
+ }
251
+
252
+ expect(logs.join("\n")).toContain("ACME-99")
253
+ expect(logs.join("\n")).toContain("New issue")
254
+ })
255
+
256
+ it("creates an issue with priority and state", async () => {
257
+ server.use(
258
+ http.post(
259
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
260
+ async ({ request }) => {
261
+ const body = (await request.json()) as any
262
+ return HttpResponse.json({
263
+ id: "new-i2",
264
+ sequence_id: 100,
265
+ name: body.name,
266
+ priority: body.priority,
267
+ state: body.state ?? "s1",
268
+ })
269
+ },
270
+ ),
271
+ )
272
+
273
+ const { issueCreate } = await import("@/commands/issue")
274
+ const logs: string[] = []
275
+ const orig = console.log
276
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
277
+
278
+ try {
279
+ await Effect.runPromise(
280
+ (issueCreate as any).handler({
281
+ project: "ACME",
282
+ title: "High priority issue",
283
+ priority: { _tag: "Some", value: "high" },
284
+ state: { _tag: "Some", value: "completed" },
285
+ }),
286
+ )
287
+ } finally {
288
+ console.log = orig
289
+ }
290
+
291
+ expect(logs.join("\n")).toContain("ACME-100")
292
+ })
293
+ })
294
+
295
+ describe("issueDelete", () => {
296
+ it("deletes an issue", async () => {
297
+ let deleted = false
298
+ server.use(
299
+ http.delete(
300
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
301
+ () => {
302
+ deleted = true
303
+ return new HttpResponse(null, { status: 204 })
304
+ },
305
+ ),
306
+ )
307
+
308
+ const { issueDelete } = await import("@/commands/issue")
309
+ const logs: string[] = []
310
+ const orig = console.log
311
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
312
+
313
+ try {
314
+ await Effect.runPromise((issueDelete as any).handler({ ref: "ACME-29" }))
315
+ } finally {
316
+ console.log = orig
317
+ }
318
+
319
+ expect(deleted).toBe(true)
320
+ expect(logs.join("\n")).toContain("Deleted ACME-29")
321
+ })
322
+ })
@@ -0,0 +1,291 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { http, HttpResponse } from "msw"
4
+ import { setupServer } from "msw/node"
5
+ import { _clearProjectCache } from "@/resolve"
6
+
7
+ const BASE = "http://cw-test.local"
8
+ const WS = "testws"
9
+
10
+ const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme Project" }]
11
+ const ISSUES = [{ id: "i1", sequence_id: 29, name: "Migrate Button", priority: "high", state: "s1" }]
12
+ const COMMENTS = [
13
+ {
14
+ id: "c1",
15
+ comment_html: "<p>Fixed in v2</p>",
16
+ actor_detail: { display_name: "Aaron" },
17
+ created_at: "2025-01-15T10:30:00Z",
18
+ },
19
+ {
20
+ id: "c2",
21
+ comment_html: "<p>LGTM</p>",
22
+ actor_detail: { display_name: "Bea" },
23
+ created_at: "2025-01-16T09:00:00Z",
24
+ },
25
+ ]
26
+ const WORKLOGS = [
27
+ {
28
+ id: "w1",
29
+ description: "Code review",
30
+ duration: 90,
31
+ logged_by_detail: { display_name: "Aaron" },
32
+ created_at: "2025-01-15T10:00:00Z",
33
+ },
34
+ ]
35
+
36
+ const server = setupServer(
37
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
38
+ HttpResponse.json({ results: PROJECTS }),
39
+ ),
40
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
41
+ HttpResponse.json({ results: ISSUES }),
42
+ ),
43
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () =>
44
+ HttpResponse.json({ results: COMMENTS }),
45
+ ),
46
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () =>
47
+ HttpResponse.json({ results: WORKLOGS }),
48
+ ),
49
+ )
50
+
51
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
52
+ afterAll(() => server.close())
53
+
54
+ beforeEach(() => {
55
+ _clearProjectCache()
56
+ process.env["PLANE_HOST"] = BASE
57
+ process.env["PLANE_WORKSPACE"] = WS
58
+ process.env["PLANE_API_TOKEN"] = "test-token"
59
+ })
60
+
61
+ afterEach(() => {
62
+ server.resetHandlers()
63
+ delete process.env["PLANE_HOST"]
64
+ delete process.env["PLANE_WORKSPACE"]
65
+ delete process.env["PLANE_API_TOKEN"]
66
+ })
67
+
68
+ describe("issueCommentsList", () => {
69
+ it("lists comments with author and stripped HTML", async () => {
70
+ const { issueCommentsList } = await import("@/commands/issue")
71
+ const logs: string[] = []
72
+ const orig = console.log
73
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
74
+ try {
75
+ await Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-29" }))
76
+ } finally {
77
+ console.log = orig
78
+ }
79
+ const output = logs.join("\n")
80
+ expect(output).toContain("c1")
81
+ expect(output).toContain("Aaron")
82
+ expect(output).toContain("Fixed in v2")
83
+ expect(output).not.toContain("<p>")
84
+ })
85
+
86
+ it("shows 'No comments' when empty", async () => {
87
+ server.use(
88
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () =>
89
+ HttpResponse.json({ results: [] }),
90
+ ),
91
+ )
92
+ const { issueCommentsList } = await import("@/commands/issue")
93
+ const logs: string[] = []
94
+ const orig = console.log
95
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
96
+ try {
97
+ await Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-29" }))
98
+ } finally {
99
+ console.log = orig
100
+ }
101
+ expect(logs.join("\n")).toBe("No comments")
102
+ })
103
+
104
+ it("shows '?' for missing actor", async () => {
105
+ server.use(
106
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () =>
107
+ HttpResponse.json({ results: [{ id: "c3", comment_html: "<p>hi</p>", created_at: "2025-01-17T10:00:00Z" }] }),
108
+ ),
109
+ )
110
+ const { issueCommentsList } = await import("@/commands/issue")
111
+ const logs: string[] = []
112
+ const orig = console.log
113
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
114
+ try {
115
+ await Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-29" }))
116
+ } finally {
117
+ console.log = orig
118
+ }
119
+ expect(logs.join("\n")).toContain("?")
120
+ })
121
+ })
122
+
123
+ describe("issueCommentUpdate", () => {
124
+ it("updates a comment", async () => {
125
+ let patchedBody: unknown
126
+ server.use(
127
+ http.patch(
128
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/c1/`,
129
+ async ({ request }) => {
130
+ patchedBody = await request.json()
131
+ return HttpResponse.json({ id: "c1", comment_html: "<p>Updated</p>", created_at: "2025-01-15T10:30:00Z" })
132
+ },
133
+ ),
134
+ )
135
+ const { issueCommentUpdate } = await import("@/commands/issue")
136
+ const logs: string[] = []
137
+ const orig = console.log
138
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
139
+ try {
140
+ await Effect.runPromise(
141
+ (issueCommentUpdate as any).handler({ ref: "ACME-29", commentId: "c1", text: "Updated text" }),
142
+ )
143
+ } finally {
144
+ console.log = orig
145
+ }
146
+ expect((patchedBody as any).comment_html).toContain("Updated text")
147
+ expect(logs.join("\n")).toContain("c1")
148
+ expect(logs.join("\n")).toContain("updated")
149
+ })
150
+ })
151
+
152
+ describe("issueCommentDelete", () => {
153
+ it("deletes a comment", async () => {
154
+ let deleted = false
155
+ server.use(
156
+ http.delete(
157
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/c1/`,
158
+ () => {
159
+ deleted = true
160
+ return new HttpResponse(null, { status: 204 })
161
+ },
162
+ ),
163
+ )
164
+ const { issueCommentDelete } = await import("@/commands/issue")
165
+ const logs: string[] = []
166
+ const orig = console.log
167
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
168
+ try {
169
+ await Effect.runPromise(
170
+ (issueCommentDelete as any).handler({ ref: "ACME-29", commentId: "c1" }),
171
+ )
172
+ } finally {
173
+ console.log = orig
174
+ }
175
+ expect(deleted).toBe(true)
176
+ expect(logs.join("\n")).toContain("c1")
177
+ })
178
+ })
179
+
180
+ describe("issueWorklogsList", () => {
181
+ it("lists worklogs with hours", async () => {
182
+ const { issueWorklogsList } = await import("@/commands/issue")
183
+ const logs: string[] = []
184
+ const orig = console.log
185
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
186
+ try {
187
+ await Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-29" }))
188
+ } finally {
189
+ console.log = orig
190
+ }
191
+ const output = logs.join("\n")
192
+ expect(output).toContain("w1")
193
+ expect(output).toContain("1.5h")
194
+ expect(output).toContain("Aaron")
195
+ expect(output).toContain("Code review")
196
+ })
197
+
198
+ it("shows 'No worklogs' when empty", async () => {
199
+ server.use(
200
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () =>
201
+ HttpResponse.json({ results: [] }),
202
+ ),
203
+ )
204
+ const { issueWorklogsList } = await import("@/commands/issue")
205
+ const logs: string[] = []
206
+ const orig = console.log
207
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
208
+ try {
209
+ await Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-29" }))
210
+ } finally {
211
+ console.log = orig
212
+ }
213
+ expect(logs.join("\n")).toBe("No worklogs")
214
+ })
215
+ })
216
+
217
+ describe("issueWorklogsAdd", () => {
218
+ it("logs time without description", async () => {
219
+ server.use(
220
+ http.post(
221
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
222
+ async ({ request }) => {
223
+ const body = (await request.json()) as any
224
+ return HttpResponse.json({ id: "w-new", duration: body.duration, created_at: "2025-01-15T10:00:00Z" })
225
+ },
226
+ ),
227
+ )
228
+ const { issueWorklogsAdd } = await import("@/commands/issue")
229
+ const logs: string[] = []
230
+ const orig = console.log
231
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
232
+ try {
233
+ await Effect.runPromise(
234
+ (issueWorklogsAdd as any).handler({ ref: "ACME-29", duration: 60, description: { _tag: "None" } }),
235
+ )
236
+ } finally {
237
+ console.log = orig
238
+ }
239
+ expect(logs.join("\n")).toContain("1.0h")
240
+ expect(logs.join("\n")).toContain("w-new")
241
+ })
242
+
243
+ it("logs time with description", async () => {
244
+ server.use(
245
+ http.post(
246
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
247
+ async ({ request }) => {
248
+ const body = (await request.json()) as any
249
+ return HttpResponse.json({ id: "w-new2", duration: body.duration, description: body.description, created_at: "2025-01-15T10:00:00Z" })
250
+ },
251
+ ),
252
+ )
253
+ const { issueWorklogsAdd } = await import("@/commands/issue")
254
+ const logs: string[] = []
255
+ const orig = console.log
256
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
257
+ try {
258
+ await Effect.runPromise(
259
+ (issueWorklogsAdd as any).handler({
260
+ ref: "ACME-29",
261
+ duration: 30,
262
+ description: { _tag: "Some", value: "standup" },
263
+ }),
264
+ )
265
+ } finally {
266
+ console.log = orig
267
+ }
268
+ expect(logs.join("\n")).toContain("0.5h")
269
+ })
270
+
271
+ it("handles missing logged_by_detail in worklogs list", async () => {
272
+ server.use(
273
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () =>
274
+ HttpResponse.json({
275
+ results: [{ id: "w2", duration: 45, created_at: "2025-01-15T10:00:00Z" }],
276
+ }),
277
+ ),
278
+ )
279
+ const { issueWorklogsList } = await import("@/commands/issue")
280
+ const logs: string[] = []
281
+ const orig = console.log
282
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
283
+ try {
284
+ await Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-29" }))
285
+ } finally {
286
+ console.log = orig
287
+ }
288
+ expect(logs.join("\n")).toContain("?")
289
+ expect(logs.join("\n")).toContain("0.8h")
290
+ })
291
+ })