@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.
- package/.github/workflows/ci.yml +36 -0
- package/.github/workflows/publish.yml +36 -0
- package/.husky/pre-commit +7 -0
- package/README.md +120 -0
- package/bin/plane +2 -0
- package/bun.lock +244 -0
- package/package.json +57 -0
- package/scripts/check-coverage.ts +54 -0
- package/scripts/check-file-size.ts +36 -0
- package/src/api.ts +81 -0
- package/src/bin.ts +72 -0
- package/src/commands/cycles.ts +108 -0
- package/src/commands/init.ts +72 -0
- package/src/commands/intake.ts +85 -0
- package/src/commands/issue.ts +409 -0
- package/src/commands/issues.ts +51 -0
- package/src/commands/labels.ts +54 -0
- package/src/commands/members.ts +20 -0
- package/src/commands/modules.ts +129 -0
- package/src/commands/pages.ts +63 -0
- package/src/commands/projects.ts +24 -0
- package/src/commands/states.ts +28 -0
- package/src/config.ts +217 -0
- package/src/format.ts +11 -0
- package/src/resolve.ts +76 -0
- package/tests/api.test.ts +169 -0
- package/tests/cycles-extended.test.ts +183 -0
- package/tests/format.test.ts +60 -0
- package/tests/helpers/mock-api.ts +27 -0
- package/tests/intake.test.ts +157 -0
- package/tests/issue-activity.test.ts +167 -0
- package/tests/issue-commands.test.ts +322 -0
- package/tests/issue-comments-worklogs.test.ts +291 -0
- package/tests/issue-links.test.ts +206 -0
- package/tests/modules.test.ts +255 -0
- package/tests/new-schemas.test.ts +201 -0
- package/tests/new-schemas2.test.ts +205 -0
- package/tests/pages.test.ts +124 -0
- package/tests/resolve.test.ts +178 -0
- package/tests/schemas.test.ts +203 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { Effect, Schema } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
WorklogSchema,
|
|
5
|
+
WorklogsResponseSchema,
|
|
6
|
+
IntakeIssueSchema,
|
|
7
|
+
IntakeIssuesResponseSchema,
|
|
8
|
+
PageSchema,
|
|
9
|
+
PagesResponseSchema,
|
|
10
|
+
CommentSchema,
|
|
11
|
+
CommentsResponseSchema,
|
|
12
|
+
CycleIssueSchema,
|
|
13
|
+
CycleIssuesResponseSchema,
|
|
14
|
+
} from "@/config"
|
|
15
|
+
|
|
16
|
+
async function decode<A, I>(schema: Schema.Schema<A, I>, data: unknown): Promise<A> {
|
|
17
|
+
return Effect.runPromise(
|
|
18
|
+
Schema.decodeUnknown(schema)(data).pipe(Effect.mapError((e) => new Error(String(e)))),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("WorklogSchema", () => {
|
|
23
|
+
it("decodes a full worklog", async () => {
|
|
24
|
+
const w = await decode(WorklogSchema, {
|
|
25
|
+
id: "w1",
|
|
26
|
+
description: "Code review",
|
|
27
|
+
duration: 90,
|
|
28
|
+
logged_by_detail: { display_name: "Aaron" },
|
|
29
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
30
|
+
})
|
|
31
|
+
expect(w.duration).toBe(90)
|
|
32
|
+
expect(w.logged_by_detail?.display_name).toBe("Aaron")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("decodes with null description", async () => {
|
|
36
|
+
const w = await decode(WorklogSchema, {
|
|
37
|
+
id: "w2",
|
|
38
|
+
description: null,
|
|
39
|
+
duration: 30,
|
|
40
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
41
|
+
})
|
|
42
|
+
expect(w.description).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("rejects missing duration", async () => {
|
|
46
|
+
await expect(
|
|
47
|
+
decode(WorklogSchema, { id: "w3", created_at: "2025-01-15T10:00:00Z" }),
|
|
48
|
+
).rejects.toThrow()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("WorklogsResponseSchema", () => {
|
|
53
|
+
it("decodes results", async () => {
|
|
54
|
+
const resp = await decode(WorklogsResponseSchema, {
|
|
55
|
+
results: [{ id: "w1", duration: 60, created_at: "2025-01-15T10:00:00Z" }],
|
|
56
|
+
})
|
|
57
|
+
expect(resp.results).toHaveLength(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("decodes empty", async () => {
|
|
61
|
+
const resp = await decode(WorklogsResponseSchema, { results: [] })
|
|
62
|
+
expect(resp.results).toHaveLength(0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("IntakeIssueSchema", () => {
|
|
67
|
+
it("decodes a full intake issue", async () => {
|
|
68
|
+
const i = await decode(IntakeIssueSchema, {
|
|
69
|
+
id: "int1",
|
|
70
|
+
issue: "issue-uuid",
|
|
71
|
+
issue_detail: { id: "issue-uuid", sequence_id: 42, name: "Bug report", priority: "high" },
|
|
72
|
+
status: 0,
|
|
73
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
74
|
+
})
|
|
75
|
+
expect(i.status).toBe(0)
|
|
76
|
+
expect(i.issue_detail?.sequence_id).toBe(42)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("decodes minimal intake issue", async () => {
|
|
80
|
+
const i = await decode(IntakeIssueSchema, {
|
|
81
|
+
id: "int2",
|
|
82
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
83
|
+
})
|
|
84
|
+
expect(i.id).toBe("int2")
|
|
85
|
+
expect(i.status).toBeUndefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("rejects missing id", async () => {
|
|
89
|
+
await expect(
|
|
90
|
+
decode(IntakeIssueSchema, { created_at: "2025-01-15T10:00:00Z" }),
|
|
91
|
+
).rejects.toThrow()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("IntakeIssuesResponseSchema", () => {
|
|
96
|
+
it("decodes results", async () => {
|
|
97
|
+
const resp = await decode(IntakeIssuesResponseSchema, {
|
|
98
|
+
results: [{ id: "int1", created_at: "2025-01-15T10:00:00Z" }],
|
|
99
|
+
})
|
|
100
|
+
expect(resp.results).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("PageSchema", () => {
|
|
105
|
+
it("decodes a page", async () => {
|
|
106
|
+
const p = await decode(PageSchema, {
|
|
107
|
+
id: "pg1",
|
|
108
|
+
name: "Architecture Overview",
|
|
109
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
110
|
+
updated_at: "2025-01-16T10:00:00Z",
|
|
111
|
+
})
|
|
112
|
+
expect(p.name).toBe("Architecture Overview")
|
|
113
|
+
expect(p.updated_at).toBe("2025-01-16T10:00:00Z")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("accepts null description_html", async () => {
|
|
117
|
+
const p = await decode(PageSchema, {
|
|
118
|
+
id: "pg2",
|
|
119
|
+
name: "Empty page",
|
|
120
|
+
description_html: null,
|
|
121
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
122
|
+
})
|
|
123
|
+
expect(p.description_html).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("rejects missing name", async () => {
|
|
127
|
+
await expect(
|
|
128
|
+
decode(PageSchema, { id: "pg3", created_at: "2025-01-15T10:00:00Z" }),
|
|
129
|
+
).rejects.toThrow()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("PagesResponseSchema", () => {
|
|
134
|
+
it("decodes results", async () => {
|
|
135
|
+
const resp = await decode(PagesResponseSchema, {
|
|
136
|
+
results: [{ id: "pg1", name: "Arch", created_at: "2025-01-15T10:00:00Z" }],
|
|
137
|
+
})
|
|
138
|
+
expect(resp.results[0].name).toBe("Arch")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("CommentSchema", () => {
|
|
143
|
+
it("decodes a comment", async () => {
|
|
144
|
+
const c = await decode(CommentSchema, {
|
|
145
|
+
id: "c1",
|
|
146
|
+
comment_html: "<p>Hello</p>",
|
|
147
|
+
actor_detail: { display_name: "Aaron" },
|
|
148
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
149
|
+
})
|
|
150
|
+
expect(c.comment_html).toBe("<p>Hello</p>")
|
|
151
|
+
expect(c.actor_detail?.display_name).toBe("Aaron")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("decodes without optional fields", async () => {
|
|
155
|
+
const c = await decode(CommentSchema, {
|
|
156
|
+
id: "c2",
|
|
157
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
158
|
+
})
|
|
159
|
+
expect(c.id).toBe("c2")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("rejects missing id", async () => {
|
|
163
|
+
await expect(
|
|
164
|
+
decode(CommentSchema, { created_at: "2025-01-15T10:00:00Z" }),
|
|
165
|
+
).rejects.toThrow()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe("CommentsResponseSchema", () => {
|
|
170
|
+
it("decodes results", async () => {
|
|
171
|
+
const resp = await decode(CommentsResponseSchema, {
|
|
172
|
+
results: [{ id: "c1", created_at: "2025-01-15T10:00:00Z" }],
|
|
173
|
+
})
|
|
174
|
+
expect(resp.results).toHaveLength(1)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe("CycleIssueSchema", () => {
|
|
179
|
+
it("decodes with detail", async () => {
|
|
180
|
+
const ci = await decode(CycleIssueSchema, {
|
|
181
|
+
id: "ci1",
|
|
182
|
+
issue: "i1",
|
|
183
|
+
issue_detail: { id: "i1", sequence_id: 5, name: "Fix bug" },
|
|
184
|
+
})
|
|
185
|
+
expect(ci.issue_detail?.sequence_id).toBe(5)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("decodes without detail", async () => {
|
|
189
|
+
const ci = await decode(CycleIssueSchema, { id: "ci2", issue: "i2" })
|
|
190
|
+
expect(ci.issue).toBe("i2")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("rejects missing issue", async () => {
|
|
194
|
+
await expect(decode(CycleIssueSchema, { id: "ci3" })).rejects.toThrow()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe("CycleIssuesResponseSchema", () => {
|
|
199
|
+
it("decodes results", async () => {
|
|
200
|
+
const resp = await decode(CycleIssuesResponseSchema, {
|
|
201
|
+
results: [{ id: "ci1", issue: "i1" }],
|
|
202
|
+
})
|
|
203
|
+
expect(resp.results).toHaveLength(1)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
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://pages-test.local"
|
|
8
|
+
const WS = "testws"
|
|
9
|
+
|
|
10
|
+
const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme Project" }]
|
|
11
|
+
const PAGES = [
|
|
12
|
+
{
|
|
13
|
+
id: "pg1",
|
|
14
|
+
name: "Architecture Overview",
|
|
15
|
+
description_html: "<p>Our architecture...</p>",
|
|
16
|
+
created_at: "2025-01-10T10:00:00Z",
|
|
17
|
+
updated_at: "2025-01-15T10:00:00Z",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "pg2",
|
|
21
|
+
name: "Migration Guide",
|
|
22
|
+
description_html: null,
|
|
23
|
+
created_at: "2025-01-05T10:00:00Z",
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const server = setupServer(
|
|
28
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
|
|
29
|
+
HttpResponse.json({ results: PROJECTS }),
|
|
30
|
+
),
|
|
31
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, () =>
|
|
32
|
+
HttpResponse.json({ results: PAGES }),
|
|
33
|
+
),
|
|
34
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/`, () =>
|
|
35
|
+
HttpResponse.json(PAGES[0]),
|
|
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("pagesList", () => {
|
|
57
|
+
it("lists pages with updated date and name", async () => {
|
|
58
|
+
const { pagesList } = await import("@/commands/pages")
|
|
59
|
+
const logs: string[] = []
|
|
60
|
+
const orig = console.log
|
|
61
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
62
|
+
try {
|
|
63
|
+
await Effect.runPromise((pagesList as any).handler({ project: "ACME" }))
|
|
64
|
+
} finally {
|
|
65
|
+
console.log = orig
|
|
66
|
+
}
|
|
67
|
+
const output = logs.join("\n")
|
|
68
|
+
expect(output).toContain("pg1")
|
|
69
|
+
expect(output).toContain("2025-01-15")
|
|
70
|
+
expect(output).toContain("Architecture Overview")
|
|
71
|
+
expect(output).toContain("pg2")
|
|
72
|
+
expect(output).toContain("Migration Guide")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("falls back to created_at when no updated_at", async () => {
|
|
76
|
+
const { pagesList } = await import("@/commands/pages")
|
|
77
|
+
const logs: string[] = []
|
|
78
|
+
const orig = console.log
|
|
79
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
80
|
+
try {
|
|
81
|
+
await Effect.runPromise((pagesList as any).handler({ project: "ACME" }))
|
|
82
|
+
} finally {
|
|
83
|
+
console.log = orig
|
|
84
|
+
}
|
|
85
|
+
// pg2 has no updated_at, should use created_at
|
|
86
|
+
expect(logs.join("\n")).toContain("2025-01-05")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("shows 'No pages' when empty", async () => {
|
|
90
|
+
server.use(
|
|
91
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, () =>
|
|
92
|
+
HttpResponse.json({ results: [] }),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
const { pagesList } = await import("@/commands/pages")
|
|
96
|
+
const logs: string[] = []
|
|
97
|
+
const orig = console.log
|
|
98
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
99
|
+
try {
|
|
100
|
+
await Effect.runPromise((pagesList as any).handler({ project: "ACME" }))
|
|
101
|
+
} finally {
|
|
102
|
+
console.log = orig
|
|
103
|
+
}
|
|
104
|
+
expect(logs.join("\n")).toBe("No pages")
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe("pagesGet", () => {
|
|
109
|
+
it("prints full JSON for a page", async () => {
|
|
110
|
+
const { pagesGet } = await import("@/commands/pages")
|
|
111
|
+
const logs: string[] = []
|
|
112
|
+
const orig = console.log
|
|
113
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
114
|
+
try {
|
|
115
|
+
await Effect.runPromise((pagesGet as any).handler({ project: "ACME", pageId: "pg1" }))
|
|
116
|
+
} finally {
|
|
117
|
+
console.log = orig
|
|
118
|
+
}
|
|
119
|
+
const parsed = JSON.parse(logs.join("\n"))
|
|
120
|
+
expect(parsed.id).toBe("pg1")
|
|
121
|
+
expect(parsed.name).toBe("Architecture Overview")
|
|
122
|
+
expect(parsed.description_html).toContain("architecture")
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
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 {
|
|
6
|
+
resolveProject,
|
|
7
|
+
parseIssueRef,
|
|
8
|
+
findIssueBySeq,
|
|
9
|
+
getStateId,
|
|
10
|
+
_clearProjectCache,
|
|
11
|
+
} from "@/resolve"
|
|
12
|
+
|
|
13
|
+
const BASE = "http://test.local"
|
|
14
|
+
const WS = "testws"
|
|
15
|
+
|
|
16
|
+
const PROJECTS = [
|
|
17
|
+
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
|
|
18
|
+
{ id: "proj-web", identifier: "WEB", name: "Web Project" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const ISSUES = [
|
|
22
|
+
{ id: "i1", sequence_id: 29, name: "Migrate Button", priority: "high", state: "s1" },
|
|
23
|
+
{ id: "i2", sequence_id: 30, name: "Migrate TextInput", priority: "medium", state: "s2" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const STATES = [
|
|
27
|
+
{ id: "s-backlog", name: "Backlog", group: "backlog" },
|
|
28
|
+
{ id: "s-todo", name: "Todo", group: "unstarted" },
|
|
29
|
+
{ id: "s-progress", name: "In Progress", group: "started" },
|
|
30
|
+
{ id: "s-done", name: "Done", group: "completed" },
|
|
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/states/`, () =>
|
|
41
|
+
HttpResponse.json({ results: STATES }),
|
|
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("resolveProject", () => {
|
|
63
|
+
it("resolves a known project identifier", async () => {
|
|
64
|
+
const result = await Effect.runPromise(resolveProject("ACME"))
|
|
65
|
+
expect(result.key).toBe("ACME")
|
|
66
|
+
expect(result.id).toBe("proj-acme")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("is case-insensitive", async () => {
|
|
70
|
+
const result = await Effect.runPromise(resolveProject("acme"))
|
|
71
|
+
expect(result.key).toBe("ACME")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("resolves WEB too", async () => {
|
|
75
|
+
const result = await Effect.runPromise(resolveProject("WEB"))
|
|
76
|
+
expect(result.id).toBe("proj-web")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("fails for unknown project", async () => {
|
|
80
|
+
const result = await Effect.runPromise(Effect.either(resolveProject("NOPE")))
|
|
81
|
+
expect(result._tag).toBe("Left")
|
|
82
|
+
if (result._tag === "Left") {
|
|
83
|
+
expect(result.left.message).toContain("Unknown project")
|
|
84
|
+
expect(result.left.message).toContain("NOPE")
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("uses the cache on second call", async () => {
|
|
89
|
+
await Effect.runPromise(resolveProject("ACME"))
|
|
90
|
+
// Override with empty — cache hit should prevent re-fetching
|
|
91
|
+
server.use(
|
|
92
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
|
|
93
|
+
HttpResponse.json({ results: [] }),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
const result = await Effect.runPromise(resolveProject("WEB"))
|
|
97
|
+
expect(result.id).toBe("proj-web") // still from cache
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("parseIssueRef", () => {
|
|
102
|
+
it("parses a valid ref", async () => {
|
|
103
|
+
const result = await Effect.runPromise(parseIssueRef("ACME-29"))
|
|
104
|
+
expect(result.projKey).toBe("ACME")
|
|
105
|
+
expect(result.seq).toBe(29)
|
|
106
|
+
expect(result.projectId).toBe("proj-acme")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("is case-insensitive", async () => {
|
|
110
|
+
const result = await Effect.runPromise(parseIssueRef("acme-29"))
|
|
111
|
+
expect(result.projKey).toBe("ACME")
|
|
112
|
+
expect(result.seq).toBe(29)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("fails on missing dash", async () => {
|
|
116
|
+
const result = await Effect.runPromise(Effect.either(parseIssueRef("ACME29")))
|
|
117
|
+
expect(result._tag).toBe("Left")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("fails on non-numeric sequence", async () => {
|
|
121
|
+
const result = await Effect.runPromise(Effect.either(parseIssueRef("ACME-abc")))
|
|
122
|
+
expect(result._tag).toBe("Left")
|
|
123
|
+
if (result._tag === "Left") {
|
|
124
|
+
expect(result.left.message).toContain("Invalid issue ref")
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("fails on empty string", async () => {
|
|
129
|
+
const result = await Effect.runPromise(Effect.either(parseIssueRef("")))
|
|
130
|
+
expect(result._tag).toBe("Left")
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe("findIssueBySeq", () => {
|
|
135
|
+
it("finds issue by sequence_id", async () => {
|
|
136
|
+
const issue = await Effect.runPromise(findIssueBySeq("proj-acme", 29))
|
|
137
|
+
expect(issue.id).toBe("i1")
|
|
138
|
+
expect(issue.name).toBe("Migrate Button")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("finds second issue", async () => {
|
|
142
|
+
const issue = await Effect.runPromise(findIssueBySeq("proj-acme", 30))
|
|
143
|
+
expect(issue.sequence_id).toBe(30)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("fails when issue not found", async () => {
|
|
147
|
+
const result = await Effect.runPromise(Effect.either(findIssueBySeq("proj-acme", 999)))
|
|
148
|
+
expect(result._tag).toBe("Left")
|
|
149
|
+
if (result._tag === "Left") {
|
|
150
|
+
expect(result.left.message).toContain("#999 not found")
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("getStateId", () => {
|
|
156
|
+
it("finds state by group name", async () => {
|
|
157
|
+
const id = await Effect.runPromise(getStateId("proj-acme", "completed"))
|
|
158
|
+
expect(id).toBe("s-done")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("finds state by exact name (case-insensitive)", async () => {
|
|
162
|
+
const id = await Effect.runPromise(getStateId("proj-acme", "in progress"))
|
|
163
|
+
expect(id).toBe("s-progress")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("finds backlog state", async () => {
|
|
167
|
+
const id = await Effect.runPromise(getStateId("proj-acme", "backlog"))
|
|
168
|
+
expect(id).toBe("s-backlog")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("fails for unknown state", async () => {
|
|
172
|
+
const result = await Effect.runPromise(Effect.either(getStateId("proj-acme", "nope")))
|
|
173
|
+
expect(result._tag).toBe("Left")
|
|
174
|
+
if (result._tag === "Left") {
|
|
175
|
+
expect(result.left.message).toContain("State not found")
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|