@aaronshaf/plane 0.1.3 → 0.1.5

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/src/output.ts CHANGED
@@ -1,39 +1,39 @@
1
- const jsonIdx = process.argv.indexOf("--json")
2
- const xmlIdx = process.argv.indexOf("--xml")
1
+ const jsonIdx = process.argv.indexOf("--json");
2
+ const xmlIdx = process.argv.indexOf("--xml");
3
3
 
4
- export const jsonMode = jsonIdx !== -1
5
- export const xmlMode = xmlIdx !== -1
4
+ export const jsonMode = jsonIdx !== -1;
5
+ export const xmlMode = xmlIdx !== -1;
6
6
 
7
- if (jsonIdx !== -1) process.argv.splice(jsonIdx, 1)
8
- if (xmlIdx !== -1) process.argv.splice(xmlIdx, 1)
7
+ if (jsonIdx !== -1) process.argv.splice(jsonIdx, 1);
8
+ if (xmlIdx !== -1) process.argv.splice(xmlIdx, 1);
9
9
 
10
10
  function escapeXml(val: unknown): string {
11
- return String(val ?? "")
12
- .replace(/&/g, "&")
13
- .replace(/</g, "&lt;")
14
- .replace(/>/g, "&gt;")
15
- .replace(/"/g, "&quot;")
11
+ return String(val ?? "")
12
+ .replace(/&/g, "&amp;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;");
16
16
  }
17
17
 
18
18
  function toXmlItem(obj: unknown, tag = "item"): string {
19
- if (obj === null || typeof obj !== "object") {
20
- return `<${tag}>${escapeXml(obj)}</${tag}>`
21
- }
22
- const attrs = Object.entries(obj as Record<string, unknown>)
23
- .filter(([, v]) => v === null || typeof v !== "object")
24
- .map(([k, v]) => `${k}="${escapeXml(v)}"`)
25
- .join(" ")
26
- const children = Object.entries(obj as Record<string, unknown>)
27
- .filter(([, v]) => v !== null && typeof v === "object")
28
- .map(([k, v]) =>
29
- Array.isArray(v)
30
- ? `<${k}>${v.map((i) => toXmlItem(i)).join("")}</${k}>`
31
- : toXmlItem(v, k),
32
- )
33
- .join("")
34
- return `<${tag}${attrs ? " " + attrs : ""}>${children}</${tag}>`
19
+ if (obj === null || typeof obj !== "object") {
20
+ return `<${tag}>${escapeXml(obj)}</${tag}>`;
21
+ }
22
+ const attrs = Object.entries(obj as Record<string, unknown>)
23
+ .filter(([, v]) => v === null || typeof v !== "object")
24
+ .map(([k, v]) => `${k}="${escapeXml(v)}"`)
25
+ .join(" ");
26
+ const children = Object.entries(obj as Record<string, unknown>)
27
+ .filter(([, v]) => v !== null && typeof v === "object")
28
+ .map(([k, v]) =>
29
+ Array.isArray(v)
30
+ ? `<${k}>${v.map((i) => toXmlItem(i)).join("")}</${k}>`
31
+ : toXmlItem(v, k),
32
+ )
33
+ .join("");
34
+ return `<${tag}${attrs ? " " + attrs : ""}>${children}</${tag}>`;
35
35
  }
36
36
 
37
37
  export function toXml(results: readonly unknown[]): string {
38
- return `<results>\n${results.map((r) => " " + toXmlItem(r)).join("\n")}\n</results>`
38
+ return `<results>\n${results.map((r) => " " + toXmlItem(r)).join("\n")}\n</results>`;
39
39
  }
package/src/resolve.ts CHANGED
@@ -1,76 +1,89 @@
1
- import { Effect } from "effect"
2
- import { api, decodeOrFail } from "./api.js"
3
- import { IssuesResponseSchema, StatesResponseSchema, ProjectsResponseSchema } from "./config.js"
1
+ import { Effect } from "effect";
2
+ import { api, decodeOrFail } from "./api.js";
3
+ import {
4
+ IssuesResponseSchema,
5
+ StatesResponseSchema,
6
+ ProjectsResponseSchema,
7
+ } from "./config.js";
4
8
 
5
9
  // Cache project list within a process invocation
6
- let _projectCache: Record<string, string> | null = null
10
+ let _projectCache: Record<string, string> | null = null;
7
11
 
8
12
  /** Clear the project cache — for use in tests only */
9
13
  export function _clearProjectCache() {
10
- _projectCache = null
14
+ _projectCache = null;
11
15
  }
12
16
 
13
17
  function getProjectMap(): Effect.Effect<Record<string, string>, Error> {
14
- if (_projectCache) return Effect.succeed(_projectCache)
15
- return Effect.gen(function* () {
16
- const raw = yield* api.get("projects/")
17
- const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw)
18
- _projectCache = Object.fromEntries(results.map((p) => [p.identifier.toUpperCase(), p.id]))
19
- return _projectCache
20
- })
18
+ if (_projectCache) return Effect.succeed(_projectCache);
19
+ return Effect.gen(function* () {
20
+ const raw = yield* api.get("projects/");
21
+ const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw);
22
+ _projectCache = Object.fromEntries(
23
+ results.map((p) => [p.identifier.toUpperCase(), p.id]),
24
+ );
25
+ return _projectCache;
26
+ });
21
27
  }
22
28
 
23
29
  export function resolveProject(
24
- identifier: string,
30
+ identifier: string,
25
31
  ): Effect.Effect<{ key: string; id: string }, Error> {
26
- const key = identifier.toUpperCase()
27
- return getProjectMap().pipe(
28
- Effect.flatMap((map) => {
29
- const id = map[key]
30
- if (!id) {
31
- return Effect.fail(
32
- new Error(`Unknown project: ${identifier}. Known: ${Object.keys(map).join(", ")}`),
33
- )
34
- }
35
- return Effect.succeed({ key, id })
36
- }),
37
- )
32
+ const key = identifier.toUpperCase();
33
+ return getProjectMap().pipe(
34
+ Effect.flatMap((map) => {
35
+ const id = map[key];
36
+ if (!id) {
37
+ return Effect.fail(
38
+ new Error(
39
+ `Unknown project: ${identifier}. Known: ${Object.keys(map).join(", ")}`,
40
+ ),
41
+ );
42
+ }
43
+ return Effect.succeed({ key, id });
44
+ }),
45
+ );
38
46
  }
39
47
 
40
48
  export function parseIssueRef(
41
- ref: string,
49
+ ref: string,
42
50
  ): Effect.Effect<{ projectId: string; projKey: string; seq: number }, Error> {
43
- const parts = ref.toUpperCase().split("-")
44
- if (parts.length !== 2 || !/^\d+$/.test(parts[1])) {
45
- return Effect.fail(
46
- new Error(`Invalid issue ref: ${ref}. Expected format like PROJ-29`),
47
- )
48
- }
49
- const [projKey, seqStr] = parts
50
- return resolveProject(projKey).pipe(
51
- Effect.map(({ id }) => ({ projectId: id, projKey, seq: parseInt(seqStr, 10) })),
52
- )
51
+ const parts = ref.toUpperCase().split("-");
52
+ if (parts.length !== 2 || !/^\d+$/.test(parts[1])) {
53
+ return Effect.fail(
54
+ new Error(`Invalid issue ref: ${ref}. Expected format like PROJ-29`),
55
+ );
56
+ }
57
+ const [projKey, seqStr] = parts;
58
+ return resolveProject(projKey).pipe(
59
+ Effect.map(({ id }) => ({
60
+ projectId: id,
61
+ projKey,
62
+ seq: parseInt(seqStr, 10),
63
+ })),
64
+ );
53
65
  }
54
66
 
55
67
  export function findIssueBySeq(projectId: string, seq: number) {
56
- return Effect.gen(function* () {
57
- const raw = yield* api.get(`projects/${projectId}/issues/`)
58
- const { results } = yield* decodeOrFail(IssuesResponseSchema, raw)
59
- const issue = results.find((i) => i.sequence_id === seq)
60
- if (!issue) return yield* Effect.fail(new Error(`Issue #${seq} not found`))
61
- return issue
62
- })
68
+ return Effect.gen(function* () {
69
+ const raw = yield* api.get(`projects/${projectId}/issues/`);
70
+ const { results } = yield* decodeOrFail(IssuesResponseSchema, raw);
71
+ const issue = results.find((i) => i.sequence_id === seq);
72
+ if (!issue) return yield* Effect.fail(new Error(`Issue #${seq} not found`));
73
+ return issue;
74
+ });
63
75
  }
64
76
 
65
77
  export function getStateId(projectId: string, nameOrGroup: string) {
66
- return Effect.gen(function* () {
67
- const raw = yield* api.get(`projects/${projectId}/states/`)
68
- const { results } = yield* decodeOrFail(StatesResponseSchema, raw)
69
- const lower = nameOrGroup.toLowerCase()
70
- const state = results.find(
71
- (s) => s.group === lower || s.name.toLowerCase() === lower,
72
- )
73
- if (!state) return yield* Effect.fail(new Error(`State not found: ${nameOrGroup}`))
74
- return state.id
75
- })
78
+ return Effect.gen(function* () {
79
+ const raw = yield* api.get(`projects/${projectId}/states/`);
80
+ const { results } = yield* decodeOrFail(StatesResponseSchema, raw);
81
+ const lower = nameOrGroup.toLowerCase();
82
+ const state = results.find(
83
+ (s) => s.group === lower || s.name.toLowerCase() === lower,
84
+ );
85
+ if (!state)
86
+ return yield* Effect.fail(new Error(`State not found: ${nameOrGroup}`));
87
+ return state.id;
88
+ });
76
89
  }
package/tests/api.test.ts CHANGED
@@ -1,169 +1,192 @@
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 { api, decodeOrFail } from "@/api"
6
- import { Schema } from "effect"
7
-
8
- const BASE = "http://api-test.local"
9
- const WS = "testws"
10
-
11
- const server = setupServer()
12
-
13
- beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
14
- afterAll(() => server.close())
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ } from "bun:test";
10
+ import { Effect } from "effect";
11
+ import { http, HttpResponse } from "msw";
12
+ import { setupServer } from "msw/node";
13
+ import { api, decodeOrFail } from "@/api";
14
+ import { Schema } from "effect";
15
+
16
+ const BASE = "http://api-test.local";
17
+ const WS = "testws";
18
+
19
+ const server = setupServer();
20
+
21
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
22
+ afterAll(() => server.close());
15
23
 
16
24
  beforeEach(() => {
17
- process.env["PLANE_HOST"] = BASE
18
- process.env["PLANE_WORKSPACE"] = WS
19
- process.env["PLANE_API_TOKEN"] = "test-token"
20
- })
25
+ process.env["PLANE_HOST"] = BASE;
26
+ process.env["PLANE_WORKSPACE"] = WS;
27
+ process.env["PLANE_API_TOKEN"] = "test-token";
28
+ });
21
29
 
22
30
  afterEach(() => {
23
- server.resetHandlers()
24
- delete process.env["PLANE_HOST"]
25
- delete process.env["PLANE_WORKSPACE"]
26
- delete process.env["PLANE_API_TOKEN"]
27
- })
31
+ server.resetHandlers();
32
+ delete process.env["PLANE_HOST"];
33
+ delete process.env["PLANE_WORKSPACE"];
34
+ delete process.env["PLANE_API_TOKEN"];
35
+ });
28
36
 
29
37
  describe("api.get", () => {
30
- it("makes a GET request and returns parsed JSON", async () => {
31
- server.use(
32
- http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
33
- HttpResponse.json({ results: [{ id: "p1", identifier: "ACME", name: "InstUI" }] }),
34
- ),
35
- )
36
- const result = await Effect.runPromise(api.get("projects/"))
37
- expect((result as any).results).toHaveLength(1)
38
- })
39
-
40
- it("strips trailing slash from PLANE_HOST", async () => {
41
- process.env["PLANE_HOST"] = `${BASE}/`
42
- server.use(
43
- http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
44
- HttpResponse.json({ results: [] }),
45
- ),
46
- )
47
- const result = await Effect.runPromise(api.get("projects/"))
48
- expect((result as any).results).toHaveLength(0)
49
- })
50
-
51
- it("appends expand=state for issues/ paths", async () => {
52
- let capturedUrl = ""
53
- server.use(
54
- http.get(`${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/`, ({ request }) => {
55
- capturedUrl = request.url
56
- return HttpResponse.json({ results: [] })
57
- }),
58
- )
59
- await Effect.runPromise(api.get("projects/p1/issues/"))
60
- expect(capturedUrl).toContain("expand=state")
61
- })
62
-
63
- it("fails on HTTP 4xx response", async () => {
64
- server.use(
65
- http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
66
- HttpResponse.json({ detail: "Not found" }, { status: 404 }),
67
- ),
68
- )
69
- const result = await Effect.runPromise(Effect.either(api.get("projects/")))
70
- expect(result._tag).toBe("Left")
71
- if (result._tag === "Left") {
72
- expect(result.left.message).toContain("HTTP 404")
73
- }
74
- })
75
-
76
- it("fails on HTTP 401 response", async () => {
77
- server.use(
78
- http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
79
- HttpResponse.text("Unauthorized", { status: 401 }),
80
- ),
81
- )
82
- const result = await Effect.runPromise(Effect.either(api.get("projects/")))
83
- expect(result._tag).toBe("Left")
84
- })
85
- })
38
+ it("makes a GET request and returns parsed JSON", async () => {
39
+ server.use(
40
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
41
+ HttpResponse.json({
42
+ results: [{ id: "p1", identifier: "ACME", name: "InstUI" }],
43
+ }),
44
+ ),
45
+ );
46
+ const result = await Effect.runPromise(api.get("projects/"));
47
+ expect((result as any).results).toHaveLength(1);
48
+ });
49
+
50
+ it("strips trailing slash from PLANE_HOST", async () => {
51
+ process.env["PLANE_HOST"] = `${BASE}/`;
52
+ server.use(
53
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
54
+ HttpResponse.json({ results: [] }),
55
+ ),
56
+ );
57
+ const result = await Effect.runPromise(api.get("projects/"));
58
+ expect((result as any).results).toHaveLength(0);
59
+ });
60
+
61
+ it("appends expand=state for issues/ paths", async () => {
62
+ let capturedUrl = "";
63
+ server.use(
64
+ http.get(
65
+ `${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/`,
66
+ ({ request }) => {
67
+ capturedUrl = request.url;
68
+ return HttpResponse.json({ results: [] });
69
+ },
70
+ ),
71
+ );
72
+ await Effect.runPromise(api.get("projects/p1/issues/"));
73
+ expect(capturedUrl).toContain("expand=state");
74
+ });
75
+
76
+ it("fails on HTTP 4xx response", async () => {
77
+ server.use(
78
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
79
+ HttpResponse.json({ detail: "Not found" }, { status: 404 }),
80
+ ),
81
+ );
82
+ const result = await Effect.runPromise(Effect.either(api.get("projects/")));
83
+ expect(result._tag).toBe("Left");
84
+ if (result._tag === "Left") {
85
+ expect(result.left.message).toContain("HTTP 404");
86
+ }
87
+ });
88
+
89
+ it("fails on HTTP 401 response", async () => {
90
+ server.use(
91
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
92
+ HttpResponse.text("Unauthorized", { status: 401 }),
93
+ ),
94
+ );
95
+ const result = await Effect.runPromise(Effect.either(api.get("projects/")));
96
+ expect(result._tag).toBe("Left");
97
+ });
98
+ });
86
99
 
87
100
  describe("api.post", () => {
88
- it("sends JSON body and returns parsed response", async () => {
89
- server.use(
90
- http.post(`${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/`, async ({ request }) => {
91
- const body = (await request.json()) as any
92
- return HttpResponse.json({
93
- id: "new-issue",
94
- sequence_id: 99,
95
- name: body.name,
96
- priority: "none",
97
- state: "s1",
98
- })
99
- }),
100
- )
101
- const result = (await Effect.runPromise(
102
- api.post("projects/p1/issues/", { name: "New Issue" }),
103
- )) as any
104
- expect(result.sequence_id).toBe(99)
105
- expect(result.name).toBe("New Issue")
106
- })
107
- })
101
+ it("sends JSON body and returns parsed response", async () => {
102
+ server.use(
103
+ http.post(
104
+ `${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/`,
105
+ async ({ request }) => {
106
+ const body = (await request.json()) as any;
107
+ return HttpResponse.json({
108
+ id: "new-issue",
109
+ sequence_id: 99,
110
+ name: body.name,
111
+ priority: "none",
112
+ state: "s1",
113
+ });
114
+ },
115
+ ),
116
+ );
117
+ const result = (await Effect.runPromise(
118
+ api.post("projects/p1/issues/", { name: "New Issue" }),
119
+ )) as any;
120
+ expect(result.sequence_id).toBe(99);
121
+ expect(result.name).toBe("New Issue");
122
+ });
123
+ });
108
124
 
109
125
  describe("api.patch", () => {
110
- it("sends a PATCH and returns updated resource", async () => {
111
- server.use(
112
- http.patch(
113
- `${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/i1/`,
114
- async ({ request }) => {
115
- const body = (await request.json()) as any
116
- return HttpResponse.json({
117
- id: "i1",
118
- sequence_id: 1,
119
- name: "Issue",
120
- priority: body.priority ?? "low",
121
- state: "s1",
122
- })
123
- },
124
- ),
125
- )
126
- const result = (await Effect.runPromise(
127
- api.patch("projects/p1/issues/i1/", { priority: "high" }),
128
- )) as any
129
- expect(result.priority).toBe("high")
130
- })
131
- })
126
+ it("sends a PATCH and returns updated resource", async () => {
127
+ server.use(
128
+ http.patch(
129
+ `${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/i1/`,
130
+ async ({ request }) => {
131
+ const body = (await request.json()) as any;
132
+ return HttpResponse.json({
133
+ id: "i1",
134
+ sequence_id: 1,
135
+ name: "Issue",
136
+ priority: body.priority ?? "low",
137
+ state: "s1",
138
+ });
139
+ },
140
+ ),
141
+ );
142
+ const result = (await Effect.runPromise(
143
+ api.patch("projects/p1/issues/i1/", { priority: "high" }),
144
+ )) as any;
145
+ expect(result.priority).toBe("high");
146
+ });
147
+ });
132
148
 
133
149
  describe("api.delete", () => {
134
- it("sends a DELETE request", async () => {
135
- let called = false
136
- server.use(
137
- http.delete(`${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/i1/`, () => {
138
- called = true
139
- return new HttpResponse(null, { status: 204 })
140
- }),
141
- )
142
- await Effect.runPromise(api.delete("projects/p1/issues/i1/"))
143
- expect(called).toBe(true)
144
- })
145
- })
150
+ it("sends a DELETE request", async () => {
151
+ let called = false;
152
+ server.use(
153
+ http.delete(
154
+ `${BASE}/api/v1/workspaces/${WS}/projects/p1/issues/i1/`,
155
+ () => {
156
+ called = true;
157
+ return new HttpResponse(null, { status: 204 });
158
+ },
159
+ ),
160
+ );
161
+ await Effect.runPromise(api.delete("projects/p1/issues/i1/"));
162
+ expect(called).toBe(true);
163
+ });
164
+ });
146
165
 
147
166
  describe("decodeOrFail", () => {
148
- const NameSchema = Schema.Struct({ name: Schema.String })
149
-
150
- it("decodes valid data", async () => {
151
- const result = await Effect.runPromise(decodeOrFail(NameSchema, { name: "hello" }))
152
- expect(result.name).toBe("hello")
153
- })
154
-
155
- it("fails with Error for invalid data", async () => {
156
- const result = await Effect.runPromise(
157
- Effect.either(decodeOrFail(NameSchema, { name: 42 })),
158
- )
159
- expect(result._tag).toBe("Left")
160
- if (result._tag === "Left") {
161
- expect(result.left).toBeInstanceOf(Error)
162
- }
163
- })
164
-
165
- it("fails for missing required field", async () => {
166
- const result = await Effect.runPromise(Effect.either(decodeOrFail(NameSchema, {})))
167
- expect(result._tag).toBe("Left")
168
- })
169
- })
167
+ const NameSchema = Schema.Struct({ name: Schema.String });
168
+
169
+ it("decodes valid data", async () => {
170
+ const result = await Effect.runPromise(
171
+ decodeOrFail(NameSchema, { name: "hello" }),
172
+ );
173
+ expect(result.name).toBe("hello");
174
+ });
175
+
176
+ it("fails with Error for invalid data", async () => {
177
+ const result = await Effect.runPromise(
178
+ Effect.either(decodeOrFail(NameSchema, { name: 42 })),
179
+ );
180
+ expect(result._tag).toBe("Left");
181
+ if (result._tag === "Left") {
182
+ expect(result.left).toBeInstanceOf(Error);
183
+ }
184
+ });
185
+
186
+ it("fails for missing required field", async () => {
187
+ const result = await Effect.runPromise(
188
+ Effect.either(decodeOrFail(NameSchema, {})),
189
+ );
190
+ expect(result._tag).toBe("Left");
191
+ });
192
+ });