@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,183 @@
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://cycles-ext-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 CYCLES = [
13
+ { id: "cyc1", name: "Sprint 1", status: "started", start_date: "2025-01-01", end_date: "2025-01-14" },
14
+ { id: "cyc2", name: "Sprint 2", status: "backlog" },
15
+ ]
16
+ const CYCLE_ISSUES = [
17
+ { id: "ci1", issue: "i1", issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" } },
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/cycles/`, () =>
28
+ HttpResponse.json({ results: CYCLES }),
29
+ ),
30
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
31
+ HttpResponse.json({ results: CYCLE_ISSUES }),
32
+ ),
33
+ )
34
+
35
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
36
+ afterAll(() => server.close())
37
+
38
+ beforeEach(() => {
39
+ _clearProjectCache()
40
+ process.env["PLANE_HOST"] = BASE
41
+ process.env["PLANE_WORKSPACE"] = WS
42
+ process.env["PLANE_API_TOKEN"] = "test-token"
43
+ })
44
+
45
+ afterEach(() => {
46
+ server.resetHandlers()
47
+ delete process.env["PLANE_HOST"]
48
+ delete process.env["PLANE_WORKSPACE"]
49
+ delete process.env["PLANE_API_TOKEN"]
50
+ })
51
+
52
+ describe("cyclesList", () => {
53
+ it("lists cycles with status and dates", async () => {
54
+ const { cyclesList } = await import("@/commands/cycles")
55
+ const logs: string[] = []
56
+ const orig = console.log
57
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
58
+ try {
59
+ await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
60
+ } finally {
61
+ console.log = orig
62
+ }
63
+ const output = logs.join("\n")
64
+ expect(output).toContain("cyc1")
65
+ expect(output).toContain("Sprint 1")
66
+ expect(output).toContain("started")
67
+ expect(output).toContain("2025-01-01")
68
+ })
69
+
70
+ it("shows em-dash for missing dates", async () => {
71
+ const { cyclesList } = await import("@/commands/cycles")
72
+ const logs: string[] = []
73
+ const orig = console.log
74
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
75
+ try {
76
+ await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
77
+ } finally {
78
+ console.log = orig
79
+ }
80
+ expect(logs.join("\n")).toContain("—")
81
+ })
82
+
83
+ it("shows 'No cycles found' when empty", async () => {
84
+ server.use(
85
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, () =>
86
+ HttpResponse.json({ results: [] }),
87
+ ),
88
+ )
89
+ const { cyclesList } = await import("@/commands/cycles")
90
+ const logs: string[] = []
91
+ const orig = console.log
92
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
93
+ try {
94
+ await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
95
+ } finally {
96
+ console.log = orig
97
+ }
98
+ expect(logs.join("\n")).toBe("No cycles found")
99
+ })
100
+ })
101
+
102
+ describe("cycleIssuesList", () => {
103
+ it("lists issues in a cycle", async () => {
104
+ const { cycleIssuesList } = await import("@/commands/cycles")
105
+ const logs: string[] = []
106
+ const orig = console.log
107
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
108
+ try {
109
+ await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
110
+ } finally {
111
+ console.log = orig
112
+ }
113
+ const output = logs.join("\n")
114
+ expect(output).toContain("ACME-")
115
+ expect(output).toContain("29")
116
+ expect(output).toContain("Migrate Button")
117
+ })
118
+
119
+ it("falls back to issue UUID without detail", async () => {
120
+ server.use(
121
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
122
+ HttpResponse.json({ results: [{ id: "ci2", issue: "bare-uuid" }] }),
123
+ ),
124
+ )
125
+ const { cycleIssuesList } = await import("@/commands/cycles")
126
+ const logs: string[] = []
127
+ const orig = console.log
128
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
129
+ try {
130
+ await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
131
+ } finally {
132
+ console.log = orig
133
+ }
134
+ expect(logs.join("\n")).toContain("bare-uuid")
135
+ })
136
+
137
+ it("shows 'No issues in cycle' when empty", async () => {
138
+ server.use(
139
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
140
+ HttpResponse.json({ results: [] }),
141
+ ),
142
+ )
143
+ const { cycleIssuesList } = await import("@/commands/cycles")
144
+ const logs: string[] = []
145
+ const orig = console.log
146
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
147
+ try {
148
+ await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
149
+ } finally {
150
+ console.log = orig
151
+ }
152
+ expect(logs.join("\n")).toBe("No issues in cycle")
153
+ })
154
+ })
155
+
156
+ describe("cycleIssuesAdd", () => {
157
+ it("adds an issue to a cycle", async () => {
158
+ let postedBody: unknown
159
+ server.use(
160
+ http.post(
161
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`,
162
+ async ({ request }) => {
163
+ postedBody = await request.json()
164
+ return HttpResponse.json({ issues: ["i1"] }, { status: 201 })
165
+ },
166
+ ),
167
+ )
168
+ const { cycleIssuesAdd } = await import("@/commands/cycles")
169
+ const logs: string[] = []
170
+ const orig = console.log
171
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
172
+ try {
173
+ await Effect.runPromise(
174
+ (cycleIssuesAdd as any).handler({ project: "ACME", cycleId: "cyc1", ref: "ACME-29" }),
175
+ )
176
+ } finally {
177
+ console.log = orig
178
+ }
179
+ expect((postedBody as any).issues).toContain("i1")
180
+ expect(logs.join("\n")).toContain("ACME-29")
181
+ expect(logs.join("\n")).toContain("cyc1")
182
+ })
183
+ })
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { formatIssue } from "@/format"
3
+ import type { Issue } from "@/config"
4
+
5
+ const stateObj = { id: "s1", name: "In Progress", group: "started" }
6
+
7
+ function makeIssue(overrides: Partial<Issue> = {}): Issue {
8
+ return {
9
+ id: "abc",
10
+ sequence_id: 7,
11
+ name: "Do the thing",
12
+ priority: "medium",
13
+ state: stateObj,
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ describe("formatIssue", () => {
19
+ it("formats a basic issue with object state", () => {
20
+ const result = formatIssue(makeIssue(), "ACME")
21
+ expect(result).toContain("ACME- 7")
22
+ expect(result).toContain("[started ]")
23
+ expect(result).toContain("In Progress")
24
+ expect(result).toContain("Do the thing")
25
+ })
26
+
27
+ it("shows ? for state name and group when state is a string", () => {
28
+ const result = formatIssue(makeIssue({ state: "some-uuid" }), "WEB")
29
+ expect(result).toContain("[?")
30
+ expect(result).toContain("?")
31
+ })
32
+
33
+ it("pads sequence_id to 3 characters", () => {
34
+ const result = formatIssue(makeIssue({ sequence_id: 1 }), "OPS")
35
+ expect(result).toContain("OPS- 1")
36
+ })
37
+
38
+ it("uses the provided project key", () => {
39
+ const result = formatIssue(makeIssue(), "XYZ")
40
+ expect(result).toStartWith("XYZ-")
41
+ })
42
+
43
+ it("includes issue name", () => {
44
+ const result = formatIssue(makeIssue({ name: "Fix everything" }), "ACME")
45
+ expect(result).toContain("Fix everything")
46
+ })
47
+
48
+ it("pads state group to 10 characters", () => {
49
+ const shortState = { id: "s2", name: "Todo", group: "todo" }
50
+ const result = formatIssue(makeIssue({ state: shortState }), "ACME")
51
+ // group "todo" padded to 10: "todo "
52
+ expect(result).toContain("[todo ]")
53
+ })
54
+
55
+ it("pads state name to 12 characters", () => {
56
+ const result = formatIssue(makeIssue({ state: stateObj }), "ACME")
57
+ // "In Progress " padded to 12
58
+ expect(result).toContain("In Progress ")
59
+ })
60
+ })
@@ -0,0 +1,27 @@
1
+ import { http, HttpResponse } from "msw"
2
+ import { setupServer } from "msw/node"
3
+
4
+ export const BASE = "http://localhost:3737"
5
+ export const WORKSPACE = "testws"
6
+
7
+ export function makeServer(...handlers: Parameters<typeof setupServer>[0][]) {
8
+ return setupServer(...handlers)
9
+ }
10
+
11
+ export function issuesHandler(projectId: string, issues: unknown[]) {
12
+ return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/${projectId}/issues/`, () =>
13
+ HttpResponse.json({ results: issues, next_page_results: false }),
14
+ )
15
+ }
16
+
17
+ export function statesHandler(projectId: string, states: unknown[]) {
18
+ return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/${projectId}/states/`, () =>
19
+ HttpResponse.json({ results: states }),
20
+ )
21
+ }
22
+
23
+ export function projectsHandler(projects: unknown[]) {
24
+ return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/`, () =>
25
+ HttpResponse.json({ results: projects }),
26
+ )
27
+ }
@@ -0,0 +1,157 @@
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://intake-test.local"
8
+ const WS = "testws"
9
+
10
+ const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme Project" }]
11
+ const INTAKE_ISSUES = [
12
+ {
13
+ id: "int1",
14
+ issue_detail: { id: "i1", sequence_id: 5, name: "Bug report", priority: "high" },
15
+ status: 0,
16
+ created_at: "2025-01-15T10:00:00Z",
17
+ },
18
+ {
19
+ id: "int2",
20
+ issue_detail: { id: "i2", sequence_id: 6, name: "Feature request", priority: "low" },
21
+ status: 1,
22
+ created_at: "2025-01-14T10:00:00Z",
23
+ },
24
+ {
25
+ id: "int3",
26
+ created_at: "2025-01-13T10:00:00Z",
27
+ },
28
+ ]
29
+
30
+ const server = setupServer(
31
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
32
+ HttpResponse.json({ results: PROJECTS }),
33
+ ),
34
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () =>
35
+ HttpResponse.json({ results: INTAKE_ISSUES }),
36
+ ),
37
+ )
38
+
39
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
40
+ afterAll(() => server.close())
41
+
42
+ beforeEach(() => {
43
+ _clearProjectCache()
44
+ process.env["PLANE_HOST"] = BASE
45
+ process.env["PLANE_WORKSPACE"] = WS
46
+ process.env["PLANE_API_TOKEN"] = "test-token"
47
+ })
48
+
49
+ afterEach(() => {
50
+ server.resetHandlers()
51
+ delete process.env["PLANE_HOST"]
52
+ delete process.env["PLANE_WORKSPACE"]
53
+ delete process.env["PLANE_API_TOKEN"]
54
+ })
55
+
56
+ describe("intakeList", () => {
57
+ it("lists intake issues with status labels", async () => {
58
+ const { intakeList } = await import("@/commands/intake")
59
+ const logs: string[] = []
60
+ const orig = console.log
61
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
62
+ try {
63
+ await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
64
+ } finally {
65
+ console.log = orig
66
+ }
67
+ const output = logs.join("\n")
68
+ expect(output).toContain("int1")
69
+ expect(output).toContain("pending")
70
+ expect(output).toContain("Bug report")
71
+ expect(output).toContain("int2")
72
+ expect(output).toContain("accepted")
73
+ })
74
+
75
+ it("shows 'No intake issues' when empty", async () => {
76
+ server.use(
77
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () =>
78
+ HttpResponse.json({ results: [] }),
79
+ ),
80
+ )
81
+ const { intakeList } = await import("@/commands/intake")
82
+ const logs: string[] = []
83
+ const orig = console.log
84
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
85
+ try {
86
+ await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
87
+ } finally {
88
+ console.log = orig
89
+ }
90
+ expect(logs.join("\n")).toBe("No intake issues")
91
+ })
92
+
93
+ it("handles intake issue without issue_detail", async () => {
94
+ const { intakeList } = await import("@/commands/intake")
95
+ const logs: string[] = []
96
+ const orig = console.log
97
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
98
+ try {
99
+ await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
100
+ } finally {
101
+ console.log = orig
102
+ }
103
+ expect(logs.join("\n")).toContain("int3")
104
+ })
105
+ })
106
+
107
+ describe("intakeAccept", () => {
108
+ it("accepts an intake issue", async () => {
109
+ let patchedBody: unknown
110
+ server.use(
111
+ http.patch(
112
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
113
+ async ({ request }) => {
114
+ patchedBody = await request.json()
115
+ return HttpResponse.json({ id: "int1", status: 1, created_at: "2025-01-15T10:00:00Z" })
116
+ },
117
+ ),
118
+ )
119
+ const { intakeAccept } = await import("@/commands/intake")
120
+ const logs: string[] = []
121
+ const orig = console.log
122
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
123
+ try {
124
+ await Effect.runPromise((intakeAccept as any).handler({ project: "ACME", intakeId: "int1" }))
125
+ } finally {
126
+ console.log = orig
127
+ }
128
+ expect((patchedBody as any).status).toBe(1)
129
+ expect(logs.join("\n")).toContain("accepted")
130
+ })
131
+ })
132
+
133
+ describe("intakeReject", () => {
134
+ it("rejects an intake issue", async () => {
135
+ let patchedBody: unknown
136
+ server.use(
137
+ http.patch(
138
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
139
+ async ({ request }) => {
140
+ patchedBody = await request.json()
141
+ return HttpResponse.json({ id: "int1", status: -2, created_at: "2025-01-15T10:00:00Z" })
142
+ },
143
+ ),
144
+ )
145
+ const { intakeReject } = await import("@/commands/intake")
146
+ const logs: string[] = []
147
+ const orig = console.log
148
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
149
+ try {
150
+ await Effect.runPromise((intakeReject as any).handler({ project: "ACME", intakeId: "int1" }))
151
+ } finally {
152
+ console.log = orig
153
+ }
154
+ expect((patchedBody as any).status).toBe(-2)
155
+ expect(logs.join("\n")).toContain("rejected")
156
+ })
157
+ })
@@ -0,0 +1,167 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } 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://act-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 ACTIVITIES = [
13
+ {
14
+ id: "act1",
15
+ actor_detail: { display_name: "Aaron" },
16
+ field: "state",
17
+ old_value: "Backlog",
18
+ new_value: "In Progress",
19
+ verb: "updated",
20
+ created_at: "2025-01-15T10:30:00Z",
21
+ },
22
+ {
23
+ id: "act2",
24
+ actor_detail: { display_name: "Bea" },
25
+ field: null,
26
+ old_value: null,
27
+ new_value: null,
28
+ verb: "created",
29
+ created_at: "2025-01-14T08:00:00Z",
30
+ },
31
+ ]
32
+
33
+ const server = setupServer(
34
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
35
+ HttpResponse.json({ results: PROJECTS }),
36
+ ),
37
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
38
+ HttpResponse.json({ results: ISSUES }),
39
+ ),
40
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
41
+ HttpResponse.json({ results: ACTIVITIES }),
42
+ ),
43
+ )
44
+
45
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
46
+ afterAll(() => server.close())
47
+
48
+ beforeEach(() => {
49
+ _clearProjectCache()
50
+ process.env["PLANE_HOST"] = BASE
51
+ process.env["PLANE_WORKSPACE"] = WS
52
+ process.env["PLANE_API_TOKEN"] = "test-token"
53
+ })
54
+
55
+ afterEach(() => {
56
+ server.resetHandlers()
57
+ delete process.env["PLANE_HOST"]
58
+ delete process.env["PLANE_WORKSPACE"]
59
+ delete process.env["PLANE_API_TOKEN"]
60
+ })
61
+
62
+ describe("issueActivity command handler", () => {
63
+ it("fetches and formats activity with field changes", async () => {
64
+ const { issueActivity } = await import("@/commands/issue")
65
+ const logs: string[] = []
66
+ const originalLog = console.log
67
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
68
+
69
+ try {
70
+ await Effect.runPromise(
71
+ (issueActivity as any).handler({ ref: "ACME-29" }),
72
+ )
73
+ } finally {
74
+ console.log = originalLog
75
+ }
76
+
77
+ const output = logs.join("\n")
78
+ expect(output).toContain("Aaron")
79
+ expect(output).toContain("state")
80
+ expect(output).toContain("Backlog")
81
+ expect(output).toContain("In Progress")
82
+ })
83
+
84
+ it("shows 'No activity found' when empty", async () => {
85
+ server.use(
86
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
87
+ HttpResponse.json({ results: [] }),
88
+ ),
89
+ )
90
+
91
+ const { issueActivity } = await import("@/commands/issue")
92
+ const logs: string[] = []
93
+ const originalLog = console.log
94
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
95
+
96
+ try {
97
+ await Effect.runPromise(
98
+ (issueActivity as any).handler({ ref: "ACME-29" }),
99
+ )
100
+ } finally {
101
+ console.log = originalLog
102
+ }
103
+
104
+ expect(logs.join("\n")).toContain("No activity found")
105
+ })
106
+
107
+ it("formats activity without field (verb-only)", async () => {
108
+ server.use(
109
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
110
+ HttpResponse.json({
111
+ results: [
112
+ {
113
+ id: "act3",
114
+ actor_detail: { display_name: "Bea" },
115
+ verb: "created",
116
+ created_at: "2025-01-14T08:00:00Z",
117
+ },
118
+ ],
119
+ }),
120
+ ),
121
+ )
122
+
123
+ const { issueActivity } = await import("@/commands/issue")
124
+ const logs: string[] = []
125
+ const originalLog = console.log
126
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
127
+
128
+ try {
129
+ await Effect.runPromise(
130
+ (issueActivity as any).handler({ ref: "ACME-29" }),
131
+ )
132
+ } finally {
133
+ console.log = originalLog
134
+ }
135
+
136
+ const output = logs.join("\n")
137
+ expect(output).toContain("Bea")
138
+ expect(output).toContain("created")
139
+ })
140
+
141
+ it("handles missing actor_detail gracefully", async () => {
142
+ server.use(
143
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
144
+ HttpResponse.json({
145
+ results: [{ id: "act4", field: "priority", old_value: "low", new_value: "high", created_at: "2025-01-15T10:30:00Z" }],
146
+ }),
147
+ ),
148
+ )
149
+
150
+ const { issueActivity } = await import("@/commands/issue")
151
+ const logs: string[] = []
152
+ const originalLog = console.log
153
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
154
+
155
+ try {
156
+ await Effect.runPromise(
157
+ (issueActivity as any).handler({ ref: "ACME-29" }),
158
+ )
159
+ } finally {
160
+ console.log = originalLog
161
+ }
162
+
163
+ const output = logs.join("\n")
164
+ expect(output).toContain("priority")
165
+ expect(output).toContain("?") // fallback for missing actor
166
+ })
167
+ })