@bdsqqq/lnr-cli 1.5.0 → 2.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 +2 -3
- package/src/bench-lnr-overhead.ts +160 -0
- package/src/e2e-mutations.test.ts +378 -0
- package/src/e2e-readonly.test.ts +103 -0
- package/src/generated/doc.ts +270 -0
- package/src/generated/issue.ts +807 -0
- package/src/generated/label.ts +273 -0
- package/src/generated/project.ts +596 -0
- package/src/generated/template.ts +157 -0
- package/src/hand-crafted/issue.ts +27 -0
- package/src/lib/adapters/doc.ts +14 -0
- package/src/lib/adapters/index.ts +4 -0
- package/src/lib/adapters/issue.ts +32 -0
- package/src/lib/adapters/label.ts +20 -0
- package/src/lib/adapters/project.ts +23 -0
- package/src/lib/arktype-config.ts +18 -0
- package/src/lib/command-introspection.ts +97 -0
- package/src/lib/dispatch-effects.test.ts +297 -0
- package/src/lib/error.ts +37 -1
- package/src/lib/operation-spec.test.ts +317 -0
- package/src/lib/operation-spec.ts +11 -0
- package/src/lib/operation-specs.ts +21 -0
- package/src/lib/output.test.ts +3 -1
- package/src/lib/output.ts +1 -296
- package/src/lib/renderers/comments.ts +300 -0
- package/src/lib/renderers/detail.ts +61 -0
- package/src/lib/renderers/index.ts +2 -0
- package/src/router/agent-sessions.ts +253 -0
- package/src/router/auth.ts +6 -5
- package/src/router/config.ts +7 -6
- package/src/router/contract.test.ts +364 -0
- package/src/router/cycles.ts +372 -95
- package/src/router/git-automation-states.ts +355 -0
- package/src/router/git-automation-target-branches.ts +309 -0
- package/src/router/index.ts +26 -8
- package/src/router/initiatives.ts +260 -0
- package/src/router/me.ts +8 -7
- package/src/router/notifications.ts +176 -0
- package/src/router/roadmaps.ts +172 -0
- package/src/router/search.ts +7 -6
- package/src/router/teams.ts +82 -24
- package/src/router/users.ts +126 -0
- package/src/router/views.ts +399 -0
- package/src/router/docs.ts +0 -153
- package/src/router/issues.ts +0 -600
- package/src/router/labels.ts +0 -192
- package/src/router/projects.ts +0 -220
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE - DO NOT EDIT
|
|
3
|
+
* Regenerate with: bun run packages/codegen/generate-commands.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import "../lib/arktype-config";
|
|
7
|
+
import { type } from "arktype";
|
|
8
|
+
import {
|
|
9
|
+
getClient,
|
|
10
|
+
listIssues,
|
|
11
|
+
getIssue,
|
|
12
|
+
createIssue,
|
|
13
|
+
updateIssue,
|
|
14
|
+
archiveIssue,
|
|
15
|
+
findTeamByKeyOrName,
|
|
16
|
+
getAvailableTeamKeys,
|
|
17
|
+
getTeamLabels,
|
|
18
|
+
resolveAssignee,
|
|
19
|
+
priorityFromString,
|
|
20
|
+
resolveStateName,
|
|
21
|
+
resolveIssueIdentifier,
|
|
22
|
+
resolveProjectByName,
|
|
23
|
+
resolveCycleByName,
|
|
24
|
+
resolveMilestoneByName,
|
|
25
|
+
createIssueRelation,
|
|
26
|
+
addComment,
|
|
27
|
+
updateComment,
|
|
28
|
+
replyToComment,
|
|
29
|
+
deleteComment,
|
|
30
|
+
createCommentReaction,
|
|
31
|
+
deleteReaction,
|
|
32
|
+
getIssueComments,
|
|
33
|
+
getSubIssues,
|
|
34
|
+
getTeamStates,
|
|
35
|
+
subscribeToIssue,
|
|
36
|
+
unsubscribeFromIssue,
|
|
37
|
+
createReaction,
|
|
38
|
+
batchUpdateIssues,
|
|
39
|
+
type Issue,
|
|
40
|
+
type ListIssuesFilter,
|
|
41
|
+
type Comment,
|
|
42
|
+
} from "@bdsqqq/lnr-core";
|
|
43
|
+
import { router, procedure } from "../router/trpc";
|
|
44
|
+
import { handleApiError, exitWithError, EXIT_CODES } from "../lib/error";
|
|
45
|
+
import type { OperationSpec } from "../lib/operation-spec";
|
|
46
|
+
import {
|
|
47
|
+
outputJson,
|
|
48
|
+
outputQuiet,
|
|
49
|
+
outputTable,
|
|
50
|
+
getOutputFormat,
|
|
51
|
+
truncate,
|
|
52
|
+
formatDate,
|
|
53
|
+
formatPriority,
|
|
54
|
+
type OutputOptions,
|
|
55
|
+
type TableColumn,
|
|
56
|
+
} from "../lib/output";
|
|
57
|
+
import { outputCommentThreads } from "../lib/renderers/comments";
|
|
58
|
+
import { outputDetail } from "../lib/renderers/detail";
|
|
59
|
+
import { issueToDetail } from "../lib/adapters";
|
|
60
|
+
import { handlePr } from "../hand-crafted/issue";
|
|
61
|
+
import { spawn } from "node:child_process";
|
|
62
|
+
import chalk from "chalk";
|
|
63
|
+
|
|
64
|
+
export const listIssuesInput = type({
|
|
65
|
+
"team?": type("string").describe("filter by team key"),
|
|
66
|
+
"project?": type("string").describe("filter by project name"),
|
|
67
|
+
"assignee?": type("string").describe("filter by assignee email or @me"),
|
|
68
|
+
"state?": type("string").describe("filter by state name"),
|
|
69
|
+
"priority?": type("string").describe("filter by priority"),
|
|
70
|
+
"label?": type("string").describe("filter by label name"),
|
|
71
|
+
"cycle?": type("string").describe("filter by cycle"),
|
|
72
|
+
"json?": type("boolean").describe("output as json"),
|
|
73
|
+
"quiet?": type("boolean").describe("output ids only"),
|
|
74
|
+
"verbose?": type("boolean").describe("show all columns"),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const issueInput = type({
|
|
78
|
+
idOrNew: type("string").configure({ positional: true }).describe("issue identifier (e.g. ENG-123) or 'new'"),
|
|
79
|
+
"json?": type("boolean").describe("output as json"),
|
|
80
|
+
"open?": type("boolean").describe("open issue in browser"),
|
|
81
|
+
"branch?": type("boolean").describe("output a git-friendly branch name"),
|
|
82
|
+
"pr?": type("string").describe("link a GitHub PR URL to the issue"),
|
|
83
|
+
"title?": type("string").describe("The issue title."),
|
|
84
|
+
"description?": type("string").describe("The issue description in markdown format."),
|
|
85
|
+
"assignee?": type("string").describe("set assignee by email or @me"),
|
|
86
|
+
"parent?": type("string").describe("set parent issue identifier"),
|
|
87
|
+
"priority?": type("string").describe("set priority (urgent, high, medium, low, none)"),
|
|
88
|
+
"estimate?": type("number").describe("set estimate points"),
|
|
89
|
+
"team?": type("string").describe("team key (required for new)"),
|
|
90
|
+
"cycle?": type("string").describe("set cycle"),
|
|
91
|
+
"project?": type("string").describe("set project name"),
|
|
92
|
+
"milestone?": type("string").describe("set milestone name (requires --project)"),
|
|
93
|
+
"state?": type("string").describe("set workflow state"),
|
|
94
|
+
"prioritySortOrder?": type("number").describe("The position of the issue related to other issues, when ordered by priority."),
|
|
95
|
+
"dueDate?": type("string").describe("set due date (YYYY-MM-DD)"),
|
|
96
|
+
"label?": type("string").describe("set label (+name to add, -name to remove)"),
|
|
97
|
+
"comment?": type("string").describe("add comment to issue"),
|
|
98
|
+
"blocks?": type("string").describe("add blocks relation to issue"),
|
|
99
|
+
"blockedBy?": type("string").describe("add blocked-by relation to issue"),
|
|
100
|
+
"relatesTo?": type("string").describe("add relates-to relation to issue"),
|
|
101
|
+
"editComment?": type("string").describe("comment id to edit (requires --text)"),
|
|
102
|
+
"text?": type("string").describe("text for --edit-comment or --reply-to"),
|
|
103
|
+
"replyTo?": type("string").describe("comment id to reply to (requires --text)"),
|
|
104
|
+
"deleteComment?": type("string").describe("comment id to delete"),
|
|
105
|
+
"archive?": type("boolean").describe("archive the issue"),
|
|
106
|
+
"comments?": type("boolean").describe("list comments on issue"),
|
|
107
|
+
"subIssues?": type("boolean").describe("list sub-issues"),
|
|
108
|
+
"react?": type("string").describe("entity id to add reaction (requires --emoji)"),
|
|
109
|
+
"emoji?": type("string").describe("emoji for --react"),
|
|
110
|
+
"unreact?": type("string").describe("reaction id to remove"),
|
|
111
|
+
"subscribe?": type("boolean").describe("subscribe to issue notifications"),
|
|
112
|
+
"unsubscribe?": type("boolean").describe("unsubscribe from issue notifications"),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
type IssueInput = typeof issueInput.infer;
|
|
116
|
+
|
|
117
|
+
export const batchUpdateInput = type({
|
|
118
|
+
issues: type("string").configure({ positional: true }).describe("comma-separated issue identifiers (e.g. ENG-1,ENG-2,ENG-3)"),
|
|
119
|
+
"state?": type("string").describe("set workflow state for all issues"),
|
|
120
|
+
"assignee?": type("string").describe("set assignee by email or @me for all issues"),
|
|
121
|
+
"priority?": type("string").describe("set priority for all issues (urgent, high, medium, low, none)"),
|
|
122
|
+
"label?": type("string").describe("set label for all issues (+name to add)"),
|
|
123
|
+
"json?": type("boolean").describe("output as json"),
|
|
124
|
+
"quiet?": type("boolean").describe("output ids only"),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
type BatchUpdateInput = typeof batchUpdateInput.infer;
|
|
128
|
+
|
|
129
|
+
const issueColumns: TableColumn<Issue>[] = [
|
|
130
|
+
{ header: "ID", value: (i) => i.identifier, width: 10 },
|
|
131
|
+
{ header: "STATE", value: (i) => i.state ?? "-", width: 15 },
|
|
132
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
|
|
133
|
+
{ header: "ASSIGNEE", value: (i) => i.assignee ?? "-", width: 15 },
|
|
134
|
+
{ header: "PRIORITY", value: (i) => formatPriority(i.priority), width: 8 },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export const issueOperations = ["create", "read", "update", "archive"] as const;
|
|
138
|
+
type Operation = (typeof issueOperations)[number];
|
|
139
|
+
|
|
140
|
+
export const issueMutationFlags: readonly (keyof IssueInput)[] = [
|
|
141
|
+
"state", "assignee", "priority", "label", "comment", "editComment", "replyTo", "deleteComment", "parent", "blocks", "blockedBy", "relatesTo", "title", "description", "project", "cycle", "estimate", "dueDate", "milestone", "pr", "prioritySortOrder", "subscribe", "unsubscribe", "react", "emoji", "unreact"
|
|
142
|
+
] as const;
|
|
143
|
+
|
|
144
|
+
export function inferOperation(input: IssueInput): Operation {
|
|
145
|
+
if (input.idOrNew === "new") return "create";
|
|
146
|
+
|
|
147
|
+
const hasMutationFlags = issueMutationFlags.some(flag => input[flag] !== undefined);
|
|
148
|
+
|
|
149
|
+
if (input.archive && hasMutationFlags) return "update";
|
|
150
|
+
if (input.archive) return "archive";
|
|
151
|
+
|
|
152
|
+
if (hasMutationFlags) return "update";
|
|
153
|
+
|
|
154
|
+
return "read";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const issueOperationSpec: OperationSpec<IssueInput, Operation> = {
|
|
158
|
+
command: "issue",
|
|
159
|
+
operations: issueOperations,
|
|
160
|
+
mutationFlags: issueMutationFlags,
|
|
161
|
+
inferOperation,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
async function handleListIssues(
|
|
165
|
+
input: typeof listIssuesInput.infer
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
const client = getClient();
|
|
169
|
+
|
|
170
|
+
const outputOpts: OutputOptions = {
|
|
171
|
+
format: input.json ? "json" : input.quiet ? "quiet" : undefined,
|
|
172
|
+
verbose: input.verbose,
|
|
173
|
+
};
|
|
174
|
+
const format = getOutputFormat(outputOpts);
|
|
175
|
+
|
|
176
|
+
const filters: ListIssuesFilter = {};
|
|
177
|
+
|
|
178
|
+
if (input.team) {
|
|
179
|
+
filters.team = input.team;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (input.assignee) {
|
|
183
|
+
filters.assignee = input.assignee;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (input.state) {
|
|
187
|
+
filters.state = input.state;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (input.label) {
|
|
191
|
+
filters.label = input.label;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (input.project) {
|
|
195
|
+
filters.project = input.project;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (input.priority) {
|
|
199
|
+
filters.priority = priorityFromString(input.priority);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (input.cycle) {
|
|
203
|
+
filters.cycle = input.cycle;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const issues = await listIssues(client, filters);
|
|
207
|
+
|
|
208
|
+
if (format === "json") {
|
|
209
|
+
outputJson(issues);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (format === "quiet") {
|
|
214
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
outputTable(issues, issueColumns, outputOpts);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
handleApiError(error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleShowIssue(
|
|
225
|
+
identifier: string,
|
|
226
|
+
input: IssueInput
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
try {
|
|
229
|
+
const client = getClient();
|
|
230
|
+
|
|
231
|
+
const outputOpts: OutputOptions = {
|
|
232
|
+
format: input.json ? "json" : undefined,
|
|
233
|
+
};
|
|
234
|
+
const format = getOutputFormat(outputOpts);
|
|
235
|
+
|
|
236
|
+
const issue = await getIssue(client, identifier);
|
|
237
|
+
|
|
238
|
+
if (!issue) {
|
|
239
|
+
exitWithError(`issue ${identifier} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (input.branch) {
|
|
243
|
+
console.log(issue.branchName);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (input.open) {
|
|
248
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
249
|
+
spawn(cmd, [issue.url], { stdio: "ignore", detached: true }).unref();
|
|
250
|
+
console.log(`opened ${issue.url}`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (input.comments) {
|
|
255
|
+
const { comments, error } = await getIssueComments(client, issue.id);
|
|
256
|
+
if (error) {
|
|
257
|
+
console.error(`failed to fetch comments: ${error}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (format === "json") {
|
|
261
|
+
outputJson(comments);
|
|
262
|
+
} else {
|
|
263
|
+
outputCommentThreads(comments);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (input.subIssues) {
|
|
269
|
+
const subIssues = await getSubIssues(client, issue.id);
|
|
270
|
+
if (format === "json") {
|
|
271
|
+
outputJson(subIssues);
|
|
272
|
+
} else {
|
|
273
|
+
outputTable(subIssues, issueColumns, outputOpts);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { comments, error: commentsError } = await getIssueComments(client, issue.id);
|
|
279
|
+
|
|
280
|
+
if (format === "json") {
|
|
281
|
+
outputJson({
|
|
282
|
+
...issue,
|
|
283
|
+
priority: formatPriority(issue.priority),
|
|
284
|
+
createdAt: formatDate(issue.createdAt),
|
|
285
|
+
updatedAt: formatDate(issue.updatedAt),
|
|
286
|
+
comments,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
outputDetail(issueToDetail(issue));
|
|
292
|
+
|
|
293
|
+
if (commentsError) {
|
|
294
|
+
console.log();
|
|
295
|
+
console.log(chalk.dim(`comments: failed to load (${commentsError})`));
|
|
296
|
+
} else if (comments.length > 0) {
|
|
297
|
+
console.log();
|
|
298
|
+
console.log("─".repeat(40));
|
|
299
|
+
console.log();
|
|
300
|
+
outputCommentThreads(comments);
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
handleApiError(error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function handleUpdateIssue(
|
|
308
|
+
identifier: string,
|
|
309
|
+
input: IssueInput
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
try {
|
|
312
|
+
const client = getClient();
|
|
313
|
+
const issue = await getIssue(client, identifier);
|
|
314
|
+
|
|
315
|
+
if (!issue) {
|
|
316
|
+
exitWithError(`issue ${identifier} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (input.editComment && !input.text) {
|
|
320
|
+
exitWithError("--text is required with --edit-comment");
|
|
321
|
+
}
|
|
322
|
+
if (input.replyTo && !input.text) {
|
|
323
|
+
exitWithError("--text is required with --reply-to");
|
|
324
|
+
}
|
|
325
|
+
if (input.react && !input.emoji) {
|
|
326
|
+
exitWithError("--emoji is required with --react");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const commentOpCount = [input.comment, input.editComment, input.replyTo, input.deleteComment].filter(Boolean).length;
|
|
330
|
+
if (commentOpCount > 1) {
|
|
331
|
+
exitWithError("only one comment operation allowed per invocation", "use --comment, --edit-comment, --reply-to, or --delete-comment separately");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const reactionOpCount = [input.react, input.unreact].filter(Boolean).length;
|
|
335
|
+
if (reactionOpCount > 1) {
|
|
336
|
+
exitWithError("only one reaction operation allowed per invocation", "use --react or --unreact, not both");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const updatePayload: Record<string, unknown> = {};
|
|
340
|
+
const rawIssue = await client.issue(issue.id);
|
|
341
|
+
const teamRef = await rawIssue.team;
|
|
342
|
+
if (!teamRef) {
|
|
343
|
+
exitWithError(`issue ${identifier} has no team`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (input.title) {
|
|
347
|
+
updatePayload.title = input.title;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (input.description) {
|
|
351
|
+
updatePayload.description = input.description;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (input.state) {
|
|
355
|
+
const states = await getTeamStates(client, teamRef.id);
|
|
356
|
+
const targetState = states.find(
|
|
357
|
+
(s) => s.name.toLowerCase() === input.state!.toLowerCase()
|
|
358
|
+
);
|
|
359
|
+
if (!targetState) {
|
|
360
|
+
const available = states.map((s) => s.name).join(", ");
|
|
361
|
+
exitWithError(`state "${input.state}" not found`, `available states: ${available}`);
|
|
362
|
+
}
|
|
363
|
+
updatePayload.stateId = targetState.id;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (input.assignee) {
|
|
367
|
+
if (input.assignee === "@me") {
|
|
368
|
+
const viewer = await client.viewer;
|
|
369
|
+
updatePayload.assigneeId = viewer.id;
|
|
370
|
+
} else {
|
|
371
|
+
const users = await client.users({ filter: { email: { eq: input.assignee } } });
|
|
372
|
+
const user = users.nodes[0];
|
|
373
|
+
if (!user) {
|
|
374
|
+
exitWithError(`user "${input.assignee}" not found`);
|
|
375
|
+
}
|
|
376
|
+
updatePayload.assigneeId = user.id;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (input.priority) {
|
|
381
|
+
updatePayload.priority = priorityFromString(input.priority);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (input.label) {
|
|
385
|
+
const labelInput = input.label;
|
|
386
|
+
const isRemove = labelInput.startsWith("-");
|
|
387
|
+
const labelName = isRemove ? labelInput.slice(1) : labelInput.startsWith("+") ? labelInput.slice(1) : labelInput;
|
|
388
|
+
|
|
389
|
+
if (!labelName) {
|
|
390
|
+
exitWithError("label name cannot be empty");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const labels = await getTeamLabels(client, teamRef.id);
|
|
394
|
+
const targetLabel = labels.find(
|
|
395
|
+
(l) => l.name.toLowerCase() === labelName.toLowerCase()
|
|
396
|
+
);
|
|
397
|
+
if (!targetLabel) {
|
|
398
|
+
const available = labels.map((l) => l.name).join(", ");
|
|
399
|
+
exitWithError(`label "${labelName}" not found`, `available labels: ${available}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const currentLabelsData = rawIssue ? await rawIssue.labels() : { nodes: [] };
|
|
403
|
+
const currentLabelIds = currentLabelsData.nodes.map((l) => l.id);
|
|
404
|
+
|
|
405
|
+
if (isRemove) {
|
|
406
|
+
updatePayload.labelIds = currentLabelIds.filter((id) => id !== targetLabel.id);
|
|
407
|
+
} else {
|
|
408
|
+
if (!currentLabelIds.includes(targetLabel.id)) {
|
|
409
|
+
updatePayload.labelIds = [...currentLabelIds, targetLabel.id];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (input.parent) {
|
|
415
|
+
const parentIssue = await getIssue(client, input.parent);
|
|
416
|
+
if (!parentIssue) {
|
|
417
|
+
exitWithError(`parent issue "${input.parent}" not found`);
|
|
418
|
+
}
|
|
419
|
+
updatePayload.parentId = parentIssue.id;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (input.milestone) {
|
|
423
|
+
if (!input.project) {
|
|
424
|
+
exitWithError("--project is required when using --milestone");
|
|
425
|
+
}
|
|
426
|
+
const projectId = await resolveProjectByName(client, input.project);
|
|
427
|
+
updatePayload.projectMilestoneId = await resolveMilestoneByName(client, projectId, input.milestone);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
431
|
+
await updateIssue(client, issue.id, updatePayload);
|
|
432
|
+
console.log(`updated ${identifier}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (input.blocks) {
|
|
436
|
+
const blockedIssue = await getIssue(client, input.blocks);
|
|
437
|
+
if (!blockedIssue) {
|
|
438
|
+
exitWithError(`issue "${input.blocks}" not found`);
|
|
439
|
+
}
|
|
440
|
+
const success = await createIssueRelation(client, issue.id, blockedIssue.id, "blocks");
|
|
441
|
+
if (!success) {
|
|
442
|
+
exitWithError(`failed to create blocks relation`);
|
|
443
|
+
}
|
|
444
|
+
console.log(`${identifier} now blocks ${input.blocks}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (input.blockedBy) {
|
|
448
|
+
const blockerIssue = await getIssue(client, input.blockedBy);
|
|
449
|
+
if (!blockerIssue) {
|
|
450
|
+
exitWithError(`issue "${input.blockedBy}" not found`);
|
|
451
|
+
}
|
|
452
|
+
const success = await createIssueRelation(client, blockerIssue.id, issue.id, "blocks");
|
|
453
|
+
if (!success) {
|
|
454
|
+
exitWithError(`failed to create blocked-by relation`);
|
|
455
|
+
}
|
|
456
|
+
console.log(`${identifier} is now blocked by ${input.blockedBy}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (input.relatesTo) {
|
|
460
|
+
const relatedIssue = await getIssue(client, input.relatesTo);
|
|
461
|
+
if (!relatedIssue) {
|
|
462
|
+
exitWithError(`issue "${input.relatesTo}" not found`);
|
|
463
|
+
}
|
|
464
|
+
const success = await createIssueRelation(client, issue.id, relatedIssue.id, "related");
|
|
465
|
+
if (!success) {
|
|
466
|
+
exitWithError(`failed to create relates-to relation`);
|
|
467
|
+
}
|
|
468
|
+
console.log(`${identifier} now relates to ${input.relatesTo}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (input.comment) {
|
|
472
|
+
await addComment(client, issue.id, input.comment);
|
|
473
|
+
console.log(`commented on ${identifier}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (input.editComment) {
|
|
477
|
+
await updateComment(client, input.editComment, input.text!);
|
|
478
|
+
console.log(`updated comment ${input.editComment.slice(0, 8)}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (input.replyTo) {
|
|
482
|
+
await replyToComment(client, issue.id, input.replyTo, input.text!);
|
|
483
|
+
console.log(`replied to comment ${input.replyTo.slice(0, 8)}`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (input.deleteComment) {
|
|
487
|
+
await deleteComment(client, input.deleteComment);
|
|
488
|
+
console.log(`deleted comment ${input.deleteComment.slice(0, 8)}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (input.react) {
|
|
492
|
+
const success = await createCommentReaction(client, input.react, input.emoji!);
|
|
493
|
+
if (!success) {
|
|
494
|
+
exitWithError(`failed to add reaction to comment ${input.react.slice(0, 8)}`);
|
|
495
|
+
}
|
|
496
|
+
console.log(`added reaction ${input.emoji} to comment ${input.react.slice(0, 8)}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (input.unreact) {
|
|
500
|
+
const success = await deleteReaction(client, input.unreact);
|
|
501
|
+
if (!success) {
|
|
502
|
+
exitWithError(`reaction ${input.unreact.slice(0, 8)} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
503
|
+
}
|
|
504
|
+
console.log(`removed reaction ${input.unreact.slice(0, 8)}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// issue subscriptions use subscriberIds, not NotificationSubscription entity
|
|
508
|
+
if (input.subscribe) {
|
|
509
|
+
const success = await subscribeToIssue(client, issue.id);
|
|
510
|
+
if (!success) {
|
|
511
|
+
exitWithError(`failed to subscribe to ${identifier}`);
|
|
512
|
+
}
|
|
513
|
+
console.log(`subscribed to ${identifier}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (input.unsubscribe) {
|
|
517
|
+
const success = await unsubscribeFromIssue(client, issue.id);
|
|
518
|
+
if (!success) {
|
|
519
|
+
exitWithError(`failed to unsubscribe from ${identifier}`);
|
|
520
|
+
}
|
|
521
|
+
console.log(`unsubscribed from ${identifier}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (input.pr) {
|
|
525
|
+
await handlePr(client, issue, input.pr);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (input.prioritySortOrder !== undefined) {
|
|
529
|
+
await updateIssue(client, issue.id, { prioritySortOrder: input.prioritySortOrder });
|
|
530
|
+
console.log(`updated priority sort order for ${identifier}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (input.archive) {
|
|
534
|
+
await archiveIssue(client, issue.id);
|
|
535
|
+
console.log(`archived ${identifier}`);
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
handleApiError(error);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function handleCreateIssue(input: IssueInput): Promise<void> {
|
|
543
|
+
if (!input.team) {
|
|
544
|
+
exitWithError("--team is required", 'usage: lnr issue new --team ENG --title "..."');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!input.title) {
|
|
548
|
+
exitWithError("--title is required", 'usage: lnr issue new --team ENG --title "..."');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const client = getClient();
|
|
553
|
+
const team = await findTeamByKeyOrName(client, input.team);
|
|
554
|
+
|
|
555
|
+
if (!team) {
|
|
556
|
+
const available = (await getAvailableTeamKeys(client)).join(", ");
|
|
557
|
+
exitWithError(`team "${input.team}" not found`, `available teams: ${available}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const createPayload: {
|
|
561
|
+
teamId: string;
|
|
562
|
+
title: string;
|
|
563
|
+
description?: string;
|
|
564
|
+
assigneeId?: string;
|
|
565
|
+
priority?: number;
|
|
566
|
+
labelIds?: string[];
|
|
567
|
+
parentId?: string;
|
|
568
|
+
projectId?: string;
|
|
569
|
+
projectMilestoneId?: string;
|
|
570
|
+
cycleId?: string;
|
|
571
|
+
stateId?: string;
|
|
572
|
+
estimate?: number;
|
|
573
|
+
dueDate?: string;
|
|
574
|
+
} = {
|
|
575
|
+
teamId: team.id,
|
|
576
|
+
title: input.title,
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
if (input.description) createPayload.description = input.description;
|
|
580
|
+
if (input.assignee) createPayload.assigneeId = await resolveAssignee(client, input.assignee);
|
|
581
|
+
if (input.priority) createPayload.priority = priorityFromString(input.priority);
|
|
582
|
+
|
|
583
|
+
if (input.label) {
|
|
584
|
+
const labels = await getTeamLabels(client, team.id);
|
|
585
|
+
const targetLabel = labels.find(
|
|
586
|
+
(l) => l.name.toLowerCase() === input.label!.toLowerCase()
|
|
587
|
+
);
|
|
588
|
+
if (!targetLabel) {
|
|
589
|
+
const available = labels.map((l) => l.name).join(", ");
|
|
590
|
+
exitWithError(`label "${input.label}" not found`, `available labels: ${available}`);
|
|
591
|
+
}
|
|
592
|
+
createPayload.labelIds = [targetLabel.id];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (input.parent) createPayload.parentId = await resolveIssueIdentifier(client, input.parent);
|
|
596
|
+
if (input.project) createPayload.projectId = await resolveProjectByName(client, input.project);
|
|
597
|
+
if (input.milestone) {
|
|
598
|
+
if (!createPayload.projectId) {
|
|
599
|
+
exitWithError("--project is required when using --milestone");
|
|
600
|
+
}
|
|
601
|
+
createPayload.projectMilestoneId = await resolveMilestoneByName(client, createPayload.projectId, input.milestone);
|
|
602
|
+
}
|
|
603
|
+
if (input.cycle) createPayload.cycleId = await resolveCycleByName(client, team.id, input.cycle);
|
|
604
|
+
if (input.state) createPayload.stateId = await resolveStateName(client, team.id, input.state);
|
|
605
|
+
if (input.estimate !== undefined) createPayload.estimate = input.estimate;
|
|
606
|
+
if (input.dueDate) createPayload.dueDate = input.dueDate;
|
|
607
|
+
|
|
608
|
+
const issue = await createIssue(client, createPayload);
|
|
609
|
+
|
|
610
|
+
if (issue) {
|
|
611
|
+
if (input.blocks) {
|
|
612
|
+
const blockedIssueId = await resolveIssueIdentifier(client, input.blocks);
|
|
613
|
+
await createIssueRelation(client, issue.id, blockedIssueId, "blocks");
|
|
614
|
+
console.log(`${issue.identifier} now blocks ${input.blocks}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (input.blockedBy) {
|
|
618
|
+
const blockerIssueId = await resolveIssueIdentifier(client, input.blockedBy);
|
|
619
|
+
await createIssueRelation(client, blockerIssueId, issue.id, "blocks");
|
|
620
|
+
console.log(`${issue.identifier} is now blocked by ${input.blockedBy}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (input.relatesTo) {
|
|
624
|
+
const relatedIssueId = await resolveIssueIdentifier(client, input.relatesTo);
|
|
625
|
+
await createIssueRelation(client, issue.id, relatedIssueId, "related");
|
|
626
|
+
console.log(`${issue.identifier} now relates to ${input.relatesTo}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (input.pr) {
|
|
630
|
+
await handlePr(client, issue, input.pr);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
console.log(`created ${issue.identifier}: ${issue.title}`);
|
|
634
|
+
} else {
|
|
635
|
+
console.log("created issue");
|
|
636
|
+
}
|
|
637
|
+
} catch (error) {
|
|
638
|
+
handleApiError(error);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async function handleArchiveIssue(
|
|
645
|
+
identifier: string,
|
|
646
|
+
input: IssueInput
|
|
647
|
+
): Promise<void> {
|
|
648
|
+
try {
|
|
649
|
+
const client = getClient();
|
|
650
|
+
const issue = await getIssue(client, identifier);
|
|
651
|
+
|
|
652
|
+
if (!issue) {
|
|
653
|
+
exitWithError(`issue ${identifier} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await archiveIssue(client, issue.id);
|
|
657
|
+
console.log(`archived ${identifier}`);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
handleApiError(error);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function handleBatchUpdate(input: BatchUpdateInput): Promise<void> {
|
|
664
|
+
try {
|
|
665
|
+
const client = getClient();
|
|
666
|
+
|
|
667
|
+
const outputOpts: OutputOptions = {
|
|
668
|
+
format: input.json ? "json" : input.quiet ? "quiet" : undefined,
|
|
669
|
+
};
|
|
670
|
+
const format = getOutputFormat(outputOpts);
|
|
671
|
+
|
|
672
|
+
const identifiers = input.issues.split(",").map((id) => id.trim()).filter(Boolean);
|
|
673
|
+
if (identifiers.length === 0) {
|
|
674
|
+
exitWithError("no issue identifiers provided", "usage: lnr issue batch ENG-1,ENG-2 --state done");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const firstIdentifier = identifiers[0] as string;
|
|
678
|
+
|
|
679
|
+
const ids: string[] = [];
|
|
680
|
+
const issueMap = new Map<string, string>();
|
|
681
|
+
|
|
682
|
+
for (const identifier of identifiers) {
|
|
683
|
+
const issue = await getIssue(client, identifier);
|
|
684
|
+
if (!issue) {
|
|
685
|
+
exitWithError(`issue "${identifier}" not found`);
|
|
686
|
+
}
|
|
687
|
+
ids.push(issue.id);
|
|
688
|
+
issueMap.set(issue.id, identifier);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const updateInput: {
|
|
692
|
+
stateId?: string;
|
|
693
|
+
assigneeId?: string;
|
|
694
|
+
priority?: number;
|
|
695
|
+
labelIds?: string[];
|
|
696
|
+
} = {};
|
|
697
|
+
|
|
698
|
+
const firstIssue = await getIssue(client, firstIdentifier);
|
|
699
|
+
if (!firstIssue) {
|
|
700
|
+
exitWithError(`issue "${firstIdentifier}" not found`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const teamId = (await (await (await client.issue(firstIssue.id)).team))?.id;
|
|
704
|
+
if (!teamId) {
|
|
705
|
+
exitWithError(`could not determine team for issue "${firstIdentifier}"`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (input.state) {
|
|
709
|
+
updateInput.stateId = await resolveStateName(client, teamId, input.state);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (input.assignee) {
|
|
713
|
+
updateInput.assigneeId = await resolveAssignee(client, input.assignee);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (input.priority) {
|
|
717
|
+
updateInput.priority = priorityFromString(input.priority);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (input.label) {
|
|
721
|
+
const labels = await getTeamLabels(client, teamId);
|
|
722
|
+
const labelName = input.label.startsWith("+") ? input.label.slice(1) : input.label;
|
|
723
|
+
const targetLabel = labels.find(
|
|
724
|
+
(l) => l.name.toLowerCase() === labelName.toLowerCase()
|
|
725
|
+
);
|
|
726
|
+
if (!targetLabel) {
|
|
727
|
+
const available = labels.map((l) => l.name).join(", ");
|
|
728
|
+
exitWithError(`label "${labelName}" not found`, `available labels: ${available}`);
|
|
729
|
+
}
|
|
730
|
+
updateInput.labelIds = [targetLabel.id];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (Object.keys(updateInput).length === 0) {
|
|
734
|
+
exitWithError("no update flags provided", "usage: lnr issue batch ENG-1,ENG-2 --state done");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const result = await batchUpdateIssues(client, ids, updateInput);
|
|
738
|
+
|
|
739
|
+
if (!result.success) {
|
|
740
|
+
exitWithError("batch update failed");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (format === "json") {
|
|
744
|
+
outputJson(result.issues);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (format === "quiet") {
|
|
749
|
+
outputQuiet(result.issues.map((i) => i.identifier));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
console.log(`updated ${result.issues.length} issues:`);
|
|
754
|
+
for (const issue of result.issues) {
|
|
755
|
+
console.log(` ${issue.identifier}: ${issue.title}`);
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
handleApiError(error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export const generatedIssuesRouter = router({
|
|
763
|
+
issues: procedure
|
|
764
|
+
.meta({
|
|
765
|
+
description: "list issues",
|
|
766
|
+
aliases: { command: ["i"] },
|
|
767
|
+
})
|
|
768
|
+
.input(listIssuesInput)
|
|
769
|
+
.query(async ({ input }) => {
|
|
770
|
+
await handleListIssues(input);
|
|
771
|
+
}),
|
|
772
|
+
|
|
773
|
+
issue: procedure
|
|
774
|
+
.meta({
|
|
775
|
+
description: "show or update a issue, or create with 'new'",
|
|
776
|
+
})
|
|
777
|
+
.input(issueInput)
|
|
778
|
+
.mutation(async ({ input }) => {
|
|
779
|
+
const operation = inferOperation(input);
|
|
780
|
+
|
|
781
|
+
switch (operation) {
|
|
782
|
+
case "create":
|
|
783
|
+
await handleCreateIssue(input);
|
|
784
|
+
break;
|
|
785
|
+
|
|
786
|
+
case "archive":
|
|
787
|
+
await handleArchiveIssue(input.idOrNew, input);
|
|
788
|
+
break;
|
|
789
|
+
case "update":
|
|
790
|
+
await handleUpdateIssue(input.idOrNew, input);
|
|
791
|
+
break;
|
|
792
|
+
case "read":
|
|
793
|
+
default:
|
|
794
|
+
await handleShowIssue(input.idOrNew, input);
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
}),
|
|
798
|
+
|
|
799
|
+
"issue batch": procedure
|
|
800
|
+
.meta({
|
|
801
|
+
description: "batch update multiple issues at once",
|
|
802
|
+
})
|
|
803
|
+
.input(batchUpdateInput)
|
|
804
|
+
.mutation(async ({ input }) => {
|
|
805
|
+
await handleBatchUpdate(input);
|
|
806
|
+
}),
|
|
807
|
+
});
|