@aaronshaf/plane 0.1.10 → 1.0.0
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/package.json +1 -1
- package/src/api.ts +18 -1
- package/src/app.ts +2 -2
- package/src/commands/issue.ts +17 -19
- package/src/commands/pages.ts +253 -8
- package/src/config.ts +1 -1
- package/tests/issue-commands.test.ts +14 -20
- package/tests/pages.test.ts +246 -1
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -38,6 +38,8 @@ function request(
|
|
|
38
38
|
return Effect.tryPromise({
|
|
39
39
|
try: async () => {
|
|
40
40
|
const { token, host, workspace } = getConfig();
|
|
41
|
+
if (!token) throw new Error("No API token configured. Run 'plane init' or set PLANE_API_TOKEN.");
|
|
42
|
+
if (!workspace) throw new Error("No workspace configured. Run 'plane init' or set PLANE_WORKSPACE.");
|
|
41
43
|
let url = `${host}/api/v1/workspaces/${workspace}/${path}`;
|
|
42
44
|
|
|
43
45
|
// Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/)
|
|
@@ -66,7 +68,22 @@ function request(
|
|
|
66
68
|
// 204 No Content
|
|
67
69
|
if (res.status === 204) return null;
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
// Use text + lenient parse to handle bare control characters (U+0000–U+001F)
|
|
72
|
+
// that may appear inside JSON string values (e.g. description_html with \n in <pre>).
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
// Escape bare control characters inside JSON string values and retry.
|
|
78
|
+
const sanitized = text.replace(
|
|
79
|
+
/"(?:[^"\\]|\\.)*"/g,
|
|
80
|
+
(match) => match.replace(/[\x00-\x1F]/g, (c) => {
|
|
81
|
+
const hex = c.charCodeAt(0).toString(16).padStart(4, "0");
|
|
82
|
+
return `\\u${hex}`;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
return JSON.parse(sanitized);
|
|
86
|
+
}
|
|
70
87
|
},
|
|
71
88
|
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
72
89
|
});
|
package/src/app.ts
CHANGED
|
@@ -44,7 +44,7 @@ ALL SUBCOMMANDS
|
|
|
44
44
|
cycles list | issues (list, add)
|
|
45
45
|
modules list | issues (list, add, remove)
|
|
46
46
|
intake list | accept | reject
|
|
47
|
-
pages list | get
|
|
47
|
+
pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate
|
|
48
48
|
states list List workflow states for a project
|
|
49
49
|
labels list List labels for a project
|
|
50
50
|
members list List members of a project
|
|
@@ -74,5 +74,5 @@ FOR AI AGENTS / BOTS
|
|
|
74
74
|
|
|
75
75
|
export const cli = Command.run(plane, {
|
|
76
76
|
name: "plane",
|
|
77
|
-
version: "0.1.
|
|
77
|
+
version: "0.1.11",
|
|
78
78
|
});
|
package/src/commands/issue.ts
CHANGED
|
@@ -79,7 +79,7 @@ const titleUpdateOption = Options.optional(Options.text("title")).pipe(
|
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
const descriptionOption = Options.optional(Options.text("description")).pipe(
|
|
82
|
-
Options.withDescription("Issue description
|
|
82
|
+
Options.withDescription("Issue description as HTML (e.g. '<p>Details</p>')"),
|
|
83
83
|
);
|
|
84
84
|
|
|
85
85
|
const assigneeOption = Options.optional(Options.text("assignee")).pipe(
|
|
@@ -120,26 +120,25 @@ export function issueUpdateHandler({
|
|
|
120
120
|
|
|
121
121
|
const body: IssueUpdatePayload = {};
|
|
122
122
|
|
|
123
|
-
if (state
|
|
123
|
+
if (Option.isSome(state)) {
|
|
124
124
|
body.state = yield* getStateId(projectId, state.value);
|
|
125
125
|
}
|
|
126
|
-
if (priority
|
|
126
|
+
if (Option.isSome(priority)) {
|
|
127
127
|
body.priority = priority.value;
|
|
128
128
|
}
|
|
129
|
-
if (title
|
|
129
|
+
if (Option.isSome(title)) {
|
|
130
130
|
body.name = title.value;
|
|
131
131
|
}
|
|
132
|
-
if (description
|
|
133
|
-
|
|
134
|
-
body.description_html = `<p>${escaped}</p>`;
|
|
132
|
+
if (Option.isSome(description)) {
|
|
133
|
+
body.description_html = description.value;
|
|
135
134
|
}
|
|
136
135
|
if (noAssignee) {
|
|
137
136
|
body.assignees = [];
|
|
138
|
-
} else if (assignee
|
|
137
|
+
} else if (Option.isSome(assignee)) {
|
|
139
138
|
const memberId = yield* getMemberId(assignee.value);
|
|
140
139
|
body.assignees = [memberId];
|
|
141
140
|
}
|
|
142
|
-
if (label
|
|
141
|
+
if (Option.isSome(label)) {
|
|
143
142
|
const labelId = yield* getLabelId(projectId, label.value);
|
|
144
143
|
body.label_ids = [labelId];
|
|
145
144
|
}
|
|
@@ -234,7 +233,7 @@ const createStateOption = Options.optional(Options.text("state")).pipe(
|
|
|
234
233
|
const createDescriptionOption = Options.optional(
|
|
235
234
|
Options.text("description"),
|
|
236
235
|
).pipe(
|
|
237
|
-
Options.withDescription("Issue description
|
|
236
|
+
Options.withDescription("Issue description as HTML (e.g. '<p>Details</p>')"),
|
|
238
237
|
);
|
|
239
238
|
|
|
240
239
|
const createAssigneeOption = Options.optional(Options.text("assignee")).pipe(
|
|
@@ -265,18 +264,17 @@ export function issueCreateHandler({
|
|
|
265
264
|
return Effect.gen(function* () {
|
|
266
265
|
const { key, id: projectId } = yield* resolveProject(project);
|
|
267
266
|
const body: IssueCreatePayload = { name: title };
|
|
268
|
-
if (priority
|
|
269
|
-
if (state
|
|
267
|
+
if (Option.isSome(priority)) body.priority = priority.value;
|
|
268
|
+
if (Option.isSome(state))
|
|
270
269
|
body.state = yield* getStateId(projectId, state.value);
|
|
271
|
-
if (description
|
|
272
|
-
|
|
273
|
-
body.description_html = `<p>${escaped}</p>`;
|
|
270
|
+
if (Option.isSome(description)) {
|
|
271
|
+
body.description_html = description.value;
|
|
274
272
|
}
|
|
275
|
-
if (assignee
|
|
273
|
+
if (Option.isSome(assignee)) {
|
|
276
274
|
const memberId = yield* getMemberId(assignee.value);
|
|
277
275
|
body.assignees = [memberId];
|
|
278
276
|
}
|
|
279
|
-
if (label
|
|
277
|
+
if (Option.isSome(label)) {
|
|
280
278
|
const labelId = yield* getLabelId(projectId, label.value);
|
|
281
279
|
body.label_ids = [labelId];
|
|
282
280
|
}
|
|
@@ -403,7 +401,7 @@ export function issueLinkAddHandler({
|
|
|
403
401
|
const { projectId, seq } = yield* parseIssueRef(ref);
|
|
404
402
|
const issue = yield* findIssueBySeq(projectId, seq);
|
|
405
403
|
const body: Record<string, string> = { url };
|
|
406
|
-
if (title
|
|
404
|
+
if (Option.isSome(title)) body["title"] = title.value;
|
|
407
405
|
const raw = yield* api.post(
|
|
408
406
|
`projects/${projectId}/issues/${issue.id}/issue-links/`,
|
|
409
407
|
body,
|
|
@@ -627,7 +625,7 @@ export function issueWorklogsAddHandler({
|
|
|
627
625
|
const { projectId, seq } = yield* parseIssueRef(ref);
|
|
628
626
|
const issue = yield* findIssueBySeq(projectId, seq);
|
|
629
627
|
const body: WorklogPayload = { duration };
|
|
630
|
-
if (description
|
|
628
|
+
if (Option.isSome(description)) body.description = description.value;
|
|
631
629
|
const raw = yield* api.post(
|
|
632
630
|
`projects/${projectId}/issues/${issue.id}/worklogs/`,
|
|
633
631
|
body,
|
package/src/commands/pages.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command, Args } from "@effect/cli";
|
|
2
|
-
import { Console, Effect } from "effect";
|
|
1
|
+
import { Command, Args, Options } from "@effect/cli";
|
|
2
|
+
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { api, decodeOrFail } from "../api.js";
|
|
4
4
|
import { PagesResponseSchema, PageSchema } from "../config.js";
|
|
5
5
|
import { resolveProject } from "../resolve.js";
|
|
@@ -9,6 +9,25 @@ const projectArg = Args.text({ name: "project" }).pipe(
|
|
|
9
9
|
Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
|
|
10
10
|
);
|
|
11
11
|
|
|
12
|
+
const pageIdArg = Args.text({ name: "page-id" }).pipe(
|
|
13
|
+
Args.withDescription("Page UUID (from 'plane pages list')"),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const nameOption = Options.text("name").pipe(
|
|
17
|
+
Options.withDescription("Page name/title"),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const nameOptionalOption = Options.optional(Options.text("name")).pipe(
|
|
21
|
+
Options.withDescription("New page name/title"),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const descriptionOption = Options.optional(Options.text("description")).pipe(
|
|
25
|
+
Options.withDescription("Page description as HTML (e.g. '<p>Hello</p>')"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
interface PageCreatePayload { name: string; description_html?: string; }
|
|
29
|
+
interface PageUpdatePayload { name?: string; description_html?: string; }
|
|
30
|
+
|
|
12
31
|
// --- pages list ---
|
|
13
32
|
|
|
14
33
|
export function pagesListHandler({ project }: { project: string }) {
|
|
@@ -48,10 +67,6 @@ export const pagesList = Command.make(
|
|
|
48
67
|
|
|
49
68
|
// --- pages get ---
|
|
50
69
|
|
|
51
|
-
const pageIdArg = Args.text({ name: "page-id" }).pipe(
|
|
52
|
-
Args.withDescription("Page UUID (from 'plane pages list')"),
|
|
53
|
-
);
|
|
54
|
-
|
|
55
70
|
export function pagesGetHandler({
|
|
56
71
|
project,
|
|
57
72
|
pageId,
|
|
@@ -77,11 +92,241 @@ export const pagesGet = Command.make(
|
|
|
77
92
|
),
|
|
78
93
|
);
|
|
79
94
|
|
|
95
|
+
// --- pages create ---
|
|
96
|
+
|
|
97
|
+
export function pagesCreateHandler({
|
|
98
|
+
project,
|
|
99
|
+
name,
|
|
100
|
+
description,
|
|
101
|
+
}: {
|
|
102
|
+
project: string;
|
|
103
|
+
name: string;
|
|
104
|
+
description: Option.Option<string>;
|
|
105
|
+
}) {
|
|
106
|
+
return Effect.gen(function* () {
|
|
107
|
+
const { id } = yield* resolveProject(project);
|
|
108
|
+
const body: PageCreatePayload = { name };
|
|
109
|
+
if (Option.isSome(description)) {
|
|
110
|
+
body.description_html = description.value;
|
|
111
|
+
}
|
|
112
|
+
const raw = yield* api.post(`projects/${id}/pages/`, body);
|
|
113
|
+
const page = yield* decodeOrFail(PageSchema, raw);
|
|
114
|
+
yield* Console.log(`Created page ${page.id}: ${page.name}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const pagesCreate = Command.make(
|
|
119
|
+
"create",
|
|
120
|
+
{ project: projectArg, name: nameOption, description: descriptionOption },
|
|
121
|
+
pagesCreateHandler,
|
|
122
|
+
).pipe(
|
|
123
|
+
Command.withDescription(
|
|
124
|
+
"Create a new page.\n\nExample:\n plane pages create --name \"My Page\" PROJ",
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// --- pages update ---
|
|
129
|
+
|
|
130
|
+
export function pagesUpdateHandler({
|
|
131
|
+
project,
|
|
132
|
+
pageId,
|
|
133
|
+
name,
|
|
134
|
+
description,
|
|
135
|
+
}: {
|
|
136
|
+
project: string;
|
|
137
|
+
pageId: string;
|
|
138
|
+
name: Option.Option<string>;
|
|
139
|
+
description: Option.Option<string>;
|
|
140
|
+
}) {
|
|
141
|
+
return Effect.gen(function* () {
|
|
142
|
+
if (Option.isNone(name) && Option.isNone(description)) {
|
|
143
|
+
yield* Effect.fail(new Error("provide at least --name or --description"));
|
|
144
|
+
}
|
|
145
|
+
const { id } = yield* resolveProject(project);
|
|
146
|
+
const body: PageUpdatePayload = {};
|
|
147
|
+
if (Option.isSome(name)) body.name = name.value;
|
|
148
|
+
if (Option.isSome(description)) body.description_html = description.value;
|
|
149
|
+
const raw = yield* api.patch(`projects/${id}/pages/${pageId}/`, body);
|
|
150
|
+
const page = yield* decodeOrFail(PageSchema, raw);
|
|
151
|
+
yield* Console.log(`Updated page ${page.id}: ${page.name}`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const pagesUpdate = Command.make(
|
|
156
|
+
"update",
|
|
157
|
+
{ project: projectArg, pageId: pageIdArg, name: nameOptionalOption, description: descriptionOption },
|
|
158
|
+
pagesUpdateHandler,
|
|
159
|
+
).pipe(
|
|
160
|
+
Command.withDescription(
|
|
161
|
+
"Update a page's name or description.\n\nExample:\n plane pages update --name \"New Title\" PROJ <page-id>",
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// --- pages delete ---
|
|
166
|
+
|
|
167
|
+
export function pagesDeleteHandler({
|
|
168
|
+
project,
|
|
169
|
+
pageId,
|
|
170
|
+
}: {
|
|
171
|
+
project: string;
|
|
172
|
+
pageId: string;
|
|
173
|
+
}) {
|
|
174
|
+
return Effect.gen(function* () {
|
|
175
|
+
const { id } = yield* resolveProject(project);
|
|
176
|
+
yield* api.delete(`projects/${id}/pages/${pageId}/`);
|
|
177
|
+
yield* Console.log(`Deleted page ${pageId}`);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const pagesDelete = Command.make(
|
|
182
|
+
"delete",
|
|
183
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
184
|
+
pagesDeleteHandler,
|
|
185
|
+
).pipe(
|
|
186
|
+
Command.withDescription(
|
|
187
|
+
"Delete a page.\n\nExample:\n plane pages delete PROJ <page-id>",
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// --- pages archive ---
|
|
192
|
+
|
|
193
|
+
export function pagesArchiveHandler({
|
|
194
|
+
project,
|
|
195
|
+
pageId,
|
|
196
|
+
}: {
|
|
197
|
+
project: string;
|
|
198
|
+
pageId: string;
|
|
199
|
+
}) {
|
|
200
|
+
return Effect.gen(function* () {
|
|
201
|
+
const { id } = yield* resolveProject(project);
|
|
202
|
+
yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {});
|
|
203
|
+
yield* Console.log(`Archived page ${pageId}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const pagesArchive = Command.make(
|
|
208
|
+
"archive",
|
|
209
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
210
|
+
pagesArchiveHandler,
|
|
211
|
+
).pipe(
|
|
212
|
+
Command.withDescription(
|
|
213
|
+
"Archive a page.\n\nExample:\n plane pages archive PROJ <page-id>",
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// --- pages unarchive ---
|
|
218
|
+
|
|
219
|
+
export function pagesUnarchiveHandler({
|
|
220
|
+
project,
|
|
221
|
+
pageId,
|
|
222
|
+
}: {
|
|
223
|
+
project: string;
|
|
224
|
+
pageId: string;
|
|
225
|
+
}) {
|
|
226
|
+
return Effect.gen(function* () {
|
|
227
|
+
const { id } = yield* resolveProject(project);
|
|
228
|
+
yield* api.delete(`projects/${id}/pages/${pageId}/archive/`);
|
|
229
|
+
yield* Console.log(`Unarchived page ${pageId}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const pagesUnarchive = Command.make(
|
|
234
|
+
"unarchive",
|
|
235
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
236
|
+
pagesUnarchiveHandler,
|
|
237
|
+
).pipe(
|
|
238
|
+
Command.withDescription(
|
|
239
|
+
"Unarchive a page.\n\nExample:\n plane pages unarchive PROJ <page-id>",
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// --- pages lock ---
|
|
244
|
+
|
|
245
|
+
export function pagesLockHandler({
|
|
246
|
+
project,
|
|
247
|
+
pageId,
|
|
248
|
+
}: {
|
|
249
|
+
project: string;
|
|
250
|
+
pageId: string;
|
|
251
|
+
}) {
|
|
252
|
+
return Effect.gen(function* () {
|
|
253
|
+
const { id } = yield* resolveProject(project);
|
|
254
|
+
yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {});
|
|
255
|
+
yield* Console.log(`Locked page ${pageId}`);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const pagesLock = Command.make(
|
|
260
|
+
"lock",
|
|
261
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
262
|
+
pagesLockHandler,
|
|
263
|
+
).pipe(
|
|
264
|
+
Command.withDescription(
|
|
265
|
+
"Lock a page (prevent edits).\n\nExample:\n plane pages lock PROJ <page-id>",
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// --- pages unlock ---
|
|
270
|
+
|
|
271
|
+
export function pagesUnlockHandler({
|
|
272
|
+
project,
|
|
273
|
+
pageId,
|
|
274
|
+
}: {
|
|
275
|
+
project: string;
|
|
276
|
+
pageId: string;
|
|
277
|
+
}) {
|
|
278
|
+
return Effect.gen(function* () {
|
|
279
|
+
const { id } = yield* resolveProject(project);
|
|
280
|
+
yield* api.delete(`projects/${id}/pages/${pageId}/lock/`);
|
|
281
|
+
yield* Console.log(`Unlocked page ${pageId}`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const pagesUnlock = Command.make(
|
|
286
|
+
"unlock",
|
|
287
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
288
|
+
pagesUnlockHandler,
|
|
289
|
+
).pipe(
|
|
290
|
+
Command.withDescription(
|
|
291
|
+
"Unlock a page.\n\nExample:\n plane pages unlock PROJ <page-id>",
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// --- pages duplicate ---
|
|
296
|
+
|
|
297
|
+
export function pagesDuplicateHandler({
|
|
298
|
+
project,
|
|
299
|
+
pageId,
|
|
300
|
+
}: {
|
|
301
|
+
project: string;
|
|
302
|
+
pageId: string;
|
|
303
|
+
}) {
|
|
304
|
+
return Effect.gen(function* () {
|
|
305
|
+
const { id } = yield* resolveProject(project);
|
|
306
|
+
const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {});
|
|
307
|
+
const page = yield* decodeOrFail(PageSchema, raw);
|
|
308
|
+
yield* Console.log(`Duplicated page ${page.id}: ${page.name}`);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export const pagesDuplicate = Command.make(
|
|
313
|
+
"duplicate",
|
|
314
|
+
{ project: projectArg, pageId: pageIdArg },
|
|
315
|
+
pagesDuplicateHandler,
|
|
316
|
+
).pipe(
|
|
317
|
+
Command.withDescription(
|
|
318
|
+
"Duplicate a page.\n\nExample:\n plane pages duplicate PROJ <page-id>",
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
|
|
80
322
|
// --- pages (parent) ---
|
|
81
323
|
|
|
82
324
|
export const pages = Command.make("pages").pipe(
|
|
83
325
|
Command.withDescription(
|
|
84
|
-
"Manage project pages (documentation). Subcommands: list, get\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ <page-id>",
|
|
326
|
+
"Manage project pages (documentation). Subcommands: list, get, create, update, delete, archive, unarchive, lock, unlock, duplicate\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ <page-id>",
|
|
85
327
|
),
|
|
86
|
-
Command.withSubcommands([
|
|
328
|
+
Command.withSubcommands([
|
|
329
|
+
pagesList, pagesGet, pagesCreate, pagesUpdate, pagesDelete,
|
|
330
|
+
pagesArchive, pagesUnarchive, pagesLock, pagesUnlock, pagesDuplicate,
|
|
331
|
+
]),
|
|
87
332
|
);
|
package/src/config.ts
CHANGED
|
@@ -179,7 +179,7 @@ export const PageSchema = Schema.Struct({
|
|
|
179
179
|
name: Schema.String,
|
|
180
180
|
description_html: Schema.optional(Schema.NullOr(Schema.String)),
|
|
181
181
|
created_at: Schema.String,
|
|
182
|
-
updated_at: Schema.optional(Schema.String),
|
|
182
|
+
updated_at: Schema.optional(Schema.NullOr(Schema.String)),
|
|
183
183
|
});
|
|
184
184
|
export type Page = typeof PageSchema.Type;
|
|
185
185
|
|
|
@@ -595,11 +595,11 @@ describe("issueCreate description", () => {
|
|
|
595
595
|
);
|
|
596
596
|
|
|
597
597
|
expect((postedBody as { description_html?: string }).description_html).toBe(
|
|
598
|
-
"
|
|
598
|
+
"Some context here",
|
|
599
599
|
);
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
-
it("
|
|
602
|
+
it("passes raw HTML description as-is", async () => {
|
|
603
603
|
let postedBody: unknown;
|
|
604
604
|
server.use(
|
|
605
605
|
http.post(
|
|
@@ -621,10 +621,10 @@ describe("issueCreate description", () => {
|
|
|
621
621
|
await Effect.runPromise(
|
|
622
622
|
issueCreateHandler({
|
|
623
623
|
project: "ACME",
|
|
624
|
-
title: "
|
|
624
|
+
title: "HTML test",
|
|
625
625
|
priority: Option.none(),
|
|
626
626
|
state: Option.none(),
|
|
627
|
-
description: Option.some("<
|
|
627
|
+
description: Option.some("<p>Raw <b>HTML</b></p>"),
|
|
628
628
|
assignee: Option.none(),
|
|
629
629
|
label: Option.none(),
|
|
630
630
|
}),
|
|
@@ -632,10 +632,7 @@ describe("issueCreate description", () => {
|
|
|
632
632
|
|
|
633
633
|
expect(
|
|
634
634
|
(postedBody as { description_html?: string }).description_html,
|
|
635
|
-
).
|
|
636
|
-
expect(
|
|
637
|
-
(postedBody as { description_html?: string }).description_html,
|
|
638
|
-
).not.toContain("<script>");
|
|
635
|
+
).toBe("<p>Raw <b>HTML</b></p>");
|
|
639
636
|
});
|
|
640
637
|
});
|
|
641
638
|
|
|
@@ -674,10 +671,10 @@ describe("issueUpdate description", () => {
|
|
|
674
671
|
|
|
675
672
|
expect(
|
|
676
673
|
(patchedBody as { description_html?: string }).description_html,
|
|
677
|
-
).toBe("
|
|
674
|
+
).toBe("Updated description");
|
|
678
675
|
});
|
|
679
676
|
|
|
680
|
-
it("HTML-
|
|
677
|
+
it("passes raw HTML as-is in update description", async () => {
|
|
681
678
|
let patchedBody: unknown;
|
|
682
679
|
server.use(
|
|
683
680
|
http.patch(
|
|
@@ -711,10 +708,7 @@ describe("issueUpdate description", () => {
|
|
|
711
708
|
|
|
712
709
|
expect(
|
|
713
710
|
(patchedBody as { description_html?: string }).description_html,
|
|
714
|
-
).
|
|
715
|
-
expect(
|
|
716
|
-
(patchedBody as { description_html?: string }).description_html,
|
|
717
|
-
).not.toContain("<b>");
|
|
711
|
+
).toBe("<b>bold</b>");
|
|
718
712
|
});
|
|
719
713
|
});
|
|
720
714
|
|
|
@@ -1030,11 +1024,11 @@ describe("--description argv parsing", () => {
|
|
|
1030
1024
|
]);
|
|
1031
1025
|
expect(logs.join("\n")).toContain("Created");
|
|
1032
1026
|
expect((postedBody as { description_html?: string }).description_html).toBe(
|
|
1033
|
-
"
|
|
1027
|
+
"Hello world",
|
|
1034
1028
|
);
|
|
1035
1029
|
});
|
|
1036
1030
|
|
|
1037
|
-
it("issue create
|
|
1031
|
+
it("issue create passes raw HTML description via argv", async () => {
|
|
1038
1032
|
let postedBody: unknown;
|
|
1039
1033
|
server.use(
|
|
1040
1034
|
http.post(
|
|
@@ -1056,12 +1050,12 @@ describe("--description argv parsing", () => {
|
|
|
1056
1050
|
"issue",
|
|
1057
1051
|
"create",
|
|
1058
1052
|
"--description",
|
|
1059
|
-
"
|
|
1053
|
+
"<p>Raw HTML</p>",
|
|
1060
1054
|
"ACME",
|
|
1061
|
-
"
|
|
1055
|
+
"HTML test",
|
|
1062
1056
|
]);
|
|
1063
1057
|
expect((postedBody as { description_html?: string }).description_html).toBe(
|
|
1064
|
-
"<p>
|
|
1058
|
+
"<p>Raw HTML</p>",
|
|
1065
1059
|
);
|
|
1066
1060
|
});
|
|
1067
1061
|
|
|
@@ -1086,6 +1080,6 @@ describe("--description argv parsing", () => {
|
|
|
1086
1080
|
await runCli(["issue", "update", "--description", "New desc", "ACME-29"]);
|
|
1087
1081
|
expect(
|
|
1088
1082
|
(patchedBody as { description_html?: string }).description_html,
|
|
1089
|
-
).toBe("
|
|
1083
|
+
).toBe("New desc");
|
|
1090
1084
|
});
|
|
1091
1085
|
});
|
package/tests/pages.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
expect,
|
|
8
8
|
it,
|
|
9
9
|
} from "bun:test";
|
|
10
|
-
import { Effect } from "effect";
|
|
10
|
+
import { Effect, Option } from "effect";
|
|
11
11
|
import { http, HttpResponse } from "msw";
|
|
12
12
|
import { setupServer } from "msw/node";
|
|
13
13
|
import { _clearProjectCache } from "@/resolve";
|
|
@@ -34,6 +34,14 @@ const PAGES = [
|
|
|
34
34
|
},
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
+
const NEW_PAGE = {
|
|
38
|
+
id: "pg-new",
|
|
39
|
+
name: "New Page",
|
|
40
|
+
description_html: null,
|
|
41
|
+
created_at: "2025-02-01T10:00:00Z",
|
|
42
|
+
updated_at: "2025-02-01T10:00:00Z",
|
|
43
|
+
};
|
|
44
|
+
|
|
37
45
|
const server = setupServer(
|
|
38
46
|
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
|
|
39
47
|
HttpResponse.json({ results: PROJECTS }),
|
|
@@ -136,3 +144,240 @@ describe("pagesGet", () => {
|
|
|
136
144
|
expect(parsed.description_html).toContain("architecture");
|
|
137
145
|
});
|
|
138
146
|
});
|
|
147
|
+
|
|
148
|
+
describe("pagesCreate", () => {
|
|
149
|
+
it("creates a page and logs confirmation", async () => {
|
|
150
|
+
let postedBody: unknown;
|
|
151
|
+
server.use(
|
|
152
|
+
http.post(
|
|
153
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`,
|
|
154
|
+
async ({ request }) => {
|
|
155
|
+
postedBody = await request.json();
|
|
156
|
+
return HttpResponse.json(NEW_PAGE);
|
|
157
|
+
},
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
const { pagesCreateHandler } = await import("@/commands/pages");
|
|
161
|
+
const logs: string[] = [];
|
|
162
|
+
const orig = console.log;
|
|
163
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
164
|
+
try {
|
|
165
|
+
await Effect.runPromise(
|
|
166
|
+
pagesCreateHandler({
|
|
167
|
+
project: "ACME",
|
|
168
|
+
name: "New Page",
|
|
169
|
+
description: Option.none(),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
console.log = orig;
|
|
174
|
+
}
|
|
175
|
+
expect((postedBody as { name?: string }).name).toBe("New Page");
|
|
176
|
+
expect(logs.join("\n")).toContain("Created page pg-new");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("sends description_html when provided", async () => {
|
|
180
|
+
let postedBody: unknown;
|
|
181
|
+
server.use(
|
|
182
|
+
http.post(
|
|
183
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`,
|
|
184
|
+
async ({ request }) => {
|
|
185
|
+
postedBody = await request.json();
|
|
186
|
+
return HttpResponse.json(NEW_PAGE);
|
|
187
|
+
},
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
const { pagesCreateHandler } = await import("@/commands/pages");
|
|
191
|
+
await Effect.runPromise(
|
|
192
|
+
pagesCreateHandler({
|
|
193
|
+
project: "ACME",
|
|
194
|
+
name: "New Page",
|
|
195
|
+
description: Option.some("<p>Hello</p>"),
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
expect((postedBody as { description_html?: string }).description_html).toBe(
|
|
199
|
+
"<p>Hello</p>",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("pagesUpdate", () => {
|
|
205
|
+
it("updates a page name", async () => {
|
|
206
|
+
let patchedBody: unknown;
|
|
207
|
+
server.use(
|
|
208
|
+
http.patch(
|
|
209
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/`,
|
|
210
|
+
async ({ request }) => {
|
|
211
|
+
patchedBody = await request.json();
|
|
212
|
+
return HttpResponse.json({ ...PAGES[0], name: "Updated Name" });
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
const { pagesUpdateHandler } = await import("@/commands/pages");
|
|
217
|
+
const logs: string[] = [];
|
|
218
|
+
const orig = console.log;
|
|
219
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
220
|
+
try {
|
|
221
|
+
await Effect.runPromise(
|
|
222
|
+
pagesUpdateHandler({
|
|
223
|
+
project: "ACME",
|
|
224
|
+
pageId: "pg1",
|
|
225
|
+
name: Option.some("Updated Name"),
|
|
226
|
+
description: Option.none(),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
} finally {
|
|
230
|
+
console.log = orig;
|
|
231
|
+
}
|
|
232
|
+
expect((patchedBody as { name?: string }).name).toBe("Updated Name");
|
|
233
|
+
expect(logs.join("\n")).toContain("Updated page");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("fails when no options provided", async () => {
|
|
237
|
+
const { pagesUpdateHandler } = await import("@/commands/pages");
|
|
238
|
+
await expect(
|
|
239
|
+
Effect.runPromise(
|
|
240
|
+
pagesUpdateHandler({
|
|
241
|
+
project: "ACME",
|
|
242
|
+
pageId: "pg1",
|
|
243
|
+
name: Option.none(),
|
|
244
|
+
description: Option.none(),
|
|
245
|
+
}),
|
|
246
|
+
),
|
|
247
|
+
).rejects.toThrow("provide at least --name or --description");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("pagesDelete", () => {
|
|
252
|
+
it("deletes a page and logs confirmation", async () => {
|
|
253
|
+
server.use(
|
|
254
|
+
http.delete(
|
|
255
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/`,
|
|
256
|
+
() => new HttpResponse(null, { status: 204 }),
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
const { pagesDeleteHandler } = await import("@/commands/pages");
|
|
260
|
+
const logs: string[] = [];
|
|
261
|
+
const orig = console.log;
|
|
262
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
263
|
+
try {
|
|
264
|
+
await Effect.runPromise(
|
|
265
|
+
pagesDeleteHandler({ project: "ACME", pageId: "pg1" }),
|
|
266
|
+
);
|
|
267
|
+
} finally {
|
|
268
|
+
console.log = orig;
|
|
269
|
+
}
|
|
270
|
+
expect(logs.join("\n")).toContain("Deleted page pg1");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("pagesArchive / pagesUnarchive", () => {
|
|
275
|
+
it("archives a page", async () => {
|
|
276
|
+
server.use(
|
|
277
|
+
http.post(
|
|
278
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/archive/`,
|
|
279
|
+
() => new HttpResponse(null, { status: 204 }),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
const { pagesArchiveHandler } = await import("@/commands/pages");
|
|
283
|
+
const logs: string[] = [];
|
|
284
|
+
const orig = console.log;
|
|
285
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
286
|
+
try {
|
|
287
|
+
await Effect.runPromise(
|
|
288
|
+
pagesArchiveHandler({ project: "ACME", pageId: "pg1" }),
|
|
289
|
+
);
|
|
290
|
+
} finally {
|
|
291
|
+
console.log = orig;
|
|
292
|
+
}
|
|
293
|
+
expect(logs.join("\n")).toContain("Archived page pg1");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("unarchives a page", async () => {
|
|
297
|
+
server.use(
|
|
298
|
+
http.delete(
|
|
299
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/archive/`,
|
|
300
|
+
() => new HttpResponse(null, { status: 204 }),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
const { pagesUnarchiveHandler } = await import("@/commands/pages");
|
|
304
|
+
const logs: string[] = [];
|
|
305
|
+
const orig = console.log;
|
|
306
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
307
|
+
try {
|
|
308
|
+
await Effect.runPromise(
|
|
309
|
+
pagesUnarchiveHandler({ project: "ACME", pageId: "pg1" }),
|
|
310
|
+
);
|
|
311
|
+
} finally {
|
|
312
|
+
console.log = orig;
|
|
313
|
+
}
|
|
314
|
+
expect(logs.join("\n")).toContain("Unarchived page pg1");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("pagesLock / pagesUnlock", () => {
|
|
319
|
+
it("locks a page", async () => {
|
|
320
|
+
server.use(
|
|
321
|
+
http.post(
|
|
322
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/lock/`,
|
|
323
|
+
() => new HttpResponse(null, { status: 204 }),
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
const { pagesLockHandler } = await import("@/commands/pages");
|
|
327
|
+
const logs: string[] = [];
|
|
328
|
+
const orig = console.log;
|
|
329
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
330
|
+
try {
|
|
331
|
+
await Effect.runPromise(
|
|
332
|
+
pagesLockHandler({ project: "ACME", pageId: "pg1" }),
|
|
333
|
+
);
|
|
334
|
+
} finally {
|
|
335
|
+
console.log = orig;
|
|
336
|
+
}
|
|
337
|
+
expect(logs.join("\n")).toContain("Locked page pg1");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("unlocks a page", async () => {
|
|
341
|
+
server.use(
|
|
342
|
+
http.delete(
|
|
343
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/lock/`,
|
|
344
|
+
() => new HttpResponse(null, { status: 204 }),
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
const { pagesUnlockHandler } = await import("@/commands/pages");
|
|
348
|
+
const logs: string[] = [];
|
|
349
|
+
const orig = console.log;
|
|
350
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
351
|
+
try {
|
|
352
|
+
await Effect.runPromise(
|
|
353
|
+
pagesUnlockHandler({ project: "ACME", pageId: "pg1" }),
|
|
354
|
+
);
|
|
355
|
+
} finally {
|
|
356
|
+
console.log = orig;
|
|
357
|
+
}
|
|
358
|
+
expect(logs.join("\n")).toContain("Unlocked page pg1");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("pagesDuplicate", () => {
|
|
363
|
+
it("duplicates a page and logs confirmation", async () => {
|
|
364
|
+
server.use(
|
|
365
|
+
http.post(
|
|
366
|
+
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/duplicate/`,
|
|
367
|
+
() => HttpResponse.json({ ...NEW_PAGE, id: "pg-dup", name: "New Page (copy)" }),
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
const { pagesDuplicateHandler } = await import("@/commands/pages");
|
|
371
|
+
const logs: string[] = [];
|
|
372
|
+
const orig = console.log;
|
|
373
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
374
|
+
try {
|
|
375
|
+
await Effect.runPromise(
|
|
376
|
+
pagesDuplicateHandler({ project: "ACME", pageId: "pg1" }),
|
|
377
|
+
);
|
|
378
|
+
} finally {
|
|
379
|
+
console.log = orig;
|
|
380
|
+
}
|
|
381
|
+
expect(logs.join("\n")).toContain("Duplicated page pg-dup");
|
|
382
|
+
});
|
|
383
|
+
});
|