@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,183 @@
|
|
|
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://cycles-ext-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 CYCLES = [
|
|
13
|
+
{ id: "cyc1", name: "Sprint 1", status: "started", start_date: "2025-01-01", end_date: "2025-01-14" },
|
|
14
|
+
{ id: "cyc2", name: "Sprint 2", status: "backlog" },
|
|
15
|
+
]
|
|
16
|
+
const CYCLE_ISSUES = [
|
|
17
|
+
{ id: "ci1", issue: "i1", issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" } },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const server = setupServer(
|
|
21
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
|
|
22
|
+
HttpResponse.json({ results: PROJECTS }),
|
|
23
|
+
),
|
|
24
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
|
|
25
|
+
HttpResponse.json({ results: ISSUES }),
|
|
26
|
+
),
|
|
27
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, () =>
|
|
28
|
+
HttpResponse.json({ results: CYCLES }),
|
|
29
|
+
),
|
|
30
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
|
|
31
|
+
HttpResponse.json({ results: CYCLE_ISSUES }),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
|
|
36
|
+
afterAll(() => server.close())
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
_clearProjectCache()
|
|
40
|
+
process.env["PLANE_HOST"] = BASE
|
|
41
|
+
process.env["PLANE_WORKSPACE"] = WS
|
|
42
|
+
process.env["PLANE_API_TOKEN"] = "test-token"
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
server.resetHandlers()
|
|
47
|
+
delete process.env["PLANE_HOST"]
|
|
48
|
+
delete process.env["PLANE_WORKSPACE"]
|
|
49
|
+
delete process.env["PLANE_API_TOKEN"]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("cyclesList", () => {
|
|
53
|
+
it("lists cycles with status and dates", async () => {
|
|
54
|
+
const { cyclesList } = await import("@/commands/cycles")
|
|
55
|
+
const logs: string[] = []
|
|
56
|
+
const orig = console.log
|
|
57
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
58
|
+
try {
|
|
59
|
+
await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
|
|
60
|
+
} finally {
|
|
61
|
+
console.log = orig
|
|
62
|
+
}
|
|
63
|
+
const output = logs.join("\n")
|
|
64
|
+
expect(output).toContain("cyc1")
|
|
65
|
+
expect(output).toContain("Sprint 1")
|
|
66
|
+
expect(output).toContain("started")
|
|
67
|
+
expect(output).toContain("2025-01-01")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("shows em-dash for missing dates", async () => {
|
|
71
|
+
const { cyclesList } = await import("@/commands/cycles")
|
|
72
|
+
const logs: string[] = []
|
|
73
|
+
const orig = console.log
|
|
74
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
75
|
+
try {
|
|
76
|
+
await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
|
|
77
|
+
} finally {
|
|
78
|
+
console.log = orig
|
|
79
|
+
}
|
|
80
|
+
expect(logs.join("\n")).toContain("—")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("shows 'No cycles found' when empty", async () => {
|
|
84
|
+
server.use(
|
|
85
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, () =>
|
|
86
|
+
HttpResponse.json({ results: [] }),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
const { cyclesList } = await import("@/commands/cycles")
|
|
90
|
+
const logs: string[] = []
|
|
91
|
+
const orig = console.log
|
|
92
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
93
|
+
try {
|
|
94
|
+
await Effect.runPromise((cyclesList as any).handler({ project: "ACME" }))
|
|
95
|
+
} finally {
|
|
96
|
+
console.log = orig
|
|
97
|
+
}
|
|
98
|
+
expect(logs.join("\n")).toBe("No cycles found")
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe("cycleIssuesList", () => {
|
|
103
|
+
it("lists issues in a cycle", async () => {
|
|
104
|
+
const { cycleIssuesList } = await import("@/commands/cycles")
|
|
105
|
+
const logs: string[] = []
|
|
106
|
+
const orig = console.log
|
|
107
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
108
|
+
try {
|
|
109
|
+
await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
|
|
110
|
+
} finally {
|
|
111
|
+
console.log = orig
|
|
112
|
+
}
|
|
113
|
+
const output = logs.join("\n")
|
|
114
|
+
expect(output).toContain("ACME-")
|
|
115
|
+
expect(output).toContain("29")
|
|
116
|
+
expect(output).toContain("Migrate Button")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("falls back to issue UUID without detail", async () => {
|
|
120
|
+
server.use(
|
|
121
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
|
|
122
|
+
HttpResponse.json({ results: [{ id: "ci2", issue: "bare-uuid" }] }),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
const { cycleIssuesList } = await import("@/commands/cycles")
|
|
126
|
+
const logs: string[] = []
|
|
127
|
+
const orig = console.log
|
|
128
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
129
|
+
try {
|
|
130
|
+
await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
|
|
131
|
+
} finally {
|
|
132
|
+
console.log = orig
|
|
133
|
+
}
|
|
134
|
+
expect(logs.join("\n")).toContain("bare-uuid")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("shows 'No issues in cycle' when empty", async () => {
|
|
138
|
+
server.use(
|
|
139
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, () =>
|
|
140
|
+
HttpResponse.json({ results: [] }),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
const { cycleIssuesList } = await import("@/commands/cycles")
|
|
144
|
+
const logs: string[] = []
|
|
145
|
+
const orig = console.log
|
|
146
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
147
|
+
try {
|
|
148
|
+
await Effect.runPromise((cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }))
|
|
149
|
+
} finally {
|
|
150
|
+
console.log = orig
|
|
151
|
+
}
|
|
152
|
+
expect(logs.join("\n")).toBe("No issues in cycle")
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("cycleIssuesAdd", () => {
|
|
157
|
+
it("adds an issue to a cycle", async () => {
|
|
158
|
+
let postedBody: unknown
|
|
159
|
+
server.use(
|
|
160
|
+
http.post(
|
|
161
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`,
|
|
162
|
+
async ({ request }) => {
|
|
163
|
+
postedBody = await request.json()
|
|
164
|
+
return HttpResponse.json({ issues: ["i1"] }, { status: 201 })
|
|
165
|
+
},
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
const { cycleIssuesAdd } = await import("@/commands/cycles")
|
|
169
|
+
const logs: string[] = []
|
|
170
|
+
const orig = console.log
|
|
171
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
172
|
+
try {
|
|
173
|
+
await Effect.runPromise(
|
|
174
|
+
(cycleIssuesAdd as any).handler({ project: "ACME", cycleId: "cyc1", ref: "ACME-29" }),
|
|
175
|
+
)
|
|
176
|
+
} finally {
|
|
177
|
+
console.log = orig
|
|
178
|
+
}
|
|
179
|
+
expect((postedBody as any).issues).toContain("i1")
|
|
180
|
+
expect(logs.join("\n")).toContain("ACME-29")
|
|
181
|
+
expect(logs.join("\n")).toContain("cyc1")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { formatIssue } from "@/format"
|
|
3
|
+
import type { Issue } from "@/config"
|
|
4
|
+
|
|
5
|
+
const stateObj = { id: "s1", name: "In Progress", group: "started" }
|
|
6
|
+
|
|
7
|
+
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
|
8
|
+
return {
|
|
9
|
+
id: "abc",
|
|
10
|
+
sequence_id: 7,
|
|
11
|
+
name: "Do the thing",
|
|
12
|
+
priority: "medium",
|
|
13
|
+
state: stateObj,
|
|
14
|
+
...overrides,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("formatIssue", () => {
|
|
19
|
+
it("formats a basic issue with object state", () => {
|
|
20
|
+
const result = formatIssue(makeIssue(), "ACME")
|
|
21
|
+
expect(result).toContain("ACME- 7")
|
|
22
|
+
expect(result).toContain("[started ]")
|
|
23
|
+
expect(result).toContain("In Progress")
|
|
24
|
+
expect(result).toContain("Do the thing")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("shows ? for state name and group when state is a string", () => {
|
|
28
|
+
const result = formatIssue(makeIssue({ state: "some-uuid" }), "WEB")
|
|
29
|
+
expect(result).toContain("[?")
|
|
30
|
+
expect(result).toContain("?")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("pads sequence_id to 3 characters", () => {
|
|
34
|
+
const result = formatIssue(makeIssue({ sequence_id: 1 }), "OPS")
|
|
35
|
+
expect(result).toContain("OPS- 1")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("uses the provided project key", () => {
|
|
39
|
+
const result = formatIssue(makeIssue(), "XYZ")
|
|
40
|
+
expect(result).toStartWith("XYZ-")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("includes issue name", () => {
|
|
44
|
+
const result = formatIssue(makeIssue({ name: "Fix everything" }), "ACME")
|
|
45
|
+
expect(result).toContain("Fix everything")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("pads state group to 10 characters", () => {
|
|
49
|
+
const shortState = { id: "s2", name: "Todo", group: "todo" }
|
|
50
|
+
const result = formatIssue(makeIssue({ state: shortState }), "ACME")
|
|
51
|
+
// group "todo" padded to 10: "todo "
|
|
52
|
+
expect(result).toContain("[todo ]")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("pads state name to 12 characters", () => {
|
|
56
|
+
const result = formatIssue(makeIssue({ state: stateObj }), "ACME")
|
|
57
|
+
// "In Progress " padded to 12
|
|
58
|
+
expect(result).toContain("In Progress ")
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { http, HttpResponse } from "msw"
|
|
2
|
+
import { setupServer } from "msw/node"
|
|
3
|
+
|
|
4
|
+
export const BASE = "http://localhost:3737"
|
|
5
|
+
export const WORKSPACE = "testws"
|
|
6
|
+
|
|
7
|
+
export function makeServer(...handlers: Parameters<typeof setupServer>[0][]) {
|
|
8
|
+
return setupServer(...handlers)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function issuesHandler(projectId: string, issues: unknown[]) {
|
|
12
|
+
return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/${projectId}/issues/`, () =>
|
|
13
|
+
HttpResponse.json({ results: issues, next_page_results: false }),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function statesHandler(projectId: string, states: unknown[]) {
|
|
18
|
+
return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/${projectId}/states/`, () =>
|
|
19
|
+
HttpResponse.json({ results: states }),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function projectsHandler(projects: unknown[]) {
|
|
24
|
+
return http.get(`${BASE}/api/v1/workspaces/${WORKSPACE}/projects/`, () =>
|
|
25
|
+
HttpResponse.json({ results: projects }),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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://intake-test.local"
|
|
8
|
+
const WS = "testws"
|
|
9
|
+
|
|
10
|
+
const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme Project" }]
|
|
11
|
+
const INTAKE_ISSUES = [
|
|
12
|
+
{
|
|
13
|
+
id: "int1",
|
|
14
|
+
issue_detail: { id: "i1", sequence_id: 5, name: "Bug report", priority: "high" },
|
|
15
|
+
status: 0,
|
|
16
|
+
created_at: "2025-01-15T10:00:00Z",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "int2",
|
|
20
|
+
issue_detail: { id: "i2", sequence_id: 6, name: "Feature request", priority: "low" },
|
|
21
|
+
status: 1,
|
|
22
|
+
created_at: "2025-01-14T10:00:00Z",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "int3",
|
|
26
|
+
created_at: "2025-01-13T10:00:00Z",
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const server = setupServer(
|
|
31
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
|
|
32
|
+
HttpResponse.json({ results: PROJECTS }),
|
|
33
|
+
),
|
|
34
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () =>
|
|
35
|
+
HttpResponse.json({ results: INTAKE_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("intakeList", () => {
|
|
57
|
+
it("lists intake issues with status labels", async () => {
|
|
58
|
+
const { intakeList } = await import("@/commands/intake")
|
|
59
|
+
const logs: string[] = []
|
|
60
|
+
const orig = console.log
|
|
61
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
62
|
+
try {
|
|
63
|
+
await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
|
|
64
|
+
} finally {
|
|
65
|
+
console.log = orig
|
|
66
|
+
}
|
|
67
|
+
const output = logs.join("\n")
|
|
68
|
+
expect(output).toContain("int1")
|
|
69
|
+
expect(output).toContain("pending")
|
|
70
|
+
expect(output).toContain("Bug report")
|
|
71
|
+
expect(output).toContain("int2")
|
|
72
|
+
expect(output).toContain("accepted")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("shows 'No intake issues' when empty", async () => {
|
|
76
|
+
server.use(
|
|
77
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () =>
|
|
78
|
+
HttpResponse.json({ results: [] }),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
const { intakeList } = await import("@/commands/intake")
|
|
82
|
+
const logs: string[] = []
|
|
83
|
+
const orig = console.log
|
|
84
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
85
|
+
try {
|
|
86
|
+
await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
|
|
87
|
+
} finally {
|
|
88
|
+
console.log = orig
|
|
89
|
+
}
|
|
90
|
+
expect(logs.join("\n")).toBe("No intake issues")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("handles intake issue without issue_detail", async () => {
|
|
94
|
+
const { intakeList } = await import("@/commands/intake")
|
|
95
|
+
const logs: string[] = []
|
|
96
|
+
const orig = console.log
|
|
97
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
98
|
+
try {
|
|
99
|
+
await Effect.runPromise((intakeList as any).handler({ project: "ACME" }))
|
|
100
|
+
} finally {
|
|
101
|
+
console.log = orig
|
|
102
|
+
}
|
|
103
|
+
expect(logs.join("\n")).toContain("int3")
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("intakeAccept", () => {
|
|
108
|
+
it("accepts an intake issue", async () => {
|
|
109
|
+
let patchedBody: unknown
|
|
110
|
+
server.use(
|
|
111
|
+
http.patch(
|
|
112
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
|
|
113
|
+
async ({ request }) => {
|
|
114
|
+
patchedBody = await request.json()
|
|
115
|
+
return HttpResponse.json({ id: "int1", status: 1, created_at: "2025-01-15T10:00:00Z" })
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
const { intakeAccept } = await import("@/commands/intake")
|
|
120
|
+
const logs: string[] = []
|
|
121
|
+
const orig = console.log
|
|
122
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
123
|
+
try {
|
|
124
|
+
await Effect.runPromise((intakeAccept as any).handler({ project: "ACME", intakeId: "int1" }))
|
|
125
|
+
} finally {
|
|
126
|
+
console.log = orig
|
|
127
|
+
}
|
|
128
|
+
expect((patchedBody as any).status).toBe(1)
|
|
129
|
+
expect(logs.join("\n")).toContain("accepted")
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("intakeReject", () => {
|
|
134
|
+
it("rejects an intake issue", async () => {
|
|
135
|
+
let patchedBody: unknown
|
|
136
|
+
server.use(
|
|
137
|
+
http.patch(
|
|
138
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
|
|
139
|
+
async ({ request }) => {
|
|
140
|
+
patchedBody = await request.json()
|
|
141
|
+
return HttpResponse.json({ id: "int1", status: -2, created_at: "2025-01-15T10:00:00Z" })
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
const { intakeReject } = await import("@/commands/intake")
|
|
146
|
+
const logs: string[] = []
|
|
147
|
+
const orig = console.log
|
|
148
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
149
|
+
try {
|
|
150
|
+
await Effect.runPromise((intakeReject as any).handler({ project: "ACME", intakeId: "int1" }))
|
|
151
|
+
} finally {
|
|
152
|
+
console.log = orig
|
|
153
|
+
}
|
|
154
|
+
expect((patchedBody as any).status).toBe(-2)
|
|
155
|
+
expect(logs.join("\n")).toContain("rejected")
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } 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://act-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 ACTIVITIES = [
|
|
13
|
+
{
|
|
14
|
+
id: "act1",
|
|
15
|
+
actor_detail: { display_name: "Aaron" },
|
|
16
|
+
field: "state",
|
|
17
|
+
old_value: "Backlog",
|
|
18
|
+
new_value: "In Progress",
|
|
19
|
+
verb: "updated",
|
|
20
|
+
created_at: "2025-01-15T10:30:00Z",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "act2",
|
|
24
|
+
actor_detail: { display_name: "Bea" },
|
|
25
|
+
field: null,
|
|
26
|
+
old_value: null,
|
|
27
|
+
new_value: null,
|
|
28
|
+
verb: "created",
|
|
29
|
+
created_at: "2025-01-14T08:00:00Z",
|
|
30
|
+
},
|
|
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/issues/i1/activities/`, () =>
|
|
41
|
+
HttpResponse.json({ results: ACTIVITIES }),
|
|
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("issueActivity command handler", () => {
|
|
63
|
+
it("fetches and formats activity with field changes", async () => {
|
|
64
|
+
const { issueActivity } = await import("@/commands/issue")
|
|
65
|
+
const logs: string[] = []
|
|
66
|
+
const originalLog = console.log
|
|
67
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await Effect.runPromise(
|
|
71
|
+
(issueActivity as any).handler({ ref: "ACME-29" }),
|
|
72
|
+
)
|
|
73
|
+
} finally {
|
|
74
|
+
console.log = originalLog
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const output = logs.join("\n")
|
|
78
|
+
expect(output).toContain("Aaron")
|
|
79
|
+
expect(output).toContain("state")
|
|
80
|
+
expect(output).toContain("Backlog")
|
|
81
|
+
expect(output).toContain("In Progress")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("shows 'No activity found' when empty", async () => {
|
|
85
|
+
server.use(
|
|
86
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
|
|
87
|
+
HttpResponse.json({ results: [] }),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const { issueActivity } = await import("@/commands/issue")
|
|
92
|
+
const logs: string[] = []
|
|
93
|
+
const originalLog = console.log
|
|
94
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await Effect.runPromise(
|
|
98
|
+
(issueActivity as any).handler({ ref: "ACME-29" }),
|
|
99
|
+
)
|
|
100
|
+
} finally {
|
|
101
|
+
console.log = originalLog
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expect(logs.join("\n")).toContain("No activity found")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("formats activity without field (verb-only)", async () => {
|
|
108
|
+
server.use(
|
|
109
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
|
|
110
|
+
HttpResponse.json({
|
|
111
|
+
results: [
|
|
112
|
+
{
|
|
113
|
+
id: "act3",
|
|
114
|
+
actor_detail: { display_name: "Bea" },
|
|
115
|
+
verb: "created",
|
|
116
|
+
created_at: "2025-01-14T08:00:00Z",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const { issueActivity } = await import("@/commands/issue")
|
|
124
|
+
const logs: string[] = []
|
|
125
|
+
const originalLog = console.log
|
|
126
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await Effect.runPromise(
|
|
130
|
+
(issueActivity as any).handler({ ref: "ACME-29" }),
|
|
131
|
+
)
|
|
132
|
+
} finally {
|
|
133
|
+
console.log = originalLog
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const output = logs.join("\n")
|
|
137
|
+
expect(output).toContain("Bea")
|
|
138
|
+
expect(output).toContain("created")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("handles missing actor_detail gracefully", async () => {
|
|
142
|
+
server.use(
|
|
143
|
+
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/activities/`, () =>
|
|
144
|
+
HttpResponse.json({
|
|
145
|
+
results: [{ id: "act4", field: "priority", old_value: "low", new_value: "high", created_at: "2025-01-15T10:30:00Z" }],
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const { issueActivity } = await import("@/commands/issue")
|
|
151
|
+
const logs: string[] = []
|
|
152
|
+
const originalLog = console.log
|
|
153
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "))
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await Effect.runPromise(
|
|
157
|
+
(issueActivity as any).handler({ ref: "ACME-29" }),
|
|
158
|
+
)
|
|
159
|
+
} finally {
|
|
160
|
+
console.log = originalLog
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const output = logs.join("\n")
|
|
164
|
+
expect(output).toContain("priority")
|
|
165
|
+
expect(output).toContain("?") // fallback for missing actor
|
|
166
|
+
})
|
|
167
|
+
})
|