@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,63 @@
|
|
|
1
|
+
import { Command, Args } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { api, decodeOrFail } from "../api.js"
|
|
4
|
+
import { PagesResponseSchema, PageSchema } from "../config.js"
|
|
5
|
+
import { resolveProject } from "../resolve.js"
|
|
6
|
+
|
|
7
|
+
const projectArg = Args.text({ name: "project" }).pipe(
|
|
8
|
+
Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// --- pages list ---
|
|
12
|
+
|
|
13
|
+
export const pagesList = Command.make("list", { project: projectArg }, ({ project }) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const { id } = yield* resolveProject(project)
|
|
16
|
+
const raw = yield* api.get(`projects/${id}/pages/`)
|
|
17
|
+
const { results } = yield* decodeOrFail(PagesResponseSchema, raw)
|
|
18
|
+
if (results.length === 0) {
|
|
19
|
+
yield* Console.log("No pages")
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const lines = results.map((p) => {
|
|
23
|
+
const updated = (p.updated_at ?? p.created_at).slice(0, 10)
|
|
24
|
+
return `${p.id} ${updated} ${p.name}`
|
|
25
|
+
})
|
|
26
|
+
yield* Console.log(lines.join("\n"))
|
|
27
|
+
}),
|
|
28
|
+
).pipe(
|
|
29
|
+
Command.withDescription(
|
|
30
|
+
"List pages for a project. Shows page UUID, last updated date, and title.\n\nExample:\n plane pages list PROJ",
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// --- pages get ---
|
|
35
|
+
|
|
36
|
+
const pageIdArg = Args.text({ name: "page-id" }).pipe(
|
|
37
|
+
Args.withDescription("Page UUID (from 'plane pages list')"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
export const pagesGet = Command.make(
|
|
41
|
+
"get",
|
|
42
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
43
|
+
({ project, pageId }) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const { id } = yield* resolveProject(project)
|
|
46
|
+
const raw = yield* api.get(`projects/${id}/pages/${pageId}/`)
|
|
47
|
+
const page = yield* decodeOrFail(PageSchema, raw)
|
|
48
|
+
yield* Console.log(JSON.stringify(page, null, 2))
|
|
49
|
+
}),
|
|
50
|
+
).pipe(
|
|
51
|
+
Command.withDescription(
|
|
52
|
+
"Print full JSON for a page including description_html.\n\nExample:\n plane pages get PROJ <page-id>",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// --- pages (parent) ---
|
|
57
|
+
|
|
58
|
+
export const pages = Command.make("pages").pipe(
|
|
59
|
+
Command.withDescription(
|
|
60
|
+
"Manage project pages (documentation). Subcommands: list, get\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ <page-id>",
|
|
61
|
+
),
|
|
62
|
+
Command.withSubcommands([pagesList, pagesGet]),
|
|
63
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { api, decodeOrFail } from "../api.js"
|
|
4
|
+
import { ProjectsResponseSchema } from "../config.js"
|
|
5
|
+
|
|
6
|
+
export const projectsList = Command.make("list", {}, () =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const raw = yield* api.get("projects/")
|
|
9
|
+
const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw)
|
|
10
|
+
const lines = results.map(
|
|
11
|
+
(p) => `${p.identifier.padEnd(6)} ${p.id} ${p.name}`,
|
|
12
|
+
)
|
|
13
|
+
yield* Console.log(lines.join("\n"))
|
|
14
|
+
}),
|
|
15
|
+
).pipe(
|
|
16
|
+
Command.withDescription(
|
|
17
|
+
"List all projects in the workspace. The IDENTIFIER column is what you pass to other commands (e.g. 'plane issues list PROJ').",
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export const projects = Command.make("projects").pipe(
|
|
22
|
+
Command.withDescription("Manage projects."),
|
|
23
|
+
Command.withSubcommands([projectsList]),
|
|
24
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Command, Options, Args } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { api, decodeOrFail } from "../api.js"
|
|
4
|
+
import { StatesResponseSchema } from "../config.js"
|
|
5
|
+
import { resolveProject } from "../resolve.js"
|
|
6
|
+
|
|
7
|
+
const projectArg = Args.text({ name: "project" }).pipe(
|
|
8
|
+
Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const statesList = Command.make(
|
|
12
|
+
"list",
|
|
13
|
+
{ project: projectArg },
|
|
14
|
+
({ project }) =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const { id } = yield* resolveProject(project)
|
|
17
|
+
const raw = yield* api.get(`projects/${id}/states/`)
|
|
18
|
+
const { results } = yield* decodeOrFail(StatesResponseSchema, raw)
|
|
19
|
+
const lines = results.map(
|
|
20
|
+
(s) => `${s.id} ${s.group.padEnd(12)} ${s.name}`,
|
|
21
|
+
)
|
|
22
|
+
yield* Console.log(lines.join("\n"))
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export const states = Command.make("states").pipe(
|
|
27
|
+
Command.withSubcommands([statesList]),
|
|
28
|
+
)
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
// --- Schemas ---
|
|
4
|
+
|
|
5
|
+
export const StateSchema = Schema.Struct({
|
|
6
|
+
id: Schema.String,
|
|
7
|
+
name: Schema.String,
|
|
8
|
+
group: Schema.String,
|
|
9
|
+
color: Schema.optional(Schema.String),
|
|
10
|
+
})
|
|
11
|
+
export type State = typeof StateSchema.Type
|
|
12
|
+
|
|
13
|
+
export const IssueSchema = Schema.Struct({
|
|
14
|
+
id: Schema.String,
|
|
15
|
+
sequence_id: Schema.Number,
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
priority: Schema.String,
|
|
18
|
+
state: Schema.Union(Schema.String, StateSchema),
|
|
19
|
+
description_html: Schema.optional(Schema.NullOr(Schema.String)),
|
|
20
|
+
})
|
|
21
|
+
export type Issue = typeof IssueSchema.Type
|
|
22
|
+
|
|
23
|
+
export const StatesResponseSchema = Schema.Struct({
|
|
24
|
+
results: Schema.Array(StateSchema),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const IssuesResponseSchema = Schema.Struct({
|
|
28
|
+
results: Schema.Array(IssueSchema),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const LabelSchema = Schema.Struct({
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
name: Schema.String,
|
|
34
|
+
color: Schema.optional(Schema.NullOr(Schema.String)),
|
|
35
|
+
parent: Schema.optional(Schema.NullOr(Schema.String)),
|
|
36
|
+
})
|
|
37
|
+
export type Label = typeof LabelSchema.Type
|
|
38
|
+
|
|
39
|
+
export const LabelsResponseSchema = Schema.Struct({
|
|
40
|
+
results: Schema.Array(LabelSchema),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Members endpoint returns a flat array (no results wrapper)
|
|
44
|
+
export const MemberSchema = Schema.Struct({
|
|
45
|
+
id: Schema.String,
|
|
46
|
+
display_name: Schema.String,
|
|
47
|
+
email: Schema.optional(Schema.NullOr(Schema.String)),
|
|
48
|
+
})
|
|
49
|
+
export type Member = typeof MemberSchema.Type
|
|
50
|
+
|
|
51
|
+
export const MembersResponseSchema = Schema.Array(MemberSchema)
|
|
52
|
+
|
|
53
|
+
export const CycleSchema = Schema.Struct({
|
|
54
|
+
id: Schema.String,
|
|
55
|
+
name: Schema.String,
|
|
56
|
+
status: Schema.optional(Schema.String),
|
|
57
|
+
start_date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
58
|
+
end_date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
59
|
+
})
|
|
60
|
+
export type Cycle = typeof CycleSchema.Type
|
|
61
|
+
|
|
62
|
+
export const CyclesResponseSchema = Schema.Struct({
|
|
63
|
+
results: Schema.Array(CycleSchema),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const ProjectSchema = Schema.Struct({
|
|
67
|
+
id: Schema.String,
|
|
68
|
+
identifier: Schema.String,
|
|
69
|
+
name: Schema.String,
|
|
70
|
+
description: Schema.optional(Schema.NullOr(Schema.String)),
|
|
71
|
+
})
|
|
72
|
+
export type Project = typeof ProjectSchema.Type
|
|
73
|
+
|
|
74
|
+
export const ProjectsResponseSchema = Schema.Struct({
|
|
75
|
+
results: Schema.Array(ProjectSchema),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const ProjectsFlatResponseSchema = Schema.Array(ProjectSchema)
|
|
79
|
+
|
|
80
|
+
export const ActivitySchema = Schema.Struct({
|
|
81
|
+
id: Schema.String,
|
|
82
|
+
actor_detail: Schema.optional(
|
|
83
|
+
Schema.Struct({
|
|
84
|
+
display_name: Schema.String,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
field: Schema.optional(Schema.NullOr(Schema.String)),
|
|
88
|
+
old_value: Schema.optional(Schema.NullOr(Schema.String)),
|
|
89
|
+
new_value: Schema.optional(Schema.NullOr(Schema.String)),
|
|
90
|
+
verb: Schema.optional(Schema.String),
|
|
91
|
+
created_at: Schema.String,
|
|
92
|
+
})
|
|
93
|
+
export type Activity = typeof ActivitySchema.Type
|
|
94
|
+
|
|
95
|
+
export const ActivitiesResponseSchema = Schema.Struct({
|
|
96
|
+
results: Schema.Array(ActivitySchema),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const IssueLinkSchema = Schema.Struct({
|
|
100
|
+
id: Schema.String,
|
|
101
|
+
title: Schema.optional(Schema.NullOr(Schema.String)),
|
|
102
|
+
url: Schema.String,
|
|
103
|
+
created_at: Schema.String,
|
|
104
|
+
})
|
|
105
|
+
export type IssueLink = typeof IssueLinkSchema.Type
|
|
106
|
+
|
|
107
|
+
export const IssueLinksResponseSchema = Schema.Struct({
|
|
108
|
+
results: Schema.Array(IssueLinkSchema),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
export const ModuleSchema = Schema.Struct({
|
|
112
|
+
id: Schema.String,
|
|
113
|
+
name: Schema.String,
|
|
114
|
+
status: Schema.optional(Schema.String),
|
|
115
|
+
description: Schema.optional(Schema.NullOr(Schema.String)),
|
|
116
|
+
})
|
|
117
|
+
export type Module = typeof ModuleSchema.Type
|
|
118
|
+
|
|
119
|
+
export const ModulesResponseSchema = Schema.Struct({
|
|
120
|
+
results: Schema.Array(ModuleSchema),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
export const ModuleIssueSchema = Schema.Struct({
|
|
124
|
+
id: Schema.String,
|
|
125
|
+
issue: Schema.String,
|
|
126
|
+
issue_detail: Schema.optional(
|
|
127
|
+
Schema.Struct({
|
|
128
|
+
id: Schema.String,
|
|
129
|
+
sequence_id: Schema.Number,
|
|
130
|
+
name: Schema.String,
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
})
|
|
134
|
+
export type ModuleIssue = typeof ModuleIssueSchema.Type
|
|
135
|
+
|
|
136
|
+
export const ModuleIssuesResponseSchema = Schema.Struct({
|
|
137
|
+
results: Schema.Array(ModuleIssueSchema),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
export const WorklogSchema = Schema.Struct({
|
|
141
|
+
id: Schema.String,
|
|
142
|
+
description: Schema.optional(Schema.NullOr(Schema.String)),
|
|
143
|
+
duration: Schema.Number,
|
|
144
|
+
logged_by_detail: Schema.optional(
|
|
145
|
+
Schema.Struct({ display_name: Schema.String }),
|
|
146
|
+
),
|
|
147
|
+
created_at: Schema.String,
|
|
148
|
+
})
|
|
149
|
+
export type Worklog = typeof WorklogSchema.Type
|
|
150
|
+
|
|
151
|
+
export const WorklogsResponseSchema = Schema.Struct({
|
|
152
|
+
results: Schema.Array(WorklogSchema),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export const IntakeIssueSchema = Schema.Struct({
|
|
156
|
+
id: Schema.String,
|
|
157
|
+
issue: Schema.optional(Schema.String),
|
|
158
|
+
issue_detail: Schema.optional(
|
|
159
|
+
Schema.Struct({
|
|
160
|
+
id: Schema.String,
|
|
161
|
+
sequence_id: Schema.Number,
|
|
162
|
+
name: Schema.String,
|
|
163
|
+
priority: Schema.String,
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
status: Schema.optional(Schema.Number),
|
|
167
|
+
created_at: Schema.String,
|
|
168
|
+
})
|
|
169
|
+
export type IntakeIssue = typeof IntakeIssueSchema.Type
|
|
170
|
+
|
|
171
|
+
export const IntakeIssuesResponseSchema = Schema.Struct({
|
|
172
|
+
results: Schema.Array(IntakeIssueSchema),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const PageSchema = Schema.Struct({
|
|
176
|
+
id: Schema.String,
|
|
177
|
+
name: Schema.String,
|
|
178
|
+
description_html: Schema.optional(Schema.NullOr(Schema.String)),
|
|
179
|
+
created_at: Schema.String,
|
|
180
|
+
updated_at: Schema.optional(Schema.String),
|
|
181
|
+
})
|
|
182
|
+
export type Page = typeof PageSchema.Type
|
|
183
|
+
|
|
184
|
+
export const PagesResponseSchema = Schema.Struct({
|
|
185
|
+
results: Schema.Array(PageSchema),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export const CommentSchema = Schema.Struct({
|
|
189
|
+
id: Schema.String,
|
|
190
|
+
comment_html: Schema.optional(Schema.String),
|
|
191
|
+
actor_detail: Schema.optional(
|
|
192
|
+
Schema.Struct({ display_name: Schema.String }),
|
|
193
|
+
),
|
|
194
|
+
created_at: Schema.String,
|
|
195
|
+
})
|
|
196
|
+
export type Comment = typeof CommentSchema.Type
|
|
197
|
+
|
|
198
|
+
export const CommentsResponseSchema = Schema.Struct({
|
|
199
|
+
results: Schema.Array(CommentSchema),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
export const CycleIssueSchema = Schema.Struct({
|
|
203
|
+
id: Schema.String,
|
|
204
|
+
issue: Schema.String,
|
|
205
|
+
issue_detail: Schema.optional(
|
|
206
|
+
Schema.Struct({
|
|
207
|
+
id: Schema.String,
|
|
208
|
+
sequence_id: Schema.Number,
|
|
209
|
+
name: Schema.String,
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
})
|
|
213
|
+
export type CycleIssue = typeof CycleIssueSchema.Type
|
|
214
|
+
|
|
215
|
+
export const CycleIssuesResponseSchema = Schema.Struct({
|
|
216
|
+
results: Schema.Array(CycleIssueSchema),
|
|
217
|
+
})
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Issue, State } from "./config.js"
|
|
2
|
+
|
|
3
|
+
export function formatIssue(issue: Issue, projKey: string): string {
|
|
4
|
+
const state = issue.state as State | string
|
|
5
|
+
const stateName = typeof state === "object" ? state.name : "?"
|
|
6
|
+
const stateGroup = typeof state === "object" ? state.group : "?"
|
|
7
|
+
const seqPad = String(issue.sequence_id).padStart(3, " ")
|
|
8
|
+
const groupPad = stateGroup.padEnd(10, " ")
|
|
9
|
+
const namePad = stateName.padEnd(12, " ")
|
|
10
|
+
return `${projKey}-${seqPad} [${groupPad}] ${namePad} ${issue.name}`
|
|
11
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { api, decodeOrFail } from "./api.js"
|
|
3
|
+
import { IssuesResponseSchema, StatesResponseSchema, ProjectsResponseSchema } from "./config.js"
|
|
4
|
+
|
|
5
|
+
// Cache project list within a process invocation
|
|
6
|
+
let _projectCache: Record<string, string> | null = null
|
|
7
|
+
|
|
8
|
+
/** Clear the project cache — for use in tests only */
|
|
9
|
+
export function _clearProjectCache() {
|
|
10
|
+
_projectCache = null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
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
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveProject(
|
|
24
|
+
identifier: string,
|
|
25
|
+
): 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
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseIssueRef(
|
|
41
|
+
ref: string,
|
|
42
|
+
): 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
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
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
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
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
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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())
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
process.env["PLANE_HOST"] = BASE
|
|
18
|
+
process.env["PLANE_WORKSPACE"] = WS
|
|
19
|
+
process.env["PLANE_API_TOKEN"] = "test-token"
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
server.resetHandlers()
|
|
24
|
+
delete process.env["PLANE_HOST"]
|
|
25
|
+
delete process.env["PLANE_WORKSPACE"]
|
|
26
|
+
delete process.env["PLANE_API_TOKEN"]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
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
|
+
})
|
|
86
|
+
|
|
87
|
+
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
|
+
})
|
|
108
|
+
|
|
109
|
+
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
|
+
})
|
|
132
|
+
|
|
133
|
+
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
|
+
})
|
|
146
|
+
|
|
147
|
+
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
|
+
})
|