@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,206 @@
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://links-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 LINKS = [
13
+ { id: "lnk1", title: "PR #42", url: "https://github.com/org/repo/pull/42", created_at: "2025-01-15T10:00:00Z" },
14
+ { id: "lnk2", title: null, url: "https://docs.example.com", created_at: "2025-01-14T10:00:00Z" },
15
+ ]
16
+
17
+ const server = setupServer(
18
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
19
+ HttpResponse.json({ results: PROJECTS }),
20
+ ),
21
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
22
+ HttpResponse.json({ results: ISSUES }),
23
+ ),
24
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, () =>
25
+ HttpResponse.json({ results: LINKS }),
26
+ ),
27
+ )
28
+
29
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
30
+ afterAll(() => server.close())
31
+
32
+ beforeEach(() => {
33
+ _clearProjectCache()
34
+ process.env["PLANE_HOST"] = BASE
35
+ process.env["PLANE_WORKSPACE"] = WS
36
+ process.env["PLANE_API_TOKEN"] = "test-token"
37
+ })
38
+
39
+ afterEach(() => {
40
+ server.resetHandlers()
41
+ delete process.env["PLANE_HOST"]
42
+ delete process.env["PLANE_WORKSPACE"]
43
+ delete process.env["PLANE_API_TOKEN"]
44
+ })
45
+
46
+ describe("issueLinkList", () => {
47
+ it("lists links for an issue", async () => {
48
+ const { issueLinkList } = await import("@/commands/issue")
49
+ const logs: string[] = []
50
+ const orig = console.log
51
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
52
+
53
+ try {
54
+ await Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-29" }))
55
+ } finally {
56
+ console.log = orig
57
+ }
58
+
59
+ const output = logs.join("\n")
60
+ expect(output).toContain("lnk1")
61
+ expect(output).toContain("PR #42")
62
+ expect(output).toContain("https://github.com/org/repo/pull/42")
63
+ })
64
+
65
+ it("shows '(no title)' for null title", async () => {
66
+ const { issueLinkList } = await import("@/commands/issue")
67
+ const logs: string[] = []
68
+ const orig = console.log
69
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
70
+
71
+ try {
72
+ await Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-29" }))
73
+ } finally {
74
+ console.log = orig
75
+ }
76
+
77
+ expect(logs.join("\n")).toContain("(no title)")
78
+ })
79
+
80
+ it("shows 'No links' when empty", async () => {
81
+ server.use(
82
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, () =>
83
+ HttpResponse.json({ results: [] }),
84
+ ),
85
+ )
86
+
87
+ const { issueLinkList } = 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((issueLinkList as any).handler({ ref: "ACME-29" }))
94
+ } finally {
95
+ console.log = orig
96
+ }
97
+
98
+ expect(logs.join("\n")).toBe("No links")
99
+ })
100
+ })
101
+
102
+ describe("issueLinkAdd", () => {
103
+ it("adds a link without title", async () => {
104
+ server.use(
105
+ http.post(
106
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
107
+ async ({ request }) => {
108
+ const body = (await request.json()) as any
109
+ return HttpResponse.json({
110
+ id: "lnk-new",
111
+ url: body.url,
112
+ created_at: "2025-01-15T10:00:00Z",
113
+ })
114
+ },
115
+ ),
116
+ )
117
+
118
+ const { issueLinkAdd } = await import("@/commands/issue")
119
+ const logs: string[] = []
120
+ const orig = console.log
121
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
122
+
123
+ try {
124
+ await Effect.runPromise(
125
+ (issueLinkAdd as any).handler({
126
+ ref: "ACME-29",
127
+ url: "https://example.com",
128
+ title: { _tag: "None" },
129
+ }),
130
+ )
131
+ } finally {
132
+ console.log = orig
133
+ }
134
+
135
+ const output = logs.join("\n")
136
+ expect(output).toContain("lnk-new")
137
+ expect(output).toContain("https://example.com")
138
+ })
139
+
140
+ it("adds a link with title", async () => {
141
+ server.use(
142
+ http.post(
143
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
144
+ async ({ request }) => {
145
+ const body = (await request.json()) as any
146
+ return HttpResponse.json({
147
+ id: "lnk-new2",
148
+ title: body.title,
149
+ url: body.url,
150
+ created_at: "2025-01-15T10:00:00Z",
151
+ })
152
+ },
153
+ ),
154
+ )
155
+
156
+ const { issueLinkAdd } = await import("@/commands/issue")
157
+ const logs: string[] = []
158
+ const orig = console.log
159
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
160
+
161
+ try {
162
+ await Effect.runPromise(
163
+ (issueLinkAdd as any).handler({
164
+ ref: "ACME-29",
165
+ url: "https://docs.example.com",
166
+ title: { _tag: "Some", value: "Design doc" },
167
+ }),
168
+ )
169
+ } finally {
170
+ console.log = orig
171
+ }
172
+
173
+ expect(logs.join("\n")).toContain("lnk-new2")
174
+ })
175
+ })
176
+
177
+ describe("issueLinkRemove", () => {
178
+ it("removes a link", async () => {
179
+ let deleted = false
180
+ server.use(
181
+ http.delete(
182
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/lnk1/`,
183
+ () => {
184
+ deleted = true
185
+ return new HttpResponse(null, { status: 204 })
186
+ },
187
+ ),
188
+ )
189
+
190
+ const { issueLinkRemove } = await import("@/commands/issue")
191
+ const logs: string[] = []
192
+ const orig = console.log
193
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
194
+
195
+ try {
196
+ await Effect.runPromise(
197
+ (issueLinkRemove as any).handler({ ref: "ACME-29", linkId: "lnk1" }),
198
+ )
199
+ } finally {
200
+ console.log = orig
201
+ }
202
+
203
+ expect(deleted).toBe(true)
204
+ expect(logs.join("\n")).toContain("lnk1")
205
+ })
206
+ })
@@ -0,0 +1,255 @@
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://modules-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 MODULES = [
13
+ { id: "mod1", name: "Sprint 1", status: "in_progress" },
14
+ { id: "mod2", name: "Sprint 2", status: "backlog" },
15
+ ]
16
+ const MODULE_ISSUES = [
17
+ {
18
+ id: "mi1",
19
+ issue: "i1",
20
+ issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" },
21
+ },
22
+ ]
23
+
24
+ const server = setupServer(
25
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
26
+ HttpResponse.json({ results: PROJECTS }),
27
+ ),
28
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
29
+ HttpResponse.json({ results: ISSUES }),
30
+ ),
31
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, () =>
32
+ HttpResponse.json({ results: MODULES }),
33
+ ),
34
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`, () =>
35
+ HttpResponse.json({ results: MODULE_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("modulesList", () => {
57
+ it("lists modules for a project", async () => {
58
+ const { modulesList } = await import("@/commands/modules")
59
+ const logs: string[] = []
60
+ const orig = console.log
61
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
62
+
63
+ try {
64
+ await Effect.runPromise((modulesList as any).handler({ project: "ACME" }))
65
+ } finally {
66
+ console.log = orig
67
+ }
68
+
69
+ const output = logs.join("\n")
70
+ expect(output).toContain("mod1")
71
+ expect(output).toContain("Sprint 1")
72
+ expect(output).toContain("in_progress")
73
+ expect(output).toContain("mod2")
74
+ expect(output).toContain("Sprint 2")
75
+ })
76
+
77
+ it("shows 'No modules found' when empty", async () => {
78
+ server.use(
79
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, () =>
80
+ HttpResponse.json({ results: [] }),
81
+ ),
82
+ )
83
+
84
+ const { modulesList } = await import("@/commands/modules")
85
+ const logs: string[] = []
86
+ const orig = console.log
87
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
88
+
89
+ try {
90
+ await Effect.runPromise((modulesList as any).handler({ project: "ACME" }))
91
+ } finally {
92
+ console.log = orig
93
+ }
94
+
95
+ expect(logs.join("\n")).toBe("No modules found")
96
+ })
97
+
98
+ it("shows '?' for missing status", async () => {
99
+ server.use(
100
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, () =>
101
+ HttpResponse.json({ results: [{ id: "mod3", name: "Unstarted Sprint" }] }),
102
+ ),
103
+ )
104
+
105
+ const { modulesList } = await import("@/commands/modules")
106
+ const logs: string[] = []
107
+ const orig = console.log
108
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
109
+
110
+ try {
111
+ await Effect.runPromise((modulesList as any).handler({ project: "ACME" }))
112
+ } finally {
113
+ console.log = orig
114
+ }
115
+
116
+ expect(logs.join("\n")).toContain("?")
117
+ })
118
+ })
119
+
120
+ describe("moduleIssuesList", () => {
121
+ it("lists issues in a module with detail", async () => {
122
+ const { moduleIssuesList } = await import("@/commands/modules")
123
+ const logs: string[] = []
124
+ const orig = console.log
125
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
126
+
127
+ try {
128
+ await Effect.runPromise(
129
+ (moduleIssuesList as any).handler({ project: "ACME", moduleId: "mod1" }),
130
+ )
131
+ } finally {
132
+ console.log = orig
133
+ }
134
+
135
+ const output = logs.join("\n")
136
+ expect(output).toContain("ACME-")
137
+ expect(output).toContain("29")
138
+ expect(output).toContain("Migrate Button")
139
+ })
140
+
141
+ it("falls back to issue UUID when no issue_detail", async () => {
142
+ server.use(
143
+ http.get(
144
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`,
145
+ () => HttpResponse.json({ results: [{ id: "mi2", issue: "bare-uuid" }] }),
146
+ ),
147
+ )
148
+
149
+ const { moduleIssuesList } = await import("@/commands/modules")
150
+ const logs: string[] = []
151
+ const orig = console.log
152
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
153
+
154
+ try {
155
+ await Effect.runPromise(
156
+ (moduleIssuesList as any).handler({ project: "ACME", moduleId: "mod1" }),
157
+ )
158
+ } finally {
159
+ console.log = orig
160
+ }
161
+
162
+ expect(logs.join("\n")).toContain("bare-uuid")
163
+ })
164
+
165
+ it("shows 'No issues in module' when empty", async () => {
166
+ server.use(
167
+ http.get(
168
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`,
169
+ () => HttpResponse.json({ results: [] }),
170
+ ),
171
+ )
172
+
173
+ const { moduleIssuesList } = await import("@/commands/modules")
174
+ const logs: string[] = []
175
+ const orig = console.log
176
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
177
+
178
+ try {
179
+ await Effect.runPromise(
180
+ (moduleIssuesList as any).handler({ project: "ACME", moduleId: "mod1" }),
181
+ )
182
+ } finally {
183
+ console.log = orig
184
+ }
185
+
186
+ expect(logs.join("\n")).toBe("No issues in module")
187
+ })
188
+ })
189
+
190
+ describe("moduleIssuesAdd", () => {
191
+ it("adds an issue to a module", async () => {
192
+ let postedBody: unknown
193
+ server.use(
194
+ http.post(
195
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`,
196
+ async ({ request }) => {
197
+ postedBody = await request.json()
198
+ return HttpResponse.json({ issues: ["i1"] }, { status: 201 })
199
+ },
200
+ ),
201
+ )
202
+
203
+ const { moduleIssuesAdd } = await import("@/commands/modules")
204
+ const logs: string[] = []
205
+ const orig = console.log
206
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
207
+
208
+ try {
209
+ await Effect.runPromise(
210
+ (moduleIssuesAdd as any).handler({ project: "ACME", moduleId: "mod1", ref: "ACME-29" }),
211
+ )
212
+ } finally {
213
+ console.log = orig
214
+ }
215
+
216
+ expect((postedBody as any).issues).toContain("i1")
217
+ expect(logs.join("\n")).toContain("ACME-29")
218
+ expect(logs.join("\n")).toContain("mod1")
219
+ })
220
+ })
221
+
222
+ describe("moduleIssuesRemove", () => {
223
+ it("removes a module-issue", async () => {
224
+ let deleted = false
225
+ server.use(
226
+ http.delete(
227
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/mi1/`,
228
+ () => {
229
+ deleted = true
230
+ return new HttpResponse(null, { status: 204 })
231
+ },
232
+ ),
233
+ )
234
+
235
+ const { moduleIssuesRemove } = await import("@/commands/modules")
236
+ const logs: string[] = []
237
+ const orig = console.log
238
+ console.log = (...args: unknown[]) => logs.push(args.join(" "))
239
+
240
+ try {
241
+ await Effect.runPromise(
242
+ (moduleIssuesRemove as any).handler({
243
+ project: "ACME",
244
+ moduleId: "mod1",
245
+ moduleIssueId: "mi1",
246
+ }),
247
+ )
248
+ } finally {
249
+ console.log = orig
250
+ }
251
+
252
+ expect(deleted).toBe(true)
253
+ expect(logs.join("\n")).toContain("mi1")
254
+ })
255
+ })
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { Effect, Schema } from "effect"
3
+ import {
4
+ ActivitySchema,
5
+ ActivitiesResponseSchema,
6
+ IssueLinkSchema,
7
+ IssueLinksResponseSchema,
8
+ ModuleSchema,
9
+ ModulesResponseSchema,
10
+ ModuleIssueSchema,
11
+ ModuleIssuesResponseSchema,
12
+ } from "@/config"
13
+
14
+ async function decode<A, I>(schema: Schema.Schema<A, I>, data: unknown): Promise<A> {
15
+ return Effect.runPromise(
16
+ Schema.decodeUnknown(schema)(data).pipe(Effect.mapError((e) => new Error(String(e)))),
17
+ )
18
+ }
19
+
20
+ describe("ActivitySchema", () => {
21
+ it("decodes a full activity", async () => {
22
+ const a = await decode(ActivitySchema, {
23
+ id: "act1",
24
+ actor_detail: { display_name: "Aaron" },
25
+ field: "state",
26
+ old_value: "Backlog",
27
+ new_value: "In Progress",
28
+ verb: "updated",
29
+ created_at: "2025-01-15T10:30:00Z",
30
+ })
31
+ expect(a.id).toBe("act1")
32
+ expect(a.actor_detail?.display_name).toBe("Aaron")
33
+ expect(a.field).toBe("state")
34
+ expect(a.old_value).toBe("Backlog")
35
+ expect(a.new_value).toBe("In Progress")
36
+ expect(a.created_at).toBe("2025-01-15T10:30:00Z")
37
+ })
38
+
39
+ it("decodes activity with null field values", async () => {
40
+ const a = await decode(ActivitySchema, {
41
+ id: "act2",
42
+ field: null,
43
+ old_value: null,
44
+ new_value: null,
45
+ created_at: "2025-01-15T10:30:00Z",
46
+ })
47
+ expect(a.field).toBeNull()
48
+ expect(a.old_value).toBeNull()
49
+ })
50
+
51
+ it("decodes minimal activity (no optional fields)", async () => {
52
+ const a = await decode(ActivitySchema, {
53
+ id: "act3",
54
+ created_at: "2025-01-15T10:30:00Z",
55
+ })
56
+ expect(a.id).toBe("act3")
57
+ })
58
+
59
+ it("rejects missing id", async () => {
60
+ await expect(
61
+ decode(ActivitySchema, { created_at: "2025-01-15T10:30:00Z" }),
62
+ ).rejects.toThrow()
63
+ })
64
+ })
65
+
66
+ describe("ActivitiesResponseSchema", () => {
67
+ it("decodes results array", async () => {
68
+ const resp = await decode(ActivitiesResponseSchema, {
69
+ results: [
70
+ { id: "a1", created_at: "2025-01-15T10:30:00Z" },
71
+ { id: "a2", created_at: "2025-01-16T10:30:00Z" },
72
+ ],
73
+ })
74
+ expect(resp.results).toHaveLength(2)
75
+ })
76
+
77
+ it("decodes empty results", async () => {
78
+ const resp = await decode(ActivitiesResponseSchema, { results: [] })
79
+ expect(resp.results).toHaveLength(0)
80
+ })
81
+ })
82
+
83
+ describe("IssueLinkSchema", () => {
84
+ it("decodes a full link", async () => {
85
+ const l = await decode(IssueLinkSchema, {
86
+ id: "link1",
87
+ title: "PR #42",
88
+ url: "https://github.com/org/repo/pull/42",
89
+ created_at: "2025-01-15T10:30:00Z",
90
+ })
91
+ expect(l.id).toBe("link1")
92
+ expect(l.title).toBe("PR #42")
93
+ expect(l.url).toBe("https://github.com/org/repo/pull/42")
94
+ })
95
+
96
+ it("decodes link with null title", async () => {
97
+ const l = await decode(IssueLinkSchema, {
98
+ id: "link2",
99
+ title: null,
100
+ url: "https://example.com",
101
+ created_at: "2025-01-15T10:30:00Z",
102
+ })
103
+ expect(l.title).toBeNull()
104
+ })
105
+
106
+ it("rejects missing url", async () => {
107
+ await expect(
108
+ decode(IssueLinkSchema, { id: "link3", created_at: "2025-01-15T10:30:00Z" }),
109
+ ).rejects.toThrow()
110
+ })
111
+ })
112
+
113
+ describe("IssueLinksResponseSchema", () => {
114
+ it("decodes results", async () => {
115
+ const resp = await decode(IssueLinksResponseSchema, {
116
+ results: [
117
+ { id: "l1", url: "https://example.com/1", created_at: "2025-01-15T10:00:00Z" },
118
+ ],
119
+ })
120
+ expect(resp.results).toHaveLength(1)
121
+ })
122
+
123
+ it("decodes empty results", async () => {
124
+ const resp = await decode(IssueLinksResponseSchema, { results: [] })
125
+ expect(resp.results).toHaveLength(0)
126
+ })
127
+ })
128
+
129
+ describe("ModuleSchema", () => {
130
+ it("decodes a module", async () => {
131
+ const m = await decode(ModuleSchema, { id: "mod1", name: "Sprint 1" })
132
+ expect(m.id).toBe("mod1")
133
+ expect(m.name).toBe("Sprint 1")
134
+ })
135
+
136
+ it("accepts optional status and description", async () => {
137
+ const m = await decode(ModuleSchema, {
138
+ id: "mod1",
139
+ name: "Sprint 1",
140
+ status: "in_progress",
141
+ description: "Focus on migration",
142
+ })
143
+ expect(m.status).toBe("in_progress")
144
+ expect(m.description).toBe("Focus on migration")
145
+ })
146
+
147
+ it("accepts null description", async () => {
148
+ const m = await decode(ModuleSchema, { id: "mod1", name: "Sprint 1", description: null })
149
+ expect(m.description).toBeNull()
150
+ })
151
+
152
+ it("rejects missing name", async () => {
153
+ await expect(decode(ModuleSchema, { id: "mod1" })).rejects.toThrow()
154
+ })
155
+ })
156
+
157
+ describe("ModulesResponseSchema", () => {
158
+ it("decodes results", async () => {
159
+ const resp = await decode(ModulesResponseSchema, {
160
+ results: [
161
+ { id: "m1", name: "Sprint 1" },
162
+ { id: "m2", name: "Sprint 2" },
163
+ ],
164
+ })
165
+ expect(resp.results).toHaveLength(2)
166
+ })
167
+ })
168
+
169
+ describe("ModuleIssueSchema", () => {
170
+ it("decodes a module issue with detail", async () => {
171
+ const mi = await decode(ModuleIssueSchema, {
172
+ id: "mi1",
173
+ issue: "issue-uuid",
174
+ issue_detail: { id: "issue-uuid", sequence_id: 29, name: "Migrate Button" },
175
+ })
176
+ expect(mi.id).toBe("mi1")
177
+ expect(mi.issue_detail?.sequence_id).toBe(29)
178
+ })
179
+
180
+ it("decodes without issue_detail", async () => {
181
+ const mi = await decode(ModuleIssueSchema, {
182
+ id: "mi1",
183
+ issue: "issue-uuid",
184
+ })
185
+ expect(mi.issue).toBe("issue-uuid")
186
+ expect(mi.issue_detail).toBeUndefined()
187
+ })
188
+
189
+ it("rejects missing issue", async () => {
190
+ await expect(decode(ModuleIssueSchema, { id: "mi1" })).rejects.toThrow()
191
+ })
192
+ })
193
+
194
+ describe("ModuleIssuesResponseSchema", () => {
195
+ it("decodes results", async () => {
196
+ const resp = await decode(ModuleIssuesResponseSchema, {
197
+ results: [{ id: "mi1", issue: "uuid1" }],
198
+ })
199
+ expect(resp.results).toHaveLength(1)
200
+ })
201
+ })