@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Template command (read-only).
3
+ * Not generated by codegen — templates use a simpler pattern.
4
+ */
5
+
6
+ import "../lib/arktype-config";
7
+ import { type } from "arktype";
8
+ import {
9
+ getClient,
10
+ listTemplates,
11
+ getTemplate,
12
+ getIssueTemplates,
13
+ type Template,
14
+ } from "@bdsqqq/lnr-core";
15
+ import { router, procedure } from "../router/trpc";
16
+ import { handleApiError, exitWithError, EXIT_CODES } from "../lib/error";
17
+ import {
18
+ outputJson,
19
+ outputQuiet,
20
+ outputTable,
21
+ getOutputFormat,
22
+ truncate,
23
+ type OutputOptions,
24
+ type TableColumn,
25
+ } from "../lib/output";
26
+
27
+ export const listTemplatesInput = type({
28
+ "team?": type("string").describe("filter by team key"),
29
+ "type?": type("string").describe("filter by template type (issue, project)"),
30
+ "json?": type("boolean").describe("output as json"),
31
+ "quiet?": type("boolean").describe("output ids only"),
32
+ "verbose?": type("boolean").describe("show all columns"),
33
+ });
34
+
35
+ export const templateInput = type({
36
+ name: type("string").configure({ positional: true }).describe("template name or id"),
37
+ "team?": type("string").describe("team key to scope template lookup"),
38
+ "json?": type("boolean").describe("output as json"),
39
+ "quiet?": type("boolean").describe("output ids only"),
40
+ "verbose?": type("boolean").describe("show all columns"),
41
+ });
42
+
43
+ type TemplateInput = typeof templateInput.infer;
44
+
45
+ const templateColumns: TableColumn<Template>[] = [
46
+ { header: "NAME", value: (t) => truncate(t.name, 30), width: 30 },
47
+ { header: "TYPE", value: (t) => t.type, width: 10 },
48
+ { header: "TEAM", value: (t) => t.teamKey ?? "workspace", width: 10 },
49
+ ];
50
+
51
+ const verboseTemplateColumns: TableColumn<Template>[] = [
52
+ ...templateColumns,
53
+ { header: "DESCRIPTION", value: (t) => truncate(t.description ?? "-", 40), width: 40 },
54
+ { header: "ID", value: (t) => t.id, width: 36 },
55
+ ];
56
+
57
+ async function handleListTemplates(
58
+ input: typeof listTemplatesInput.infer
59
+ ): Promise<void> {
60
+ try {
61
+ const client = getClient();
62
+
63
+ const outputOpts: OutputOptions = {
64
+ format: input.json ? "json" : input.quiet ? "quiet" : undefined,
65
+ verbose: input.verbose,
66
+ };
67
+ const format = getOutputFormat(outputOpts);
68
+
69
+ let templates = await listTemplates(client, input.team);
70
+
71
+ if (input.type) {
72
+ templates = templates.filter(
73
+ (t) => t.type.toLowerCase() === input.type!.toLowerCase()
74
+ );
75
+ }
76
+
77
+ if (format === "json") {
78
+ outputJson(templates);
79
+ return;
80
+ }
81
+
82
+ if (format === "quiet") {
83
+ outputQuiet(templates.map((t) => t.id));
84
+ return;
85
+ }
86
+
87
+ const columns = input.verbose ? verboseTemplateColumns : templateColumns;
88
+ outputTable(templates, columns, outputOpts);
89
+ } catch (error) {
90
+ handleApiError(error);
91
+ }
92
+ }
93
+
94
+ async function handleShowTemplate(
95
+ name: string,
96
+ input: TemplateInput
97
+ ): Promise<void> {
98
+ try {
99
+ const client = getClient();
100
+
101
+ const outputOpts: OutputOptions = {
102
+ format: input.json ? "json" : input.quiet ? "quiet" : undefined,
103
+ verbose: input.verbose,
104
+ };
105
+ const format = getOutputFormat(outputOpts);
106
+
107
+ const template = await getTemplate(client, name, input.team);
108
+
109
+ if (!template) {
110
+ exitWithError(
111
+ `template "${name}" not found`,
112
+ input.team ? undefined : "try: lnr templates --team <key>",
113
+ EXIT_CODES.NOT_FOUND
114
+ );
115
+ }
116
+
117
+ if (format === "json") {
118
+ outputJson(template);
119
+ return;
120
+ }
121
+
122
+ if (format === "quiet") {
123
+ console.log(template.id);
124
+ return;
125
+ }
126
+
127
+ console.log(`${template.name}`);
128
+ console.log(`type: ${template.type}`);
129
+ console.log(`team: ${template.teamKey ?? "workspace"}`);
130
+ if (template.description) {
131
+ console.log(`description: ${template.description}`);
132
+ }
133
+ console.log(`id: ${template.id}`);
134
+ } catch (error) {
135
+ handleApiError(error);
136
+ }
137
+ }
138
+
139
+ export const generatedTemplatesRouter = router({
140
+ templates: procedure
141
+ .meta({
142
+ description: "list templates",
143
+ })
144
+ .input(listTemplatesInput)
145
+ .query(async ({ input }) => {
146
+ await handleListTemplates(input);
147
+ }),
148
+
149
+ template: procedure
150
+ .meta({
151
+ description: "show a template",
152
+ })
153
+ .input(templateInput)
154
+ .query(async ({ input }) => {
155
+ await handleShowTemplate(input.name, input);
156
+ }),
157
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Hand-crafted handlers for CLI-only issue features.
3
+ * These are not backed by Linear's schema — they're pure CLI UX.
4
+ *
5
+ * The generator imports these and dispatches to them.
6
+ * Edit freely; regeneration won't touch this file.
7
+ */
8
+
9
+ import { linkGitHubPR, type Issue } from "@bdsqqq/lnr-core";
10
+ import { exitWithError } from "../lib/error";
11
+
12
+ type Client = Parameters<typeof linkGitHubPR>[0];
13
+
14
+ /**
15
+ * --pr: link a GitHub PR URL to the issue
16
+ */
17
+ export async function handlePr(
18
+ client: Client,
19
+ issue: Issue,
20
+ prUrl: string
21
+ ): Promise<void> {
22
+ const success = await linkGitHubPR(client, issue.id, prUrl);
23
+ if (!success) {
24
+ exitWithError(`failed to link pr ${prUrl}`);
25
+ }
26
+ console.log(`linked pr ${prUrl} to ${issue.identifier}`);
27
+ }
@@ -0,0 +1,14 @@
1
+ import type { Document } from "@bdsqqq/lnr-core";
2
+ import type { DetailSection } from "../renderers/detail";
3
+
4
+ export function docToDetail(doc: Document): DetailSection[] {
5
+ const sections: DetailSection[] = [
6
+ { type: "header", title: doc.title },
7
+ ];
8
+
9
+ if (doc.content) {
10
+ sections.push({ type: "text", body: doc.content });
11
+ }
12
+
13
+ return sections;
14
+ }
@@ -0,0 +1,4 @@
1
+ export { issueToDetail } from "./issue";
2
+ export { projectToDetail } from "./project";
3
+ export { labelToDetail } from "./label";
4
+ export { docToDetail } from "./doc";
@@ -0,0 +1,32 @@
1
+ import type { Issue } from "@bdsqqq/lnr-core";
2
+ import { formatDate, formatPriority } from "../output";
3
+ import type { DetailSection } from "../renderers/detail";
4
+
5
+ export function issueToDetail(issue: Issue): DetailSection[] {
6
+ const fields: { label: string; value: string }[] = [
7
+ { label: "state", value: issue.state ?? "-" },
8
+ { label: "assignee", value: issue.assignee ?? "-" },
9
+ { label: "priority", value: formatPriority(issue.priority) },
10
+ ];
11
+
12
+ if (issue.parentId) {
13
+ fields.push({ label: "parent", value: issue.parentId });
14
+ }
15
+
16
+ fields.push(
17
+ { label: "created", value: formatDate(issue.createdAt) },
18
+ { label: "updated", value: formatDate(issue.updatedAt) },
19
+ { label: "url", value: issue.url },
20
+ );
21
+
22
+ const sections: DetailSection[] = [
23
+ { type: "header", title: `${issue.identifier}: ${issue.title}` },
24
+ { type: "fields", fields },
25
+ ];
26
+
27
+ if (issue.description) {
28
+ sections.push({ type: "text", body: issue.description });
29
+ }
30
+
31
+ return sections;
32
+ }
@@ -0,0 +1,20 @@
1
+ import type { Label } from "@bdsqqq/lnr-core";
2
+ import type { DetailSection } from "../renderers/detail";
3
+
4
+ export function labelToDetail(label: Label): DetailSection[] {
5
+ const fields: { label: string; value: string }[] = [
6
+ { label: "id", value: label.id },
7
+ { label: "color", value: label.color ?? "-" },
8
+ ];
9
+
10
+ const sections: DetailSection[] = [
11
+ { type: "header", title: label.name },
12
+ { type: "fields", fields },
13
+ ];
14
+
15
+ if (label.description) {
16
+ sections.push({ type: "text", body: label.description });
17
+ }
18
+
19
+ return sections;
20
+ }
@@ -0,0 +1,23 @@
1
+ import type { Project } from "@bdsqqq/lnr-core";
2
+ import { formatDate } from "../output";
3
+ import type { DetailSection } from "../renderers/detail";
4
+
5
+ export function projectToDetail(project: Project): DetailSection[] {
6
+ const header: DetailSection = project.description
7
+ ? { type: "header", title: project.name, subtitle: project.description }
8
+ : { type: "header", title: project.name };
9
+
10
+ const sections: DetailSection[] = [header];
11
+
12
+ const fields: { label: string; value: string }[] = [
13
+ { label: "state", value: project.state ?? "-" },
14
+ { label: "progress", value: `${Math.round((project.progress ?? 0) * 100)}%` },
15
+ { label: "target", value: formatDate(project.targetDate) },
16
+ { label: "started", value: formatDate(project.startDate) },
17
+ { label: "created", value: formatDate(project.createdAt) },
18
+ ];
19
+
20
+ sections.push({ type: "fields", fields });
21
+
22
+ return sections;
23
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * arktype configuration for CLI schemas.
3
+ *
4
+ * extends ArkEnv.meta to support trpc-cli metadata like `positional`.
5
+ * MUST be imported before any arktype usage in generated files.
6
+ *
7
+ * see: https://arktype.io/docs/configuration
8
+ */
9
+
10
+ declare global {
11
+ interface ArkEnv {
12
+ meta(): {
13
+ positional?: boolean;
14
+ };
15
+ }
16
+ }
17
+
18
+ export {};
@@ -0,0 +1,97 @@
1
+ import "./arktype-config";
2
+ import { appRouter } from "../router/index";
3
+
4
+ export interface Flag {
5
+ name: string;
6
+ type: string;
7
+ description: string;
8
+ positional: boolean;
9
+ required: boolean;
10
+ }
11
+
12
+ export interface Command {
13
+ name: string;
14
+ description: string;
15
+ aliases: string[];
16
+ flags: Flag[];
17
+ }
18
+
19
+ export type SchemaJson = {
20
+ domain: string;
21
+ required?: { key: string; value: SchemaValue }[];
22
+ optional?: { key: string; value: SchemaValue }[];
23
+ };
24
+
25
+ export type SchemaValue = {
26
+ domain?: string;
27
+ meta?: string | { description?: string; positional?: boolean };
28
+ branches?: { unit?: unknown; meta?: string | { description?: string; positional?: boolean } }[];
29
+ unit?: unknown;
30
+ };
31
+
32
+ export const extractMeta = (value: SchemaValue): { description: string; positional: boolean } => {
33
+ const meta = value.meta ?? value.branches?.[0]?.meta;
34
+ return typeof meta === "object"
35
+ ? { description: meta.description ?? "", positional: meta.positional ?? false }
36
+ : { description: meta ?? "", positional: false };
37
+ };
38
+
39
+ export const inferType = (value: SchemaValue): string => {
40
+ if (value.domain) return value.domain === "number" ? "number" : "string";
41
+ if (value.branches?.some((b) => b.unit === true || b.unit === false)) return "boolean";
42
+ if (value.unit !== undefined) return typeof value.unit;
43
+ return "string";
44
+ };
45
+
46
+ export const parseSchema = (json: SchemaJson): Flag[] => [
47
+ ...(json.required ?? []).map(({ key, value }) => ({
48
+ name: key,
49
+ type: inferType(value),
50
+ ...extractMeta(value),
51
+ required: true,
52
+ })),
53
+ ...(json.optional ?? []).map(({ key, value }) => ({
54
+ name: key,
55
+ type: inferType(value),
56
+ ...extractMeta(value),
57
+ required: false,
58
+ })),
59
+ ];
60
+
61
+ export const camelToKebab = (s: string): string =>
62
+ s.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
63
+
64
+ interface ProcedureDef {
65
+ _def: {
66
+ meta?: { description?: string; aliases?: { command?: string[] } };
67
+ inputs?: { json: SchemaJson }[];
68
+ };
69
+ }
70
+
71
+ function isProcedureDef(value: unknown): value is ProcedureDef {
72
+ return (
73
+ (typeof value === "object" || typeof value === "function") &&
74
+ value !== null &&
75
+ "_def" in value &&
76
+ typeof (value as Record<string, unknown>)._def === "object"
77
+ );
78
+ }
79
+
80
+ export const buildCommands = (): Command[] => {
81
+ const procedures = appRouter._def.procedures as Record<string, unknown>;
82
+
83
+ return Object.entries(procedures)
84
+ .filter((entry): entry is [string, ProcedureDef] => isProcedureDef(entry[1]))
85
+ .map(([name, proc]) => {
86
+ const meta = proc._def.meta ?? {};
87
+ const schema = proc._def.inputs?.[0]?.json;
88
+ const flags = schema ? parseSchema(schema) : [];
89
+
90
+ return {
91
+ name: name.replace(/\./g, " "),
92
+ description: meta.description ?? "",
93
+ aliases: meta.aliases?.command ?? [],
94
+ flags,
95
+ };
96
+ });
97
+ };
@@ -0,0 +1,297 @@
1
+ import { test, expect, mock, describe, beforeEach } from "bun:test";
2
+
3
+ const mockUpdateIssue = mock(async () => true);
4
+ const mockGetIssue = mock(
5
+ async () =>
6
+ ({
7
+ id: "I1",
8
+ identifier: "ENG-1",
9
+ title: "test",
10
+ url: "https://linear.app/test",
11
+ branchName: "eng-1-test",
12
+ priority: 3,
13
+ state: "Todo",
14
+ assignee: "alice",
15
+ createdAt: new Date(),
16
+ updatedAt: new Date(),
17
+ }) as const
18
+ );
19
+ const mockClientLabels = mock(async () => ({ nodes: [] }));
20
+ const mockClientIssue = mock(async () => ({
21
+ team: Promise.resolve({ id: "T1" }),
22
+ labels: mockClientLabels,
23
+ }));
24
+ const mockGetClient = mock(() => ({
25
+ issue: mockClientIssue,
26
+ viewer: Promise.resolve({ id: "U1" }),
27
+ users: mock(async () => ({
28
+ nodes: [{ id: "U2", email: "bob@example.com" }],
29
+ })),
30
+ }));
31
+ const mockListIssues = mock(async () => [
32
+ {
33
+ id: "I1",
34
+ identifier: "ENG-1",
35
+ title: "test",
36
+ state: "Todo",
37
+ assignee: "alice",
38
+ priority: 3,
39
+ },
40
+ {
41
+ id: "I2",
42
+ identifier: "ENG-2",
43
+ title: "test2",
44
+ state: "Done",
45
+ assignee: "bob",
46
+ priority: 1,
47
+ },
48
+ ]);
49
+ const mockPriorityFromString = mock((s: string) => (s === "high" ? 2 : 0));
50
+ const mockGetTeamStates = mock(async () => [
51
+ { id: "S1", name: "Done" },
52
+ { id: "S2", name: "Todo" },
53
+ ]);
54
+ const mockGetTeamLabels = mock(async () => [
55
+ { id: "L1", name: "bug" },
56
+ { id: "L2", name: "feature" },
57
+ ]);
58
+
59
+ const mockOutputJson = mock((..._args: unknown[]) => {});
60
+ const mockOutputQuiet = mock((..._args: unknown[]) => {});
61
+ const mockOutputTable = mock((..._args: unknown[]) => {});
62
+ const mockGetOutputFormat = mock(
63
+ (opts: { format?: string }) => opts?.format ?? "table"
64
+ );
65
+
66
+ mock.module("@bdsqqq/lnr-core", () => ({
67
+ getClient: mockGetClient,
68
+ getIssue: mockGetIssue,
69
+ updateIssue: mockUpdateIssue,
70
+ listIssues: mockListIssues,
71
+ createIssue: mock(async () => ({
72
+ id: "I3",
73
+ identifier: "ENG-3",
74
+ title: "new",
75
+ })),
76
+ archiveIssue: mock(async () => true),
77
+ findTeamByKeyOrName: mock(async () => ({ id: "T1", key: "ENG" })),
78
+ getAvailableTeamKeys: mock(async () => ["ENG"]),
79
+ getTeamLabels: mockGetTeamLabels,
80
+ resolveAssignee: mock(async () => "U2"),
81
+ priorityFromString: mockPriorityFromString,
82
+ resolveStateName: mock(async () => "S1"),
83
+ resolveIssueIdentifier: mock(async () => "I1"),
84
+ resolveProjectByName: mock(async () => "P1"),
85
+ resolveCycleByName: mock(async () => "C1"),
86
+ resolveMilestoneByName: mock(async () => "M1"),
87
+ createIssueRelation: mock(async () => true),
88
+ addComment: mock(async () => true),
89
+ updateComment: mock(async () => true),
90
+ replyToComment: mock(async () => true),
91
+ deleteComment: mock(async () => true),
92
+ createCommentReaction: mock(async () => true),
93
+ deleteReaction: mock(async () => true),
94
+ getIssueComments: mock(async () => ({ comments: [], error: null })),
95
+ getSubIssues: mock(async () => []),
96
+ getTeamStates: mockGetTeamStates,
97
+ subscribeToIssue: mock(async () => true),
98
+ unsubscribeFromIssue: mock(async () => true),
99
+ createReaction: mock(async () => true),
100
+ batchUpdateIssues: mock(async () => ({ success: true, issues: [] })),
101
+ getConfigValue: mock(() => undefined),
102
+ linkGitHubPR: mock(async () => true),
103
+ }));
104
+
105
+ mock.module("./output", () => ({
106
+ outputJson: mockOutputJson,
107
+ outputQuiet: mockOutputQuiet,
108
+ outputTable: mockOutputTable,
109
+ getOutputFormat: mockGetOutputFormat,
110
+ formatDate: (d: unknown) => (d ? "2026-01-01" : "-"),
111
+ formatPriority: (p: unknown) => String(p ?? "-"),
112
+ truncate: (s: string) => s,
113
+ formatRelativeTime: () => "1d ago",
114
+ }));
115
+
116
+ mock.module("./error", () => ({
117
+ handleApiError: mock((e: unknown) => {
118
+ throw e;
119
+ }),
120
+ exitWithError: mock((msg: string) => {
121
+ throw new Error(msg);
122
+ }),
123
+ EXIT_CODES: { SUCCESS: 0, GENERAL_ERROR: 1, AUTH_ERROR: 2, NOT_FOUND: 3, RATE_LIMITED: 4, PLAN_REQUIRED: 5 },
124
+ }));
125
+
126
+ mock.module("./renderers/comments", () => ({
127
+ outputCommentThreads: mock(() => {}),
128
+ shortcodeToEmoji: mock((s: string) => s),
129
+ formatReactions: mock(() => ""),
130
+ wrapText: mock((s: string) => [s]),
131
+ buildChildMap: mock(() => new Map()),
132
+ }));
133
+
134
+ mock.module("./renderers/detail", () => ({
135
+ outputDetail: mock(() => {}),
136
+ }));
137
+
138
+ mock.module("./adapters", () => ({
139
+ issueToDetail: mock(() => ({})),
140
+ }));
141
+
142
+ mock.module("../../hand-crafted/issue", () => ({
143
+ handlePr: mock(async () => {}),
144
+ }));
145
+
146
+ mock.module("./arktype-config", () => ({}));
147
+
148
+ const { generatedIssuesRouter } = await import("../generated/issue");
149
+
150
+ function resetAll() {
151
+ mockUpdateIssue.mockClear();
152
+ mockGetIssue.mockClear();
153
+ mockGetClient.mockClear();
154
+ mockClientIssue.mockClear();
155
+ mockListIssues.mockClear();
156
+ mockPriorityFromString.mockClear();
157
+ mockGetTeamStates.mockClear();
158
+ mockGetTeamLabels.mockClear();
159
+ mockOutputJson.mockClear();
160
+ mockOutputQuiet.mockClear();
161
+ mockOutputTable.mockClear();
162
+ mockGetOutputFormat.mockClear();
163
+ }
164
+
165
+ describe("property D: flag → payload effect (issue update)", () => {
166
+ beforeEach(resetAll);
167
+
168
+ test("--title sets title in updateIssue payload", async () => {
169
+ const caller = generatedIssuesRouter.createCaller({});
170
+ await caller.issue({ idOrNew: "ENG-1", title: "new title" });
171
+
172
+ expect(mockUpdateIssue).toHaveBeenCalled();
173
+ const payload = (mockUpdateIssue.mock.calls[0] as unknown as unknown[])[2] as Record<string, unknown>;
174
+ expect(payload.title).toBe("new title");
175
+ });
176
+
177
+ test("--description sets description in updateIssue payload", async () => {
178
+ const caller = generatedIssuesRouter.createCaller({});
179
+ await caller.issue({ idOrNew: "ENG-1", description: "some desc" });
180
+
181
+ expect(mockUpdateIssue).toHaveBeenCalled();
182
+ const payload = (mockUpdateIssue.mock.calls[0] as unknown as unknown[])[2] as Record<string, unknown>;
183
+ expect(payload.description).toBe("some desc");
184
+ });
185
+
186
+ test("--priority passes numeric priority to updateIssue", async () => {
187
+ const caller = generatedIssuesRouter.createCaller({});
188
+ await caller.issue({ idOrNew: "ENG-1", priority: "high" });
189
+
190
+ expect(mockUpdateIssue).toHaveBeenCalled();
191
+ const payload = (mockUpdateIssue.mock.calls[0] as unknown as unknown[])[2] as Record<string, unknown>;
192
+ expect(payload.priority).toBe(2);
193
+ });
194
+
195
+ test("--state resolves to stateId in updateIssue payload", async () => {
196
+ const caller = generatedIssuesRouter.createCaller({});
197
+ await caller.issue({ idOrNew: "ENG-1", state: "Done" });
198
+
199
+ expect(mockUpdateIssue).toHaveBeenCalled();
200
+ const payload = (mockUpdateIssue.mock.calls[0] as unknown as unknown[])[2] as Record<string, unknown>;
201
+ expect(payload.stateId).toBe("S1");
202
+ });
203
+ });
204
+
205
+ describe("property E: resolver call discipline (issue update)", () => {
206
+ beforeEach(resetAll);
207
+
208
+ test("--state calls getTeamStates, not getTeamLabels", async () => {
209
+ const caller = generatedIssuesRouter.createCaller({});
210
+ await caller.issue({ idOrNew: "ENG-1", state: "Done" });
211
+
212
+ expect(mockGetTeamStates).toHaveBeenCalled();
213
+ expect(mockGetTeamLabels).not.toHaveBeenCalled();
214
+ });
215
+
216
+ test("--label calls getTeamLabels, not getTeamStates", async () => {
217
+ const caller = generatedIssuesRouter.createCaller({});
218
+ await caller.issue({ idOrNew: "ENG-1", label: "bug" });
219
+
220
+ expect(mockGetTeamLabels).toHaveBeenCalled();
221
+ expect(mockGetTeamStates).not.toHaveBeenCalled();
222
+ });
223
+
224
+ test("--priority calls priorityFromString, not getTeamStates", async () => {
225
+ const caller = generatedIssuesRouter.createCaller({});
226
+ await caller.issue({ idOrNew: "ENG-1", priority: "high" });
227
+
228
+ expect(mockPriorityFromString).toHaveBeenCalledWith("high");
229
+ expect(mockGetTeamStates).not.toHaveBeenCalled();
230
+ expect(mockGetTeamLabels).not.toHaveBeenCalled();
231
+ });
232
+
233
+ test("no mutation flags → read path, no resolvers called", async () => {
234
+ const caller = generatedIssuesRouter.createCaller({});
235
+ await caller.issue({ idOrNew: "ENG-1" });
236
+
237
+ expect(mockUpdateIssue).not.toHaveBeenCalled();
238
+ expect(mockGetTeamStates).not.toHaveBeenCalled();
239
+ expect(mockGetTeamLabels).not.toHaveBeenCalled();
240
+ expect(mockPriorityFromString).not.toHaveBeenCalled();
241
+ });
242
+ });
243
+
244
+ describe("property F: output-format equivalence (issue list)", () => {
245
+ beforeEach(resetAll);
246
+
247
+ test("json/quiet/table all receive same entity count", async () => {
248
+ const caller = generatedIssuesRouter.createCaller({});
249
+
250
+ await caller.issues({ json: true });
251
+ const jsonEntities = mockOutputJson.mock.calls[0]?.[0] as unknown[];
252
+
253
+ await caller.issues({ quiet: true });
254
+ const quietIds = mockOutputQuiet.mock.calls[0]?.[0] as string[];
255
+
256
+ await caller.issues({});
257
+ const tableEntities = mockOutputTable.mock.calls[0]?.[0] as unknown[];
258
+
259
+ expect(jsonEntities.length).toBe(2);
260
+ expect(quietIds.length).toBe(2);
261
+ expect(tableEntities.length).toBe(2);
262
+ });
263
+
264
+ test("json and table receive identical entity set", async () => {
265
+ const caller = generatedIssuesRouter.createCaller({});
266
+
267
+ await caller.issues({ json: true });
268
+ const jsonEntities = mockOutputJson.mock.calls[0]?.[0] as {
269
+ identifier: string;
270
+ }[];
271
+
272
+ mockOutputTable.mockClear();
273
+ await caller.issues({});
274
+ const tableEntities = mockOutputTable.mock.calls[0]?.[0] as {
275
+ identifier: string;
276
+ }[];
277
+
278
+ expect(jsonEntities.map((e) => e.identifier)).toEqual(
279
+ tableEntities.map((e) => e.identifier)
280
+ );
281
+ });
282
+
283
+ test("quiet receives identifiers matching json entities", async () => {
284
+ const caller = generatedIssuesRouter.createCaller({});
285
+
286
+ await caller.issues({ json: true });
287
+ const jsonEntities = mockOutputJson.mock.calls[0]?.[0] as {
288
+ identifier: string;
289
+ }[];
290
+
291
+ mockOutputQuiet.mockClear();
292
+ await caller.issues({ quiet: true });
293
+ const quietIds = mockOutputQuiet.mock.calls[0]?.[0] as string[];
294
+
295
+ expect(quietIds).toEqual(jsonEntities.map((e) => e.identifier));
296
+ });
297
+ });