@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.1.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/README.md +223 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +329 -0
- package/dist/commands.d.ts +15 -0
- package/dist/commands.js +521 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +548 -0
- package/dist/contracts.d.ts +47 -0
- package/dist/contracts.js +14 -0
- package/dist/development.d.ts +9 -0
- package/dist/development.js +220 -0
- package/dist/documents.d.ts +9 -0
- package/dist/documents.js +256 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +306 -0
- package/dist/issues.d.ts +61 -0
- package/dist/issues.js +709 -0
- package/dist/writes.d.ts +8 -0
- package/dist/writes.js +57 -0
- package/package.json +44 -4
package/dist/issues.js
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { CliError, } from "./contracts.js";
|
|
4
|
+
import { classifyWriteFailure } from "./http.js";
|
|
5
|
+
const states = ["backlog", "unstarted", "started", "completed", "canceled"];
|
|
6
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
const positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
8
|
+
function unsupported(message) {
|
|
9
|
+
throw new CliError("UNSUPPORTED_RESPONSE", message, 2);
|
|
10
|
+
}
|
|
11
|
+
function object(value, name) {
|
|
12
|
+
if (!record(value))
|
|
13
|
+
unsupported(`Expected ${name} object from API`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function objects(value, name) {
|
|
17
|
+
if (!Array.isArray(value) || !value.every(record))
|
|
18
|
+
unsupported(`Expected ${name} array from API`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function issue(value) {
|
|
22
|
+
const item = object(value, "issue");
|
|
23
|
+
if (!positive(item.id) ||
|
|
24
|
+
!positive(item.projectId) ||
|
|
25
|
+
!positive(item.sequenceNumber) ||
|
|
26
|
+
typeof item.identifier !== "string")
|
|
27
|
+
unsupported("Issue response is missing stable identifiers");
|
|
28
|
+
return item;
|
|
29
|
+
}
|
|
30
|
+
function integer(value, name, max = Number.MAX_SAFE_INTEGER) {
|
|
31
|
+
if ((typeof value === "string" && !/^[1-9]\d*$/.test(value)) ||
|
|
32
|
+
!positive(Number(value)) ||
|
|
33
|
+
Number(value) > max)
|
|
34
|
+
throw new CliError("INVALID_ARGUMENT", `${name} must be an integer between 1 and ${max}`);
|
|
35
|
+
return Number(value);
|
|
36
|
+
}
|
|
37
|
+
function projectRef(value) {
|
|
38
|
+
if (!/^(?:[A-Za-z0-9_-]+\.)?[A-Za-z][A-Za-z0-9_-]*$/.test(value))
|
|
39
|
+
throw new CliError("INVALID_PROJECT", "Use a project key or team.KEY project ref");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function keyOf(ref) {
|
|
43
|
+
return ref.slice(ref.lastIndexOf(".") + 1);
|
|
44
|
+
}
|
|
45
|
+
function publicData(value) {
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.map(publicData);
|
|
48
|
+
if (!record(value))
|
|
49
|
+
return value;
|
|
50
|
+
return Object.fromEntries(Object.entries(value)
|
|
51
|
+
.filter(([key]) => !/^(shareToken|shareExtended|token|apiKey|password|secret)$/i.test(key))
|
|
52
|
+
.map(([key, item]) => [key, publicData(item)]));
|
|
53
|
+
}
|
|
54
|
+
async function getProject(ctx, ref) {
|
|
55
|
+
const result = object(await ctx.client.request(`/projects/${encodeURIComponent(projectRef(ref))}`), "project setup");
|
|
56
|
+
const metadata = object(result.project, "project");
|
|
57
|
+
if (!positive(metadata.id) ||
|
|
58
|
+
typeof metadata.ref !== "string" ||
|
|
59
|
+
typeof metadata.key !== "string")
|
|
60
|
+
unsupported("Project response is missing id, key, or ref");
|
|
61
|
+
const setup = {
|
|
62
|
+
project: metadata,
|
|
63
|
+
columns: objects(result.columns, "columns"),
|
|
64
|
+
issueTypes: objects(result.issueTypes, "issueTypes"),
|
|
65
|
+
labels: objects(result.labels, "labels"),
|
|
66
|
+
assignees: objects(result.assignees, "assignees"),
|
|
67
|
+
customFields: objects(result.customFields, "customFields"),
|
|
68
|
+
};
|
|
69
|
+
for (const [name, values] of [
|
|
70
|
+
["columns", setup.columns],
|
|
71
|
+
["issueTypes", setup.issueTypes],
|
|
72
|
+
["labels", setup.labels],
|
|
73
|
+
["customFields", setup.customFields],
|
|
74
|
+
]) {
|
|
75
|
+
if (values.some((item) => !positive(item.id) || typeof item.name !== "string"))
|
|
76
|
+
unsupported(`Invalid ${name} in project response`);
|
|
77
|
+
if (values.some((item) => item.projectId !== undefined && item.projectId !== metadata.id))
|
|
78
|
+
unsupported(`Project response contains foreign ${name}`);
|
|
79
|
+
}
|
|
80
|
+
if (setup.columns.some((item) => typeof item.stateType !== "string") ||
|
|
81
|
+
setup.assignees.some((item) => typeof item.userId !== "string" ||
|
|
82
|
+
(item.username !== null && typeof item.username !== "string") ||
|
|
83
|
+
!["member", "agent"].includes(String(item.kind))))
|
|
84
|
+
unsupported("Invalid columns or assignees in project response");
|
|
85
|
+
return setup;
|
|
86
|
+
}
|
|
87
|
+
async function resolve(ctx, identifier, options) {
|
|
88
|
+
if (identifier !== undefined && options.id !== undefined)
|
|
89
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "An issue identifier and --id are mutually exclusive");
|
|
90
|
+
const selected = options.project ?? ctx.options.project ?? ctx.project;
|
|
91
|
+
const ref = selected === undefined ? undefined : projectRef(selected);
|
|
92
|
+
if (options.id !== undefined) {
|
|
93
|
+
const item = issue(await ctx.client.request(`/issues/${integer(options.id, "--id")}`));
|
|
94
|
+
if (ref !== undefined) {
|
|
95
|
+
const project = await getProject(ctx, ref);
|
|
96
|
+
if (project.project.id !== item.projectId)
|
|
97
|
+
throw new CliError("PROJECT_CONFLICT", "The issue does not belong to the selected project");
|
|
98
|
+
}
|
|
99
|
+
return { item, ref };
|
|
100
|
+
}
|
|
101
|
+
if (identifier === undefined)
|
|
102
|
+
throw new CliError("ISSUE_REQUIRED", "Provide KEY-42, a project-scoped number, or --id");
|
|
103
|
+
let key;
|
|
104
|
+
let sequence;
|
|
105
|
+
if (/^[0-9]+$/.test(identifier)) {
|
|
106
|
+
if (ref === undefined)
|
|
107
|
+
throw new CliError("PROJECT_REQUIRED", "A numeric issue number requires --project or configured project");
|
|
108
|
+
key = keyOf(ref);
|
|
109
|
+
sequence = integer(identifier, "issue number");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*)-([1-9]\d*)$/.exec(identifier);
|
|
113
|
+
if (!match)
|
|
114
|
+
throw new CliError("INVALID_IDENTIFIER", "Use KEY-42, a project-scoped number, or --id; issue URLs are not supported");
|
|
115
|
+
key = match[1];
|
|
116
|
+
sequence = integer(match[2], "issue number");
|
|
117
|
+
if (ref !== undefined && keyOf(ref) !== key)
|
|
118
|
+
throw new CliError("PROJECT_CONFLICT", "The issue key conflicts with the selected project");
|
|
119
|
+
}
|
|
120
|
+
const target = ref ?? key;
|
|
121
|
+
const item = issue(await ctx.client.request(`/projects/${encodeURIComponent(target)}/issues/${sequence}`));
|
|
122
|
+
// Moved identifiers may resolve to a different project. An explicitly selected scope must not silently change.
|
|
123
|
+
if (ref !== undefined && keyOf(ref) !== item.identifier.replace(/-\d+$/, ""))
|
|
124
|
+
throw new CliError("PROJECT_CONFLICT", "The resolved issue belongs to a different project");
|
|
125
|
+
return { item, ref: target };
|
|
126
|
+
}
|
|
127
|
+
async function setupFor(ctx, item, ref) {
|
|
128
|
+
if (ref !== undefined) {
|
|
129
|
+
const setup = await getProject(ctx, ref);
|
|
130
|
+
if (setup.project.id !== item.projectId)
|
|
131
|
+
throw new CliError("PROJECT_CONFLICT", "The issue does not belong to the selected project");
|
|
132
|
+
return setup;
|
|
133
|
+
}
|
|
134
|
+
// Internal IDs have no project key. Discover only after the exact ID lookup, and match the numeric project ID.
|
|
135
|
+
const projects = objects(await ctx.client.request("/projects"), "projects");
|
|
136
|
+
const matches = projects.filter((value) => value.id === item.projectId);
|
|
137
|
+
if (matches.length !== 1 || typeof matches[0].ref !== "string")
|
|
138
|
+
unsupported("Cannot resolve the issue project; provide --project team.KEY");
|
|
139
|
+
return getProject(ctx, matches[0].ref);
|
|
140
|
+
}
|
|
141
|
+
function cursorValue(value) {
|
|
142
|
+
const item = object(value, "feed cursor");
|
|
143
|
+
if (typeof item.ts !== "string" ||
|
|
144
|
+
!item.ts ||
|
|
145
|
+
!positive(item.id) ||
|
|
146
|
+
Object.keys(item).some((key) => key !== "ts" && key !== "id"))
|
|
147
|
+
unsupported("Invalid API feed cursor");
|
|
148
|
+
return { ts: item.ts, id: item.id };
|
|
149
|
+
}
|
|
150
|
+
function encodeCursor(cursor) {
|
|
151
|
+
return cursor === null
|
|
152
|
+
? null
|
|
153
|
+
: Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
|
154
|
+
}
|
|
155
|
+
function decodeCursor(value) {
|
|
156
|
+
if (value === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
try {
|
|
159
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
160
|
+
throw new Error();
|
|
161
|
+
const buffer = Buffer.from(value, "base64url");
|
|
162
|
+
if (buffer.toString("base64url") !== value)
|
|
163
|
+
throw new Error();
|
|
164
|
+
return JSON.stringify(cursorValue(JSON.parse(buffer.toString("utf8"))));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new CliError("INVALID_CURSOR", "Use the opaque nextCursor returned by this CLI");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function feed(ctx, id, limit, cursor, comments) {
|
|
171
|
+
const data = object(await ctx.client.request(`/issues/${id}/feed`, {
|
|
172
|
+
query: { limit, cursor, filter: comments ? "comments" : undefined },
|
|
173
|
+
}), "feed");
|
|
174
|
+
const items = objects(data.items, "feed items");
|
|
175
|
+
if (items.some((item) => !positive(item.id) ||
|
|
176
|
+
item.issueId !== id ||
|
|
177
|
+
typeof item.kind !== "string"))
|
|
178
|
+
unsupported("Invalid or foreign issue feed entries");
|
|
179
|
+
if (!("nextCursor" in data))
|
|
180
|
+
unsupported("Feed response is missing nextCursor");
|
|
181
|
+
return {
|
|
182
|
+
items,
|
|
183
|
+
nextCursor: data.nextCursor === null ? null : cursorValue(data.nextCursor),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function findComment(ctx, issueId, commentId) {
|
|
187
|
+
let cursor;
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (;;) {
|
|
190
|
+
const page = await feed(ctx, issueId, 100, cursor, true);
|
|
191
|
+
const found = page.items.find((item) => item.id === commentId && item.kind === "comment");
|
|
192
|
+
if (found)
|
|
193
|
+
return found;
|
|
194
|
+
if (page.nextCursor === null)
|
|
195
|
+
return undefined;
|
|
196
|
+
cursor = JSON.stringify(page.nextCursor);
|
|
197
|
+
if (seen.has(cursor))
|
|
198
|
+
unsupported("Feed returned a repeated cursor; reply ownership cannot be established");
|
|
199
|
+
seen.add(cursor);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function actorFor(options) {
|
|
203
|
+
const env = process.env.ITSAPLAN_AGENT;
|
|
204
|
+
if (options.human && (options.agent !== undefined || Boolean(env)))
|
|
205
|
+
throw new CliError("ACTOR_CONFLICT", "--human conflicts with --agent or ITSAPLAN_AGENT; clear the environment declaration");
|
|
206
|
+
if (env && env !== "codex" && env !== "claude")
|
|
207
|
+
throw new CliError("INVALID_ACTOR", "ITSAPLAN_AGENT must be codex or claude");
|
|
208
|
+
if (options.agent !== undefined &&
|
|
209
|
+
options.agent !== "codex" &&
|
|
210
|
+
options.agent !== "claude")
|
|
211
|
+
throw new CliError("INVALID_ACTOR", "--agent must be codex or claude");
|
|
212
|
+
if (options.human)
|
|
213
|
+
return { kind: "human", source: "flag" };
|
|
214
|
+
const name = options.agent ?? env;
|
|
215
|
+
if (name !== "codex" && name !== "claude")
|
|
216
|
+
throw new CliError("ACTOR_REQUIRED", "Provide --agent codex|claude or --human");
|
|
217
|
+
return {
|
|
218
|
+
kind: "agent",
|
|
219
|
+
name,
|
|
220
|
+
source: options.agent !== undefined ? "flag" : "env",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async function readBody(options) {
|
|
224
|
+
if (options.body !== undefined && options.bodyFile !== undefined)
|
|
225
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "--body and --body-file are mutually exclusive");
|
|
226
|
+
if (options.bodyFile === undefined)
|
|
227
|
+
return options.body;
|
|
228
|
+
if (options.bodyFile !== "-") {
|
|
229
|
+
try {
|
|
230
|
+
return await readFile(options.bodyFile, "utf8");
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new CliError("BODY_READ_FAILED", "Cannot read --body-file as UTF-8");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const chunks = [];
|
|
237
|
+
for await (const chunk of process.stdin)
|
|
238
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
239
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
240
|
+
}
|
|
241
|
+
function signedBody(body, actor, comment) {
|
|
242
|
+
let value = body;
|
|
243
|
+
if (actor.kind === "agent") {
|
|
244
|
+
if (!comment) {
|
|
245
|
+
value = value.trimEnd();
|
|
246
|
+
const footer = /(?:^|\r?\n[ \t]*\r?\n)— last edited by (?:Codex|Claude) via iap$/u;
|
|
247
|
+
while (footer.test(value))
|
|
248
|
+
value = value.replace(footer, "").trimEnd();
|
|
249
|
+
}
|
|
250
|
+
const name = actor.name === "codex" ? "Codex" : "Claude";
|
|
251
|
+
value = `${value.trimEnd()}\n\n— ${comment ? "by" : "last edited by"} ${name} via iap`;
|
|
252
|
+
}
|
|
253
|
+
if (value.length > 50000 || (comment && !value.trim()))
|
|
254
|
+
throw new CliError("BODY_LENGTH", "The final body including its footer must be 1–50000 characters for comments, or at most 50000 for descriptions");
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
async function me(ctx) {
|
|
258
|
+
const response = object(await ctx.client.request("/me"), "identity");
|
|
259
|
+
if (response.authenticated !== true)
|
|
260
|
+
throw new CliError("AUTHENTICATION_FAILED", "The API key is not authenticated", 3);
|
|
261
|
+
const user = object(response.user, "current user");
|
|
262
|
+
if (typeof user.id !== "string" || !user.id)
|
|
263
|
+
unsupported("Current user response is missing id");
|
|
264
|
+
return Object.fromEntries(["id", "name", "username", "isAgent"]
|
|
265
|
+
.filter((key) => user[key] !== undefined)
|
|
266
|
+
.map((key) => [key, user[key]]));
|
|
267
|
+
}
|
|
268
|
+
function one(values, label, input, predicate) {
|
|
269
|
+
const matches = values.filter(predicate);
|
|
270
|
+
if (matches.length === 0)
|
|
271
|
+
throw new CliError("VALUE_NOT_FOUND", `${label} ${JSON.stringify(input)} is not available in this project`);
|
|
272
|
+
if (matches.length > 1)
|
|
273
|
+
throw new CliError("AMBIGUOUS_VALUE", `${label} ${JSON.stringify(input)} is ambiguous`, 2, {
|
|
274
|
+
candidates: matches.map((item) => ({
|
|
275
|
+
id: item.id ?? item.userId,
|
|
276
|
+
name: item.name,
|
|
277
|
+
username: item.username,
|
|
278
|
+
})),
|
|
279
|
+
});
|
|
280
|
+
return matches[0];
|
|
281
|
+
}
|
|
282
|
+
async function assignee(ctx, setup, value) {
|
|
283
|
+
const input = value === "@me" ? String((await me(ctx)).id) : value.replace(/^@/, "");
|
|
284
|
+
const member = one(setup.assignees, "Assignee", value, (entry) => entry.kind === "member" &&
|
|
285
|
+
(entry.userId === input || entry.username === input));
|
|
286
|
+
return String(member.userId);
|
|
287
|
+
}
|
|
288
|
+
function exclusive(values, message) {
|
|
289
|
+
if (values.filter((value) => value !== undefined && value !== false).length > 1)
|
|
290
|
+
throw new CliError("CONFLICTING_ARGUMENTS", message);
|
|
291
|
+
}
|
|
292
|
+
async function patchFor(ctx, item, setup, options, actor) {
|
|
293
|
+
exclusive([options.state, options.stateType, options.columnId], "Use only one of --state, --state-type, --column-id");
|
|
294
|
+
exclusive([options.assignee, options.removeAssignee], "--assignee and --remove-assignee conflict");
|
|
295
|
+
exclusive([options.priority, options.removePriority], "--priority and --remove-priority conflict");
|
|
296
|
+
exclusive([options.type, options.removeType], "--type and --remove-type conflict");
|
|
297
|
+
const patch = {};
|
|
298
|
+
if (options.title !== undefined) {
|
|
299
|
+
if (!options.title.trim())
|
|
300
|
+
throw new CliError("INVALID_TITLE", "--title must not be empty");
|
|
301
|
+
patch.title = options.title;
|
|
302
|
+
}
|
|
303
|
+
const body = await readBody(options);
|
|
304
|
+
if (body !== undefined)
|
|
305
|
+
patch.description = signedBody(body, actor, false);
|
|
306
|
+
if (options.stateType !== undefined && !states.includes(options.stateType))
|
|
307
|
+
throw new CliError("INVALID_STATE_TYPE", `--state-type must be ${states.join("|")}`);
|
|
308
|
+
if (options.columnId !== undefined ||
|
|
309
|
+
options.state !== undefined ||
|
|
310
|
+
options.stateType !== undefined) {
|
|
311
|
+
const id = options.columnId === undefined
|
|
312
|
+
? undefined
|
|
313
|
+
: integer(options.columnId, "--column-id");
|
|
314
|
+
const value = options.columnId ?? options.state ?? options.stateType;
|
|
315
|
+
patch.columnId = one(setup.columns, "State", value, (entry) => id !== undefined
|
|
316
|
+
? entry.id === id
|
|
317
|
+
: options.state !== undefined
|
|
318
|
+
? entry.name === options.state
|
|
319
|
+
: entry.stateType === options.stateType).id;
|
|
320
|
+
}
|
|
321
|
+
if (options.type !== undefined)
|
|
322
|
+
patch.typeId = one(setup.issueTypes, "Issue type", options.type, (entry) => entry.name === options.type || String(entry.id) === options.type).id;
|
|
323
|
+
if (options.removeType)
|
|
324
|
+
patch.typeId = null;
|
|
325
|
+
if (options.assignee !== undefined)
|
|
326
|
+
patch.assigneeUserId = await assignee(ctx, setup, options.assignee);
|
|
327
|
+
if (options.removeAssignee)
|
|
328
|
+
patch.assigneeUserId = null;
|
|
329
|
+
if (options.priority !== undefined) {
|
|
330
|
+
if (!["urgent", "high", "medium", "low"].includes(options.priority))
|
|
331
|
+
throw new CliError("INVALID_PRIORITY", "--priority must be urgent|high|medium|low");
|
|
332
|
+
patch.priority = options.priority;
|
|
333
|
+
}
|
|
334
|
+
if (options.removePriority)
|
|
335
|
+
patch.priority = null;
|
|
336
|
+
const add = options.addLabel ?? [];
|
|
337
|
+
const remove = options.removeLabel ?? [];
|
|
338
|
+
if (add.length || remove.length) {
|
|
339
|
+
if (!Array.isArray(item.labelIds) || !item.labelIds.every(positive))
|
|
340
|
+
unsupported("Issue response is missing labelIds; cannot preserve existing labels");
|
|
341
|
+
const resolveLabel = (name) => Number(one(setup.labels, "Label", name, (entry) => entry.name === name || String(entry.id) === name).id);
|
|
342
|
+
const addIds = add.map(resolveLabel), removeIds = remove.map(resolveLabel);
|
|
343
|
+
if (addIds.some((id) => removeIds.includes(id)))
|
|
344
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "The same label cannot be added and removed");
|
|
345
|
+
patch.labelIds = [
|
|
346
|
+
...new Set([
|
|
347
|
+
...item.labelIds.filter((id) => !removeIds.includes(id)),
|
|
348
|
+
...addIds,
|
|
349
|
+
]),
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
if (Object.keys(patch).length === 0)
|
|
353
|
+
throw new CliError("NO_CHANGES", "Provide at least one issue edit field");
|
|
354
|
+
for (const key of Object.keys(patch))
|
|
355
|
+
if (!(key in item))
|
|
356
|
+
unsupported(`Issue response is missing ${key}; cannot build a trustworthy diff`);
|
|
357
|
+
return patch;
|
|
358
|
+
}
|
|
359
|
+
function matchesPatch(value, item, patch) {
|
|
360
|
+
if (!record(value) ||
|
|
361
|
+
value.id !== item.id ||
|
|
362
|
+
value.projectId !== item.projectId)
|
|
363
|
+
return false;
|
|
364
|
+
return Object.entries(patch).every(([key, expected]) => {
|
|
365
|
+
const actual = value[key];
|
|
366
|
+
return key === "labelIds" &&
|
|
367
|
+
Array.isArray(actual) &&
|
|
368
|
+
Array.isArray(expected)
|
|
369
|
+
? isDeepStrictEqual([...actual].sort(), [...expected].sort())
|
|
370
|
+
: isDeepStrictEqual(actual, expected);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function writeFailure(error, data, actor, resource = "issue/feed") {
|
|
374
|
+
const detail = error instanceof CliError ? error.details : {};
|
|
375
|
+
const status = typeof detail.status === "number" ? detail.status : undefined;
|
|
376
|
+
const rejected = classifyWriteFailure(error) === "not_applied";
|
|
377
|
+
return {
|
|
378
|
+
data,
|
|
379
|
+
meta: { actor },
|
|
380
|
+
outcome: rejected ? "not_applied" : "unknown",
|
|
381
|
+
verification: "not_run",
|
|
382
|
+
exitCode: rejected && error instanceof CliError ? error.exitCode : 6,
|
|
383
|
+
error: {
|
|
384
|
+
code: rejected && error instanceof CliError
|
|
385
|
+
? error.code
|
|
386
|
+
: "WRITE_OUTCOME_UNKNOWN",
|
|
387
|
+
message: rejected && error instanceof CliError
|
|
388
|
+
? error.message
|
|
389
|
+
: `The write outcome is unknown. Read the ${resource} before deciding whether to resend.`,
|
|
390
|
+
...(status === undefined ? {} : { status }),
|
|
391
|
+
...(typeof detail.apiCode === "string"
|
|
392
|
+
? { apiCode: detail.apiCode }
|
|
393
|
+
: {}),
|
|
394
|
+
retryable: false,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function verificationFailed(data, actor, resource = "issue/feed") {
|
|
399
|
+
return {
|
|
400
|
+
data,
|
|
401
|
+
meta: { actor },
|
|
402
|
+
outcome: "applied",
|
|
403
|
+
verification: "failed",
|
|
404
|
+
exitCode: 6,
|
|
405
|
+
error: {
|
|
406
|
+
code: "VERIFICATION_FAILED",
|
|
407
|
+
message: `The write was accepted but verification failed. Read the ${resource}; do not resend.`,
|
|
408
|
+
retryable: false,
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async function writeIssue(ctx, action, identifier, options, actor) {
|
|
413
|
+
const { item, ref } = await resolve(ctx, identifier, options);
|
|
414
|
+
const setup = action === "edit" ? await setupFor(ctx, item, ref) : undefined;
|
|
415
|
+
let requestBody;
|
|
416
|
+
if (action === "edit")
|
|
417
|
+
requestBody = await patchFor(ctx, item, setup, options, actor);
|
|
418
|
+
else {
|
|
419
|
+
const body = await readBody(options);
|
|
420
|
+
if (body === undefined || !body.trim())
|
|
421
|
+
throw new CliError("BODY_REQUIRED", "Provide nonempty --body or --body-file");
|
|
422
|
+
requestBody = { body: signedBody(body, actor, true) };
|
|
423
|
+
if (options.replyTo !== undefined) {
|
|
424
|
+
const replyToId = integer(options.replyTo, "--reply-to");
|
|
425
|
+
if (!(await findComment(ctx, item.id, replyToId)))
|
|
426
|
+
throw new CliError("REPLY_NOT_FOUND", "The reply target is not a comment belonging to this issue");
|
|
427
|
+
requestBody.replyToId = replyToId;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const method = action === "edit" ? "PATCH" : "POST";
|
|
431
|
+
const path = `/issues/${item.id}${action === "comment" ? "/comments" : ""}`;
|
|
432
|
+
const data = {
|
|
433
|
+
action,
|
|
434
|
+
issueId: item.id,
|
|
435
|
+
identifier: item.identifier,
|
|
436
|
+
};
|
|
437
|
+
if (options.dryRun) {
|
|
438
|
+
const identity = await me(ctx);
|
|
439
|
+
const diff = action === "edit"
|
|
440
|
+
? Object.fromEntries(Object.entries(requestBody).map(([key, value]) => [
|
|
441
|
+
key,
|
|
442
|
+
{ before: item[key], after: value },
|
|
443
|
+
]))
|
|
444
|
+
: { body: { before: null, after: requestBody.body } };
|
|
445
|
+
return {
|
|
446
|
+
data: {
|
|
447
|
+
...data,
|
|
448
|
+
dryRun: true,
|
|
449
|
+
identity,
|
|
450
|
+
request: { method, path, body: requestBody },
|
|
451
|
+
diff,
|
|
452
|
+
warnings: [
|
|
453
|
+
"Preview does not validate server permissions, WIP limits, or concurrent edits.",
|
|
454
|
+
...(requestBody.labelIds === undefined
|
|
455
|
+
? []
|
|
456
|
+
: [
|
|
457
|
+
"Label replacement is a read-modify-write operation, not an atomic add/remove.",
|
|
458
|
+
]),
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
meta: { actor },
|
|
462
|
+
outcome: "not_applied",
|
|
463
|
+
verification: "not_run",
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
let response;
|
|
467
|
+
try {
|
|
468
|
+
response = await ctx.client.request(path, { method, body: requestBody });
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
return writeFailure(error, data, actor);
|
|
472
|
+
}
|
|
473
|
+
data.changed = true;
|
|
474
|
+
if (record(response) && typeof response.updatedAt === "string")
|
|
475
|
+
data.updatedAt = response.updatedAt;
|
|
476
|
+
if (action === "edit") {
|
|
477
|
+
if (matchesPatch(response, item, requestBody))
|
|
478
|
+
return {
|
|
479
|
+
data,
|
|
480
|
+
meta: { actor },
|
|
481
|
+
outcome: "applied",
|
|
482
|
+
verification: "passed",
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
const verified = await ctx.client.request(`/issues/${item.id}`);
|
|
486
|
+
if (matchesPatch(verified, item, requestBody)) {
|
|
487
|
+
if (typeof verified.updatedAt === "string")
|
|
488
|
+
data.updatedAt = verified.updatedAt;
|
|
489
|
+
return {
|
|
490
|
+
data,
|
|
491
|
+
meta: { actor },
|
|
492
|
+
outcome: "applied",
|
|
493
|
+
verification: "passed",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
/* The accepted write must remain applied when verification fails. */
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
if (record(response) && positive(response.id))
|
|
503
|
+
data.commentId = response.id;
|
|
504
|
+
const matches = (entry) => entry.issueId === item.id &&
|
|
505
|
+
entry.kind === "comment" &&
|
|
506
|
+
entry.body === requestBody.body &&
|
|
507
|
+
entry.replyToId === (requestBody.replyToId ?? null);
|
|
508
|
+
if (record(response) && positive(response.id) && matches(response))
|
|
509
|
+
return {
|
|
510
|
+
data,
|
|
511
|
+
meta: { actor },
|
|
512
|
+
outcome: "applied",
|
|
513
|
+
verification: "passed",
|
|
514
|
+
};
|
|
515
|
+
if (typeof data.commentId === "number") {
|
|
516
|
+
try {
|
|
517
|
+
const entry = await findComment(ctx, item.id, data.commentId);
|
|
518
|
+
if (entry && matches(entry))
|
|
519
|
+
return {
|
|
520
|
+
data,
|
|
521
|
+
meta: { actor },
|
|
522
|
+
outcome: "applied",
|
|
523
|
+
verification: "passed",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
/* Preserve the applied resource ID. */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return verificationFailed(data, actor);
|
|
532
|
+
}
|
|
533
|
+
function enriched(item, setup) {
|
|
534
|
+
if (typeof item.title !== "string" ||
|
|
535
|
+
typeof item.description !== "string" ||
|
|
536
|
+
!positive(item.columnId) ||
|
|
537
|
+
!Array.isArray(item.labelIds) ||
|
|
538
|
+
!item.labelIds.every(positive))
|
|
539
|
+
unsupported("Issue view is missing title, description, state, or labels");
|
|
540
|
+
const namedIssue = (value) => ({
|
|
541
|
+
...value,
|
|
542
|
+
state: setup.columns.find((entry) => entry.id === value.columnId) ?? null,
|
|
543
|
+
type: setup.issueTypes.find((entry) => entry.id === value.typeId) ?? null,
|
|
544
|
+
});
|
|
545
|
+
const data = namedIssue(item);
|
|
546
|
+
if (item.parent !== undefined && item.parent !== null)
|
|
547
|
+
data.parent = namedIssue(object(item.parent, "parent issue"));
|
|
548
|
+
if (item.subtasks !== undefined)
|
|
549
|
+
data.subtasks = objects(item.subtasks, "subtasks").map(namedIssue);
|
|
550
|
+
if (item.links !== undefined)
|
|
551
|
+
data.links = objects(item.links, "issue links").map((link) => ({
|
|
552
|
+
...link,
|
|
553
|
+
issue: namedIssue(object(link.issue, "linked issue")),
|
|
554
|
+
}));
|
|
555
|
+
if (item.fieldValues !== undefined) {
|
|
556
|
+
data.fieldValues = objects(item.fieldValues, "field values").map((field) => {
|
|
557
|
+
if (!positive(field.fieldId) ||
|
|
558
|
+
!Array.isArray(field.optionIds) ||
|
|
559
|
+
!field.optionIds.every(positive))
|
|
560
|
+
unsupported("Invalid custom field value identifiers");
|
|
561
|
+
const definition = setup.customFields.find((entry) => entry.id === field.fieldId);
|
|
562
|
+
const options = definition?.options === undefined
|
|
563
|
+
? []
|
|
564
|
+
: objects(definition.options, "custom field options");
|
|
565
|
+
return {
|
|
566
|
+
...field,
|
|
567
|
+
name: definition?.name ?? null,
|
|
568
|
+
fieldType: definition?.fieldType ?? null,
|
|
569
|
+
options: field.optionIds.map((id) => options.find((option) => option.id === id) ?? {
|
|
570
|
+
id,
|
|
571
|
+
unavailable: true,
|
|
572
|
+
}),
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return publicData({
|
|
577
|
+
...data,
|
|
578
|
+
project: setup.project,
|
|
579
|
+
assignee: setup.assignees.find((entry) => entry.userId === item.assigneeUserId) ??
|
|
580
|
+
null,
|
|
581
|
+
delegate: setup.assignees.find((entry) => entry.userId === item.delegateUserId) ??
|
|
582
|
+
null,
|
|
583
|
+
labels: item.labelIds.map((id) => setup.labels.find((entry) => entry.id === id) ?? {
|
|
584
|
+
id,
|
|
585
|
+
unavailable: true,
|
|
586
|
+
}),
|
|
587
|
+
customFields: setup.customFields,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
export async function runIssue(ctx, action, identifier, options) {
|
|
591
|
+
if (action === "edit" || action === "comment") {
|
|
592
|
+
let actor;
|
|
593
|
+
try {
|
|
594
|
+
actor = actorFor(options);
|
|
595
|
+
return await writeIssue(ctx, action, identifier, options, actor);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (error instanceof CliError) {
|
|
599
|
+
error.details = {
|
|
600
|
+
...error.details,
|
|
601
|
+
outcome: "not_applied",
|
|
602
|
+
verification: "not_run",
|
|
603
|
+
...(actor ? { meta: { actor } } : {}),
|
|
604
|
+
retryable: false,
|
|
605
|
+
};
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
throw new CliError("ISSUE_PREFLIGHT_FAILED", "Issue write preflight failed; no write was sent", 1, {
|
|
609
|
+
outcome: "not_applied",
|
|
610
|
+
verification: "not_run",
|
|
611
|
+
...(actor ? { meta: { actor } } : {}),
|
|
612
|
+
retryable: false,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (action === "list") {
|
|
617
|
+
if (identifier !== undefined || options.id !== undefined)
|
|
618
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "issue list does not accept an issue identifier or --id");
|
|
619
|
+
if (options.cursor !== undefined)
|
|
620
|
+
throw new CliError("UNSUPPORTED_PAGINATION", "Issue list/search only supports a bounded limit, not a cursor");
|
|
621
|
+
const ref = options.project ?? ctx.options.project ?? ctx.project;
|
|
622
|
+
if (ref === undefined)
|
|
623
|
+
throw new CliError("PROJECT_REQUIRED", "Provide --project team.KEY or configure a project");
|
|
624
|
+
projectRef(ref);
|
|
625
|
+
const limit = options.limit === undefined ? 30 : integer(options.limit, "--limit", 500);
|
|
626
|
+
if (options.search !== undefined && options.assignee !== undefined)
|
|
627
|
+
throw new CliError("UNSUPPORTED_FILTER_COMBINATION", "--search cannot be combined with --assignee");
|
|
628
|
+
if (options.search !== undefined && !options.search.trim())
|
|
629
|
+
throw new CliError("INVALID_SEARCH", "--search must not be empty");
|
|
630
|
+
let assigneeUserId;
|
|
631
|
+
if (options.assignee !== undefined)
|
|
632
|
+
assigneeUserId = await assignee(ctx, await getProject(ctx, ref), options.assignee);
|
|
633
|
+
const items = objects(await ctx.client.request(`/projects/${encodeURIComponent(ref)}/issues${options.search !== undefined ? "/search" : ""}`, {
|
|
634
|
+
query: {
|
|
635
|
+
limit,
|
|
636
|
+
...(options.search === undefined
|
|
637
|
+
? { assigneeUserId }
|
|
638
|
+
: { q: options.search }),
|
|
639
|
+
},
|
|
640
|
+
}), "issues");
|
|
641
|
+
if (items.some((entry) => !positive(entry.id) ||
|
|
642
|
+
typeof entry.identifier !== "string" ||
|
|
643
|
+
typeof entry.title !== "string"))
|
|
644
|
+
unsupported("List response is missing issue identifiers or titles");
|
|
645
|
+
return {
|
|
646
|
+
data: { items: publicData(items), nextCursor: null },
|
|
647
|
+
meta: { limit, returned: items.length, completeness: "unknown" },
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (!["resolve", "view", "activity", "comments"].includes(action))
|
|
651
|
+
throw new CliError("UNKNOWN_ACTION", `Unsupported issue action: ${action}`);
|
|
652
|
+
const { item, ref } = await resolve(ctx, identifier, options);
|
|
653
|
+
if (action === "resolve")
|
|
654
|
+
return {
|
|
655
|
+
data: publicData({
|
|
656
|
+
id: item.id,
|
|
657
|
+
issueId: item.id,
|
|
658
|
+
projectId: item.projectId,
|
|
659
|
+
identifier: item.identifier,
|
|
660
|
+
sequenceNumber: item.sequenceNumber,
|
|
661
|
+
...(ref ? { projectRef: ref } : {}),
|
|
662
|
+
}),
|
|
663
|
+
};
|
|
664
|
+
if (action === "activity" || action === "comments") {
|
|
665
|
+
const limit = options.limit === undefined ? 25 : integer(options.limit, "--limit", 100);
|
|
666
|
+
const page = await feed(ctx, item.id, limit, decodeCursor(options.cursor), action === "comments");
|
|
667
|
+
return {
|
|
668
|
+
data: {
|
|
669
|
+
items: publicData(page.items),
|
|
670
|
+
nextCursor: encodeCursor(page.nextCursor),
|
|
671
|
+
},
|
|
672
|
+
meta: {
|
|
673
|
+
limit,
|
|
674
|
+
returned: page.items.length,
|
|
675
|
+
completeness: page.nextCursor === null ? "complete" : "partial",
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const setup = await setupFor(ctx, item, ref);
|
|
680
|
+
const attachments = objects(await ctx.client.request(`/issues/${item.id}/attachments`), "attachments");
|
|
681
|
+
if (attachments.some((entry) => typeof entry.id !== "string" ||
|
|
682
|
+
typeof entry.filename !== "string" ||
|
|
683
|
+
typeof entry.contentType !== "string" ||
|
|
684
|
+
typeof entry.sizeBytes !== "number"))
|
|
685
|
+
unsupported("Invalid attachment metadata");
|
|
686
|
+
const data = object(enriched(item, setup), "enriched issue");
|
|
687
|
+
data.attachments = publicData(attachments);
|
|
688
|
+
if (options.comments) {
|
|
689
|
+
const page = await feed(ctx, item.id, 25, undefined, true);
|
|
690
|
+
data.comments = {
|
|
691
|
+
items: publicData(page.items),
|
|
692
|
+
nextCursor: encodeCursor(page.nextCursor),
|
|
693
|
+
};
|
|
694
|
+
return {
|
|
695
|
+
data,
|
|
696
|
+
meta: {
|
|
697
|
+
comments: {
|
|
698
|
+
limit: 25,
|
|
699
|
+
returned: page.items.length,
|
|
700
|
+
completeness: page.nextCursor === null ? "complete" : "partial",
|
|
701
|
+
},
|
|
702
|
+
attachmentsContentRead: false,
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
return { data, meta: { attachmentsContentRead: false } };
|
|
707
|
+
}
|
|
708
|
+
// Shared resource workflows use the same identity, scope, input and outcome rules.
|
|
709
|
+
export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, };
|