@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,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
+ })