@bdsqqq/lnr-cli 1.6.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.
Files changed (47) hide show
  1. package/package.json +2 -3
  2. package/src/bench-lnr-overhead.ts +160 -0
  3. package/src/e2e-mutations.test.ts +378 -0
  4. package/src/e2e-readonly.test.ts +103 -0
  5. package/src/generated/doc.ts +270 -0
  6. package/src/generated/issue.ts +807 -0
  7. package/src/generated/label.ts +273 -0
  8. package/src/generated/project.ts +596 -0
  9. package/src/generated/template.ts +157 -0
  10. package/src/hand-crafted/issue.ts +27 -0
  11. package/src/lib/adapters/doc.ts +14 -0
  12. package/src/lib/adapters/index.ts +4 -0
  13. package/src/lib/adapters/issue.ts +32 -0
  14. package/src/lib/adapters/label.ts +20 -0
  15. package/src/lib/adapters/project.ts +23 -0
  16. package/src/lib/arktype-config.ts +18 -0
  17. package/src/lib/command-introspection.ts +97 -0
  18. package/src/lib/dispatch-effects.test.ts +297 -0
  19. package/src/lib/error.ts +37 -1
  20. package/src/lib/operation-spec.test.ts +317 -0
  21. package/src/lib/operation-spec.ts +11 -0
  22. package/src/lib/operation-specs.ts +21 -0
  23. package/src/lib/output.test.ts +3 -1
  24. package/src/lib/output.ts +1 -296
  25. package/src/lib/renderers/comments.ts +300 -0
  26. package/src/lib/renderers/detail.ts +61 -0
  27. package/src/lib/renderers/index.ts +2 -0
  28. package/src/router/agent-sessions.ts +253 -0
  29. package/src/router/auth.ts +6 -5
  30. package/src/router/config.ts +7 -6
  31. package/src/router/contract.test.ts +364 -0
  32. package/src/router/cycles.ts +372 -95
  33. package/src/router/git-automation-states.ts +355 -0
  34. package/src/router/git-automation-target-branches.ts +309 -0
  35. package/src/router/index.ts +26 -8
  36. package/src/router/initiatives.ts +260 -0
  37. package/src/router/me.ts +8 -7
  38. package/src/router/notifications.ts +176 -0
  39. package/src/router/roadmaps.ts +172 -0
  40. package/src/router/search.ts +7 -6
  41. package/src/router/teams.ts +82 -24
  42. package/src/router/users.ts +126 -0
  43. package/src/router/views.ts +399 -0
  44. package/src/router/docs.ts +0 -153
  45. package/src/router/issues.ts +0 -606
  46. package/src/router/labels.ts +0 -192
  47. package/src/router/projects.ts +0 -220
@@ -1,12 +1,20 @@
1
- import { z } from "zod";
1
+ import "../lib/arktype-config";
2
+ import { type } from "arktype";
2
3
  import {
3
4
  getClient,
4
5
  listCycles,
6
+ getCycle,
5
7
  getCurrentCycle,
6
8
  getCycleIssues,
9
+ createCycle,
10
+ updateCycle,
11
+ deleteCycle,
12
+ findTeamByKeyOrName,
13
+ type Cycle,
7
14
  } from "@bdsqqq/lnr-core";
15
+ import type { OperationSpec } from "../lib/operation-spec";
8
16
  import { router, procedure } from "./trpc";
9
- import { exitWithError, handleApiError } from "../lib/error";
17
+ import { exitWithError, handleApiError, EXIT_CODES } from "../lib/error";
10
18
  import {
11
19
  outputJson,
12
20
  outputQuiet,
@@ -14,23 +22,350 @@ import {
14
22
  getOutputFormat,
15
23
  formatDate,
16
24
  truncate,
25
+ type OutputOptions,
26
+ type TableColumn,
17
27
  } from "../lib/output";
18
28
 
19
- const outputOptions = z.object({
20
- json: z.boolean().optional().describe("output as json"),
21
- quiet: z.boolean().optional().describe("output ids only"),
22
- verbose: z.boolean().optional().describe("show all columns"),
29
+ export const listCyclesInput = type({
30
+ team: type("string").describe("team key"),
31
+ "json?": type("boolean").describe("output as json"),
32
+ "quiet?": type("boolean").describe("output ids only"),
33
+ "verbose?": type("boolean").describe("show all columns"),
23
34
  });
24
35
 
25
- const cyclesInput = z.object({
26
- team: z.string().describe("team key"),
27
- }).merge(outputOptions);
36
+ export const cycleInput = type({
37
+ nameOrNumber: type("string").configure({ positional: true }).describe("cycle name, number, or 'new'"),
38
+ team: type("string").describe("team key"),
39
+ "name?": type("string").describe("cycle name"),
40
+ "description?": type("string").describe("cycle description"),
41
+ "startsAt?": type("string").describe("start date (ISO format)"),
42
+ "endsAt?": type("string").describe("end date (ISO format)"),
43
+ "current?": type("boolean").describe("show current active cycle"),
44
+ "issues?": type("boolean").describe("list issues in cycle"),
45
+ "delete?": type("boolean").describe("archive the cycle"),
46
+ "json?": type("boolean").describe("output as json"),
47
+ "quiet?": type("boolean").describe("output ids only"),
48
+ "verbose?": type("boolean").describe("show all columns"),
49
+ });
50
+
51
+ type CycleInput = typeof cycleInput.infer;
52
+
53
+ const cycleColumns: TableColumn<Cycle>[] = [
54
+ { header: "#", value: (c) => String(c.number), width: 4 },
55
+ { header: "NAME", value: (c) => c.name ?? `Cycle ${c.number}`, width: 20 },
56
+ { header: "START", value: (c) => formatDate(c.startsAt), width: 12 },
57
+ { header: "END", value: (c) => formatDate(c.endsAt), width: 12 },
58
+ ];
59
+
60
+ const verboseCycleColumns: TableColumn<Cycle>[] = [
61
+ ...cycleColumns,
62
+ {
63
+ header: "PROGRESS",
64
+ value: (c) => (c.progress != null ? `${Math.round(c.progress * 100)}%` : "-"),
65
+ width: 10,
66
+ },
67
+ {
68
+ header: "DESCRIPTION",
69
+ value: (c) => truncate(c.description ?? "-", 30),
70
+ width: 30,
71
+ },
72
+ { header: "ID", value: (c) => c.id, width: 36 },
73
+ ];
74
+
75
+ export const cycleOperations = ["create", "read", "update", "delete", "current"] as const;
76
+ type Operation = (typeof cycleOperations)[number];
77
+
78
+ export const cycleMutationFlags: readonly (keyof CycleInput)[] = [
79
+ "name", "description", "startsAt", "endsAt"
80
+ ] as const;
81
+
82
+ export function inferOperation(input: CycleInput): Operation {
83
+ if (input.current) return "current";
84
+ if (input.nameOrNumber === "new") return "create";
85
+ if (input.delete) return "delete";
86
+
87
+ for (const flag of cycleMutationFlags) {
88
+ if (input[flag] !== undefined) return "update";
89
+ }
90
+
91
+ return "read";
92
+ }
93
+
94
+ export const cycleOperationSpec: OperationSpec<CycleInput, Operation> = {
95
+ command: "cycle",
96
+ operations: cycleOperations,
97
+ mutationFlags: cycleMutationFlags,
98
+ inferOperation,
99
+ };
100
+
101
+ async function handleListCycles(
102
+ input: typeof listCyclesInput.infer
103
+ ): Promise<void> {
104
+ try {
105
+ const client = getClient();
106
+ const cycles = await listCycles(client, input.team);
107
+
108
+ const outputOpts: OutputOptions = {
109
+ format: input.json ? "json" : input.quiet ? "quiet" : undefined,
110
+ verbose: input.verbose,
111
+ };
112
+ const format = getOutputFormat(outputOpts);
113
+
114
+ if (format === "json") {
115
+ outputJson(cycles);
116
+ return;
117
+ }
118
+
119
+ if (format === "quiet") {
120
+ outputQuiet(cycles.map((c) => c.id));
121
+ return;
122
+ }
123
+
124
+ const columns = input.verbose ? verboseCycleColumns : cycleColumns;
125
+ outputTable(cycles, columns, outputOpts);
126
+ } catch (error) {
127
+ handleApiError(error);
128
+ }
129
+ }
130
+
131
+ async function handleShowCycle(
132
+ nameOrNumber: string,
133
+ input: CycleInput
134
+ ): Promise<void> {
135
+ try {
136
+ const client = getClient();
137
+
138
+ const outputOpts: OutputOptions = {
139
+ format: input.json ? "json" : input.quiet ? "quiet" : undefined,
140
+ verbose: input.verbose,
141
+ };
142
+ const format = getOutputFormat(outputOpts);
143
+
144
+ const cycle = await getCycle(client, input.team, nameOrNumber);
145
+
146
+ if (!cycle) {
147
+ exitWithError(
148
+ `cycle "${nameOrNumber}" not found`,
149
+ `try: lnr cycles --team ${input.team}`,
150
+ EXIT_CODES.NOT_FOUND
151
+ );
152
+ }
153
+
154
+ if (input.issues) {
155
+ const issues = await getCycleIssues(client, input.team);
156
+
157
+ if (format === "json") {
158
+ outputJson(issues);
159
+ return;
160
+ }
161
+
162
+ if (format === "quiet") {
163
+ outputQuiet(issues.map((i) => i.identifier));
164
+ return;
165
+ }
166
+
167
+ outputTable(
168
+ issues,
169
+ [
170
+ { header: "ID", value: (i) => i.identifier, width: 10 },
171
+ { header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
172
+ ],
173
+ outputOpts
174
+ );
175
+ return;
176
+ }
177
+
178
+ if (format === "json") {
179
+ outputJson(cycle);
180
+ return;
181
+ }
182
+
183
+ if (format === "quiet") {
184
+ console.log(cycle.id);
185
+ return;
186
+ }
187
+
188
+ console.log(`cycle ${cycle.number}: ${cycle.name ?? `Cycle ${cycle.number}`}`);
189
+ console.log(` start: ${formatDate(cycle.startsAt)}`);
190
+ console.log(` end: ${formatDate(cycle.endsAt)}`);
191
+ if (cycle.progress != null) {
192
+ console.log(` progress: ${Math.round(cycle.progress * 100)}%`);
193
+ }
194
+ if (cycle.description) {
195
+ console.log(` description: ${cycle.description}`);
196
+ }
197
+ } catch (error) {
198
+ handleApiError(error);
199
+ }
200
+ }
201
+
202
+ async function handleCurrentCycle(input: CycleInput): Promise<void> {
203
+ try {
204
+ const client = getClient();
205
+
206
+ const outputOpts: OutputOptions = {
207
+ format: input.json ? "json" : input.quiet ? "quiet" : undefined,
208
+ verbose: input.verbose,
209
+ };
210
+ const format = getOutputFormat(outputOpts);
28
211
 
29
- const cycleInput = z.object({
30
- team: z.string().describe("team key"),
31
- current: z.boolean().optional().describe("show current active cycle"),
32
- issues: z.boolean().optional().describe("list issues in cycle"),
33
- }).merge(outputOptions);
212
+ const cycle = await getCurrentCycle(client, input.team);
213
+
214
+ if (!cycle) {
215
+ exitWithError("no active cycle", `team "${input.team}" has no current cycle`);
216
+ }
217
+
218
+ if (input.issues) {
219
+ const issues = await getCycleIssues(client, input.team);
220
+
221
+ if (format === "json") {
222
+ outputJson(issues);
223
+ return;
224
+ }
225
+
226
+ if (format === "quiet") {
227
+ outputQuiet(issues.map((i) => i.identifier));
228
+ return;
229
+ }
230
+
231
+ outputTable(
232
+ issues,
233
+ [
234
+ { header: "ID", value: (i) => i.identifier, width: 10 },
235
+ { header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
236
+ ],
237
+ outputOpts
238
+ );
239
+ return;
240
+ }
241
+
242
+ if (format === "json") {
243
+ outputJson(cycle);
244
+ return;
245
+ }
246
+
247
+ if (format === "quiet") {
248
+ console.log(cycle.id);
249
+ return;
250
+ }
251
+
252
+ console.log(`cycle ${cycle.number}: ${cycle.name ?? `Cycle ${cycle.number}`}`);
253
+ console.log(` start: ${formatDate(cycle.startsAt)}`);
254
+ console.log(` end: ${formatDate(cycle.endsAt)}`);
255
+ if (cycle.progress != null) {
256
+ console.log(` progress: ${Math.round(cycle.progress * 100)}%`);
257
+ }
258
+ } catch (error) {
259
+ handleApiError(error);
260
+ }
261
+ }
262
+
263
+ async function handleCreateCycle(input: CycleInput): Promise<void> {
264
+ if (!input.startsAt || !input.endsAt) {
265
+ exitWithError(
266
+ "--starts-at and --ends-at are required",
267
+ 'usage: lnr cycle new --team ENG --starts-at "2026-02-01" --ends-at "2026-02-14"'
268
+ );
269
+ }
270
+
271
+ try {
272
+ const client = getClient();
273
+
274
+ const team = await findTeamByKeyOrName(client, input.team);
275
+ if (!team) {
276
+ exitWithError(`team "${input.team}" not found`);
277
+ }
278
+
279
+ const cycle = await createCycle(client, {
280
+ teamId: team.id,
281
+ name: input.name,
282
+ description: input.description,
283
+ startsAt: input.startsAt,
284
+ endsAt: input.endsAt,
285
+ });
286
+
287
+ if (cycle) {
288
+ console.log(
289
+ `created cycle ${cycle.number}: ${cycle.name ?? `Cycle ${cycle.number}`}`
290
+ );
291
+ } else {
292
+ exitWithError("failed to create cycle");
293
+ }
294
+ } catch (error) {
295
+ handleApiError(error);
296
+ }
297
+ }
298
+
299
+ async function handleUpdateCycle(
300
+ nameOrNumber: string,
301
+ input: CycleInput
302
+ ): Promise<void> {
303
+ try {
304
+ const client = getClient();
305
+
306
+ const cycle = await getCycle(client, input.team, nameOrNumber);
307
+
308
+ if (!cycle) {
309
+ exitWithError(
310
+ `cycle "${nameOrNumber}" not found`,
311
+ `try: lnr cycles --team ${input.team}`,
312
+ EXIT_CODES.NOT_FOUND
313
+ );
314
+ }
315
+
316
+ const success = await updateCycle(client, cycle.id, {
317
+ name: input.name,
318
+ description: input.description,
319
+ startsAt: input.startsAt,
320
+ endsAt: input.endsAt,
321
+ });
322
+
323
+ if (!success) {
324
+ exitWithError(
325
+ `failed to update cycle "${nameOrNumber}"`,
326
+ undefined,
327
+ EXIT_CODES.NOT_FOUND
328
+ );
329
+ }
330
+
331
+ console.log(`updated cycle: ${cycle.name ?? `Cycle ${cycle.number}`}`);
332
+ } catch (error) {
333
+ handleApiError(error);
334
+ }
335
+ }
336
+
337
+ async function handleDeleteCycle(
338
+ nameOrNumber: string,
339
+ input: CycleInput
340
+ ): Promise<void> {
341
+ try {
342
+ const client = getClient();
343
+
344
+ const cycle = await getCycle(client, input.team, nameOrNumber);
345
+
346
+ if (!cycle) {
347
+ exitWithError(
348
+ `cycle "${nameOrNumber}" not found`,
349
+ `try: lnr cycles --team ${input.team}`,
350
+ EXIT_CODES.NOT_FOUND
351
+ );
352
+ }
353
+
354
+ const success = await deleteCycle(client, cycle.id);
355
+
356
+ if (!success) {
357
+ exitWithError(
358
+ `failed to archive cycle "${nameOrNumber}"`,
359
+ undefined,
360
+ EXIT_CODES.NOT_FOUND
361
+ );
362
+ }
363
+
364
+ console.log(`archived cycle: ${cycle.name ?? `Cycle ${cycle.number}`}`);
365
+ } catch (error) {
366
+ handleApiError(error);
367
+ }
368
+ }
34
369
 
35
370
  export const cyclesRouter = router({
36
371
  cycles: procedure
@@ -38,94 +373,36 @@ export const cyclesRouter = router({
38
373
  aliases: { command: ["c"] },
39
374
  description: "list cycles for a team",
40
375
  })
41
- .input(cyclesInput)
376
+ .input(listCyclesInput)
42
377
  .query(async ({ input }) => {
43
- try {
44
- const client = getClient();
45
- const cycles = await listCycles(client, input.team);
46
-
47
- if (cycles.length === 0) {
48
- exitWithError(`team "${input.team}" not found`);
49
- }
50
-
51
- const format = input.json ? "json" : input.quiet ? "quiet" : getOutputFormat(input);
52
-
53
- if (format === "json") {
54
- outputJson(cycles);
55
- return;
56
- }
57
-
58
- if (format === "quiet") {
59
- outputQuiet(cycles.map((c) => c.id));
60
- return;
61
- }
62
-
63
- outputTable(cycles, [
64
- { header: "#", value: (c) => String(c.number), width: 4 },
65
- { header: "NAME", value: (c) => c.name ?? `Cycle ${c.number}`, width: 20 },
66
- { header: "START", value: (c) => formatDate(c.startsAt), width: 12 },
67
- { header: "END", value: (c) => formatDate(c.endsAt), width: 12 },
68
- ], input);
69
- } catch (error) {
70
- handleApiError(error);
71
- }
378
+ await handleListCycles(input);
72
379
  }),
73
380
 
74
381
  cycle: procedure
75
382
  .meta({
76
- description: "show cycle details",
383
+ description: "show, create, update, or archive a cycle",
77
384
  })
78
385
  .input(cycleInput)
79
- .query(async ({ input }) => {
80
- try {
81
- if (!input.current) {
82
- exitWithError("cycle identifier required", "use --current to show active cycle");
83
- }
84
-
85
- const client = getClient();
86
- const cycle = await getCurrentCycle(client, input.team);
87
-
88
- if (!cycle) {
89
- exitWithError("no active cycle", `team "${input.team}" has no current cycle`);
90
- }
91
-
92
- const format = input.json ? "json" : input.quiet ? "quiet" : getOutputFormat(input);
93
-
94
- if (input.issues) {
95
- const issues = await getCycleIssues(client, input.team);
96
-
97
- if (format === "json") {
98
- outputJson(issues);
99
- return;
100
- }
101
-
102
- if (format === "quiet") {
103
- outputQuiet(issues.map((i) => i.identifier));
104
- return;
105
- }
106
-
107
- outputTable(issues, [
108
- { header: "ID", value: (i) => i.identifier, width: 10 },
109
- { header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
110
- ], input);
111
- return;
112
- }
113
-
114
- if (format === "json") {
115
- outputJson(cycle);
116
- return;
117
- }
118
-
119
- if (format === "quiet") {
120
- console.log(cycle.id);
121
- return;
122
- }
123
-
124
- console.log(`cycle ${cycle.number}: ${cycle.name ?? `Cycle ${cycle.number}`}`);
125
- console.log(` start: ${formatDate(cycle.startsAt)}`);
126
- console.log(` end: ${formatDate(cycle.endsAt)}`);
127
- } catch (error) {
128
- handleApiError(error);
386
+ .mutation(async ({ input }) => {
387
+ const operation = inferOperation(input);
388
+
389
+ switch (operation) {
390
+ case "current":
391
+ await handleCurrentCycle(input);
392
+ break;
393
+ case "create":
394
+ await handleCreateCycle(input);
395
+ break;
396
+ case "delete":
397
+ await handleDeleteCycle(input.nameOrNumber, input);
398
+ break;
399
+ case "update":
400
+ await handleUpdateCycle(input.nameOrNumber, input);
401
+ break;
402
+ case "read":
403
+ default:
404
+ await handleShowCycle(input.nameOrNumber, input);
405
+ break;
129
406
  }
130
407
  }),
131
408
  });