@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.
- 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 -606
- package/src/router/labels.ts +0 -192
- package/src/router/projects.ts +0 -220
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bdsqqq/lnr-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "cli for linear issue tracking",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -29,8 +29,7 @@
|
|
|
29
29
|
"@trpc/server": "^11.8.1",
|
|
30
30
|
"chalk": "^5.6.2",
|
|
31
31
|
"commander": "^14.0.2",
|
|
32
|
-
"trpc-cli": "^0.12.2"
|
|
33
|
-
"zod": "^4.3.5"
|
|
32
|
+
"trpc-cli": "^0.12.2"
|
|
34
33
|
},
|
|
35
34
|
"peerDependencies": {
|
|
36
35
|
"typescript": "^5"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* measures per-call cost breakdown of lnr e2e subprocess invocations.
|
|
5
|
+
*
|
|
6
|
+
* the e2e tests spawn `bun run dev -- <args>` for every assertion (~50 calls).
|
|
7
|
+
* this script isolates where time goes: bun startup, module loading, API latency.
|
|
8
|
+
* use it to evaluate whether optimization ideas (compiling, parallelizing, calling
|
|
9
|
+
* core directly) are worth the complexity.
|
|
10
|
+
*
|
|
11
|
+
* run: bun run packages/cli/src/bench-lnr-overhead.ts
|
|
12
|
+
* needs: LINEAR_API_KEY set (for API-hitting measurements)
|
|
13
|
+
*
|
|
14
|
+
* baseline results (2026-02-07, macOS arm64, bun 1.3.5):
|
|
15
|
+
*
|
|
16
|
+
* bare subprocess (bun --version): 5ms
|
|
17
|
+
* bun run dev -- --help (no API): 302ms ← module loading dominates
|
|
18
|
+
* compiled binary --help (no API): 263ms ← only 38ms faster
|
|
19
|
+
* bun run dev -- me (with API): 629ms
|
|
20
|
+
* compiled binary me (with API): 533ms
|
|
21
|
+
*
|
|
22
|
+
* API latency (derived): ~300ms ← irreducible
|
|
23
|
+
* savings from compiling (40 calls): ~1.5s ← not worth it
|
|
24
|
+
*
|
|
25
|
+
* CI end-to-end (2026-02-07, ubuntu github actions runner):
|
|
26
|
+
* readonly tests: 6s (10 tests)
|
|
27
|
+
* mutation tests: 42s (32 tests, 40 subprocess calls)
|
|
28
|
+
* per-test mean: ~1.3s (range: 575ms to 4000ms)
|
|
29
|
+
*
|
|
30
|
+
* conclusion: the bottleneck is sequential API round-trips (~300ms each
|
|
31
|
+
* on CI), not subprocess startup. parallelizing independent test groups
|
|
32
|
+
* is the only approach that meaningfully reduces total time.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const ITERATIONS = 5;
|
|
36
|
+
const cliDir = import.meta.dir + "/../..";
|
|
37
|
+
const cliPkgDir = import.meta.dir + "/..";
|
|
38
|
+
const BUN = process.execPath;
|
|
39
|
+
|
|
40
|
+
interface TimingResult {
|
|
41
|
+
label: string;
|
|
42
|
+
times: number[];
|
|
43
|
+
mean: number;
|
|
44
|
+
min: number;
|
|
45
|
+
max: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stats(label: string, times: number[]): TimingResult {
|
|
49
|
+
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
|
50
|
+
return { label, times, mean, min: Math.min(...times), max: Math.max(...times) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function report(r: TimingResult) {
|
|
54
|
+
console.log(` ${r.label}: mean=${r.mean.toFixed(0)}ms min=${r.min.toFixed(0)}ms max=${r.max.toFixed(0)}ms (n=${r.times.length})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function timeLnrDev(...args: string[]): Promise<number> {
|
|
58
|
+
const start = performance.now();
|
|
59
|
+
const proc = Bun.spawn([BUN, "run", "dev", "--", ...args], {
|
|
60
|
+
cwd: cliDir,
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
await new Response(proc.stdout).text();
|
|
65
|
+
await proc.exited;
|
|
66
|
+
return performance.now() - start;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function timeLnrCompiled(...args: string[]): Promise<number> {
|
|
70
|
+
const start = performance.now();
|
|
71
|
+
const proc = Bun.spawn(["/tmp/lnr-bench", ...args], {
|
|
72
|
+
cwd: cliDir,
|
|
73
|
+
stdout: "pipe",
|
|
74
|
+
stderr: "pipe",
|
|
75
|
+
});
|
|
76
|
+
await new Response(proc.stdout).text();
|
|
77
|
+
await proc.exited;
|
|
78
|
+
return performance.now() - start;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function timeBareSubprocess(): Promise<number> {
|
|
82
|
+
const start = performance.now();
|
|
83
|
+
const proc = Bun.spawn([BUN, "--version"], {
|
|
84
|
+
stdout: "pipe",
|
|
85
|
+
stderr: "pipe",
|
|
86
|
+
});
|
|
87
|
+
await new Response(proc.stdout).text();
|
|
88
|
+
await proc.exited;
|
|
89
|
+
return performance.now() - start;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- run ---
|
|
93
|
+
|
|
94
|
+
console.log("=== lnr subprocess overhead benchmark ===\n");
|
|
95
|
+
|
|
96
|
+
console.log("1. bare `bun --version` (subprocess overhead floor)");
|
|
97
|
+
const bareTimes: number[] = [];
|
|
98
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
99
|
+
bareTimes.push(await timeBareSubprocess());
|
|
100
|
+
}
|
|
101
|
+
report(stats("bun --version", bareTimes));
|
|
102
|
+
|
|
103
|
+
console.log("\n2. `bun run dev -- --help` (startup + module loading, no API)");
|
|
104
|
+
const helpTimes: number[] = [];
|
|
105
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
106
|
+
helpTimes.push(await timeLnrDev("--help"));
|
|
107
|
+
}
|
|
108
|
+
report(stats("lnr --help (dev)", helpTimes));
|
|
109
|
+
|
|
110
|
+
console.log("\n3. compiling binary...");
|
|
111
|
+
const compileStart = performance.now();
|
|
112
|
+
const compileProc = Bun.spawn([BUN, "build", "./src/cli.ts", "--compile", "--outfile", "/tmp/lnr-bench"], {
|
|
113
|
+
cwd: cliPkgDir,
|
|
114
|
+
stdout: "pipe",
|
|
115
|
+
stderr: "pipe",
|
|
116
|
+
});
|
|
117
|
+
const compileStderr = await new Response(compileProc.stderr).text();
|
|
118
|
+
const compileExit = await compileProc.exited;
|
|
119
|
+
console.log(` compiled in ${(performance.now() - compileStart).toFixed(0)}ms (exit: ${compileExit})`);
|
|
120
|
+
if (compileExit !== 0) {
|
|
121
|
+
console.log(` compile failed: ${compileStderr}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log("\n4. `/tmp/lnr-bench --help` (compiled binary, no API)");
|
|
126
|
+
const compiledHelpTimes: number[] = [];
|
|
127
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
128
|
+
compiledHelpTimes.push(await timeLnrCompiled("--help"));
|
|
129
|
+
}
|
|
130
|
+
report(stats("lnr --help (compiled)", compiledHelpTimes));
|
|
131
|
+
|
|
132
|
+
console.log("\n5. `bun run dev -- me` (dev mode, hits Linear API)");
|
|
133
|
+
const meTimes: number[] = [];
|
|
134
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
135
|
+
meTimes.push(await timeLnrDev("me"));
|
|
136
|
+
}
|
|
137
|
+
report(stats("lnr me (dev)", meTimes));
|
|
138
|
+
|
|
139
|
+
console.log("\n6. `/tmp/lnr-bench me` (compiled, hits Linear API)");
|
|
140
|
+
const meCompiledTimes: number[] = [];
|
|
141
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
142
|
+
meCompiledTimes.push(await timeLnrCompiled("me"));
|
|
143
|
+
}
|
|
144
|
+
report(stats("lnr me (compiled)", meCompiledTimes));
|
|
145
|
+
|
|
146
|
+
const devOverhead = stats("lnr --help (dev)", helpTimes).mean;
|
|
147
|
+
const devWithApi = stats("lnr me (dev)", meTimes).mean;
|
|
148
|
+
const compiledOverhead = stats("lnr --help (compiled)", compiledHelpTimes).mean;
|
|
149
|
+
const compiledWithApi = stats("lnr me (compiled)", meCompiledTimes).mean;
|
|
150
|
+
|
|
151
|
+
console.log("\n=== derived ===");
|
|
152
|
+
console.log(` dev startup overhead (no API): ${devOverhead.toFixed(0)}ms`);
|
|
153
|
+
console.log(` compiled startup overhead (no API): ${compiledOverhead.toFixed(0)}ms`);
|
|
154
|
+
console.log(` savings per call (compiled): ${(devOverhead - compiledOverhead).toFixed(0)}ms`);
|
|
155
|
+
console.log(` estimated API latency (dev): ${(devWithApi - devOverhead).toFixed(0)}ms`);
|
|
156
|
+
console.log(` estimated API latency (compiled): ${(compiledWithApi - compiledOverhead).toFixed(0)}ms`);
|
|
157
|
+
console.log(` total lnr calls in mutations e2e: 40`);
|
|
158
|
+
console.log(` estimated savings (40 calls): ${((devOverhead - compiledOverhead) * 40 / 1000).toFixed(1)}s`);
|
|
159
|
+
|
|
160
|
+
console.log("\ndone.");
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ DANGER: MUTATION TESTS — creates, updates, deletes Linear data.
|
|
3
|
+
*
|
|
4
|
+
* DO NOT RUN WITH YOUR PRODUCTION LINEAR API KEY.
|
|
5
|
+
* USE A SANDBOX WORKSPACE ONLY.
|
|
6
|
+
*
|
|
7
|
+
* run: LINEAR_API_KEY=<SANDBOX_KEY> LNR_E2E_CONFIRM_ORG=<org-name> bun test packages/cli/src/e2e-mutations.test.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { getApiKey, getClient } from "@bdsqqq/lnr-core";
|
|
12
|
+
|
|
13
|
+
const API_KEY = getApiKey();
|
|
14
|
+
if (!API_KEY) {
|
|
15
|
+
console.log("skipping e2e mutation tests: no API key found (set LINEAR_API_KEY or add .lnr.json)");
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const client = getClient();
|
|
20
|
+
const org = await client.organization;
|
|
21
|
+
|
|
22
|
+
const confirmOrg = process.env.LNR_E2E_CONFIRM_ORG;
|
|
23
|
+
|
|
24
|
+
if (confirmOrg) {
|
|
25
|
+
if (confirmOrg !== org.name) {
|
|
26
|
+
console.log(`aborted — org "${confirmOrg}" does not match actual org "${org.name}"`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
console.log(`testing org: ${org.name}`);
|
|
30
|
+
} else {
|
|
31
|
+
const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
const answer = await new Promise<string>((resolve) => {
|
|
33
|
+
rl.question(`\n⚠️ MUTATION TESTS will create, update, and delete data in org: ${org.name}\n type the org name to confirm: `, resolve);
|
|
34
|
+
});
|
|
35
|
+
rl.close();
|
|
36
|
+
|
|
37
|
+
if (answer.trim() !== org.name) {
|
|
38
|
+
console.log("aborted — org name did not match.");
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`testing org: ${org.name}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cleanupPreviousRuns() {
|
|
46
|
+
try {
|
|
47
|
+
console.log("cleaning up previous test runs...");
|
|
48
|
+
|
|
49
|
+
const teams = await client.teams();
|
|
50
|
+
for (const team of teams.nodes.filter((t) => t.name.startsWith("e2e-test-"))) {
|
|
51
|
+
console.log(`deleting leftover team: ${team.name}`);
|
|
52
|
+
await client.deleteTeam(team.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const projects = await client.projects();
|
|
56
|
+
for (const project of projects.nodes.filter((p) => p.name.startsWith("e2e-project-"))) {
|
|
57
|
+
console.log(`deleting leftover project: ${project.name}`);
|
|
58
|
+
await client.deleteProject(project.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const views = await client.customViews();
|
|
62
|
+
for (const view of views.nodes.filter((v) => v.name === "Test View" || v.name === "Updated View")) {
|
|
63
|
+
console.log(`deleting leftover view: ${view.name}`);
|
|
64
|
+
await client.deleteCustomView(view.id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log("cleanup complete");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.log("cleanup failed, continuing:", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await cleanupPreviousRuns();
|
|
74
|
+
|
|
75
|
+
const TEST_TEAM_KEY = `E2E${Date.now().toString(36).slice(-4).toUpperCase()}`;
|
|
76
|
+
const TEST_TEAM_NAME = `e2e-test-${Date.now()}`;
|
|
77
|
+
const TEST_PROJECT_NAME = `e2e-project-${Date.now()}`;
|
|
78
|
+
|
|
79
|
+
let teamId: string;
|
|
80
|
+
let issueId: string;
|
|
81
|
+
let issueIdentifier: string;
|
|
82
|
+
let projectId: string;
|
|
83
|
+
let viewId: string;
|
|
84
|
+
let commentId: string;
|
|
85
|
+
|
|
86
|
+
async function lnr(...args: string[]): Promise<string> {
|
|
87
|
+
const proc = Bun.spawn(["bun", "run", "dev", "--", ...args], {
|
|
88
|
+
cwd: import.meta.dir + "/../..",
|
|
89
|
+
stdout: "pipe",
|
|
90
|
+
stderr: "pipe",
|
|
91
|
+
});
|
|
92
|
+
const stdout = await new Response(proc.stdout).text();
|
|
93
|
+
const stderr = await new Response(proc.stderr).text();
|
|
94
|
+
const exitCode = await proc.exited;
|
|
95
|
+
|
|
96
|
+
if (exitCode !== 0) {
|
|
97
|
+
throw new Error(`lnr ${args.join(" ")} failed (${exitCode}):\n${stderr || stdout}`);
|
|
98
|
+
}
|
|
99
|
+
return stdout.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("e2e: mutations", () => {
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
console.log(`\n⚠️ MUTATION TESTS — creating test team: ${TEST_TEAM_NAME} (${TEST_TEAM_KEY})`);
|
|
105
|
+
const result = await client.createTeam({
|
|
106
|
+
name: TEST_TEAM_NAME,
|
|
107
|
+
key: TEST_TEAM_KEY,
|
|
108
|
+
});
|
|
109
|
+
const team = await result.team;
|
|
110
|
+
if (!team) throw new Error("failed to create test team");
|
|
111
|
+
teamId = team.id;
|
|
112
|
+
console.log(`created team ${teamId}`);
|
|
113
|
+
}, 30000);
|
|
114
|
+
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
console.log(`cleaning up: deleting team ${teamId}`);
|
|
117
|
+
if (teamId) {
|
|
118
|
+
await client.deleteTeam(teamId);
|
|
119
|
+
console.log("team deleted");
|
|
120
|
+
}
|
|
121
|
+
}, 30000);
|
|
122
|
+
|
|
123
|
+
describe("team", () => {
|
|
124
|
+
test("list teams includes test team", async () => {
|
|
125
|
+
const out = await lnr("teams");
|
|
126
|
+
expect(out).toContain(TEST_TEAM_KEY);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("show team by key", async () => {
|
|
130
|
+
const out = await lnr("team", TEST_TEAM_KEY);
|
|
131
|
+
expect(out).toContain(TEST_TEAM_NAME);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("cycle CRUD", () => {
|
|
136
|
+
test("create cycle", async () => {
|
|
137
|
+
const out = await lnr(
|
|
138
|
+
"cycle",
|
|
139
|
+
"new",
|
|
140
|
+
"--team",
|
|
141
|
+
TEST_TEAM_KEY,
|
|
142
|
+
"--name",
|
|
143
|
+
"Test Cycle",
|
|
144
|
+
"--starts-at",
|
|
145
|
+
"2026-03-01",
|
|
146
|
+
"--ends-at",
|
|
147
|
+
"2026-03-14"
|
|
148
|
+
);
|
|
149
|
+
expect(out).toContain("created cycle");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("list cycles", async () => {
|
|
153
|
+
const out = await lnr("cycles", "--team", TEST_TEAM_KEY);
|
|
154
|
+
expect(out).toContain("Test Cycle");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("show cycle by number", async () => {
|
|
158
|
+
const out = await lnr("cycle", "1", "--team", TEST_TEAM_KEY);
|
|
159
|
+
expect(out).toContain("Test Cycle");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("update cycle name", async () => {
|
|
163
|
+
const out = await lnr("cycle", "1", "--team", TEST_TEAM_KEY, "--name", "Updated Cycle");
|
|
164
|
+
expect(out).toContain("updated");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("delete cycle", async () => {
|
|
168
|
+
const out = await lnr("cycle", "1", "--team", TEST_TEAM_KEY, "--delete");
|
|
169
|
+
expect(out).toContain("archived");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("view CRUD", () => {
|
|
174
|
+
test("create view", async () => {
|
|
175
|
+
const out = await lnr("view", "new", "--name", "Test View");
|
|
176
|
+
expect(out).toContain("created");
|
|
177
|
+
const json = await lnr("views", "--json");
|
|
178
|
+
const views = JSON.parse(json);
|
|
179
|
+
const testView = views.find((v: any) => v.name === "Test View");
|
|
180
|
+
expect(testView).toBeTruthy();
|
|
181
|
+
viewId = testView.id;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("list views", async () => {
|
|
185
|
+
const out = await lnr("views");
|
|
186
|
+
expect(out).toContain("Test View");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("show view", async () => {
|
|
190
|
+
const out = await lnr("view", "Test View");
|
|
191
|
+
expect(out).toContain("Test View");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("update view name", async () => {
|
|
195
|
+
const out = await lnr("view", "Test View", "--name", "Updated View");
|
|
196
|
+
expect(out).toContain("updated");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("delete view", async () => {
|
|
200
|
+
const out = await lnr("view", "Updated View", "--delete");
|
|
201
|
+
expect(out).toContain("deleted");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("issue + comment + reaction", () => {
|
|
206
|
+
test("create issue", async () => {
|
|
207
|
+
const out = await lnr("issue", "new", "--team", TEST_TEAM_KEY, "--title", "Test Issue");
|
|
208
|
+
expect(out).toContain("created");
|
|
209
|
+
const json = await lnr("issues", "--team", TEST_TEAM_KEY, "--json");
|
|
210
|
+
const issues = JSON.parse(json);
|
|
211
|
+
const testIssue = issues.find((i: any) => i.title === "Test Issue");
|
|
212
|
+
expect(testIssue).toBeTruthy();
|
|
213
|
+
issueId = testIssue.id;
|
|
214
|
+
issueIdentifier = testIssue.identifier;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("add comment", async () => {
|
|
218
|
+
const out = await lnr("issue", issueIdentifier, "--comment", "Test comment body");
|
|
219
|
+
expect(out).toContain("comment");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("list comments", async () => {
|
|
223
|
+
const out = await lnr("issue", issueIdentifier, "--comments");
|
|
224
|
+
expect(out).toContain("Test comment body");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("add reaction to comment", async () => {
|
|
228
|
+
const json = await lnr("issue", issueIdentifier, "--comments", "--json");
|
|
229
|
+
const comments = JSON.parse(json);
|
|
230
|
+
expect(comments.length).toBeGreaterThan(0);
|
|
231
|
+
commentId = comments[0].id;
|
|
232
|
+
const out = await lnr("issue", issueIdentifier, "--react", commentId, "--emoji", "thumbsup");
|
|
233
|
+
expect(out).toContain("reaction");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("subscribe to issue", async () => {
|
|
237
|
+
const out = await lnr("issue", issueIdentifier, "--subscribe");
|
|
238
|
+
expect(out.toLowerCase()).toMatch(/subscrib/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("unsubscribe from issue", async () => {
|
|
242
|
+
const out = await lnr("issue", issueIdentifier, "--unsubscribe");
|
|
243
|
+
expect(out.toLowerCase()).toMatch(/unsubscrib/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("issue batch", () => {
|
|
248
|
+
test("create additional issues for batch", async () => {
|
|
249
|
+
await lnr("issue", "new", "--team", TEST_TEAM_KEY, "--title", "Batch Issue 1");
|
|
250
|
+
await lnr("issue", "new", "--team", TEST_TEAM_KEY, "--title", "Batch Issue 2");
|
|
251
|
+
const out = await lnr("issues", "--team", TEST_TEAM_KEY);
|
|
252
|
+
expect(out).toContain("Batch Issue 1");
|
|
253
|
+
expect(out).toContain("Batch Issue 2");
|
|
254
|
+
}, 15000);
|
|
255
|
+
|
|
256
|
+
test("batch update priority", async () => {
|
|
257
|
+
const json = await lnr("issues", "--team", TEST_TEAM_KEY, "--json");
|
|
258
|
+
const issues = JSON.parse(json);
|
|
259
|
+
const batchIssues = issues.filter((i: any) => i.title.startsWith("Batch Issue"));
|
|
260
|
+
const ids = batchIssues.map((i: any) => i.identifier).join(",");
|
|
261
|
+
const out = await lnr("issue batch", ids, "--priority", "high");
|
|
262
|
+
expect(out).toContain("updated");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("project + scoped entities", () => {
|
|
267
|
+
test("create project", async () => {
|
|
268
|
+
const out = await lnr("project", "new", "--new-name", TEST_PROJECT_NAME, "--team", TEST_TEAM_KEY);
|
|
269
|
+
expect(out).toContain("created");
|
|
270
|
+
const json = await lnr("projects", "--json");
|
|
271
|
+
const projects = JSON.parse(json);
|
|
272
|
+
const testProject = projects.find((p: any) => p.name === TEST_PROJECT_NAME);
|
|
273
|
+
expect(testProject).toBeTruthy();
|
|
274
|
+
projectId = testProject.id;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("show project labels (scoped)", async () => {
|
|
278
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--labels");
|
|
279
|
+
expect(out).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("show project status (scoped)", async () => {
|
|
283
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--show-status");
|
|
284
|
+
expect(out).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("show project updates (scoped)", async () => {
|
|
288
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--updates");
|
|
289
|
+
expect(out).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("subscribe to project", async () => {
|
|
293
|
+
// may succeed or fail with "already subscribed" — both are valid
|
|
294
|
+
try {
|
|
295
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--subscribe");
|
|
296
|
+
expect(out.toLowerCase()).toMatch(/subscrib/);
|
|
297
|
+
} catch (e: any) {
|
|
298
|
+
expect(e.message).toContain("already have an existing subscription");
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("unsubscribe from project", async () => {
|
|
303
|
+
// auto-finds subscription, no id required
|
|
304
|
+
try {
|
|
305
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--unsubscribe");
|
|
306
|
+
expect(out.toLowerCase()).toMatch(/unsubscrib/);
|
|
307
|
+
} catch (e: any) {
|
|
308
|
+
// may fail if not subscribed
|
|
309
|
+
expect(e.message).toMatch(/no subscription found|not subscribed/);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("git automation", () => {
|
|
315
|
+
test("list git automations (empty ok)", async () => {
|
|
316
|
+
try {
|
|
317
|
+
const out = await lnr("git-automations", "--team", TEST_TEAM_KEY);
|
|
318
|
+
expect(out).toBeDefined();
|
|
319
|
+
} catch (e: any) {
|
|
320
|
+
expect(e.message).toContain("no git automation");
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("create git automation state", async () => {
|
|
325
|
+
const team = await client.team(teamId);
|
|
326
|
+
const states = await team.states();
|
|
327
|
+
const inProgressState = states.nodes.find((s) => s.name === "In Progress");
|
|
328
|
+
if (!inProgressState) {
|
|
329
|
+
console.log("skipping: no In Progress state found");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const out = await lnr(
|
|
334
|
+
"git-automation",
|
|
335
|
+
"new",
|
|
336
|
+
"--team",
|
|
337
|
+
TEST_TEAM_KEY,
|
|
338
|
+
"--event",
|
|
339
|
+
"start",
|
|
340
|
+
"--state",
|
|
341
|
+
"In Progress"
|
|
342
|
+
);
|
|
343
|
+
expect(out).toContain("created");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("list git automations after create", async () => {
|
|
347
|
+
const out = await lnr("git-automations", "--team", TEST_TEAM_KEY);
|
|
348
|
+
expect(out).toContain("start");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("delete git automation", async () => {
|
|
352
|
+
const out = await lnr("git-automation", "start", "--team", TEST_TEAM_KEY, "--delete");
|
|
353
|
+
expect(out).toContain("deleted");
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("cleanup", () => {
|
|
358
|
+
test(
|
|
359
|
+
"archive all test issues",
|
|
360
|
+
async () => {
|
|
361
|
+
const json = await lnr("issues", "--team", TEST_TEAM_KEY, "--json");
|
|
362
|
+
const issues = JSON.parse(json);
|
|
363
|
+
for (const issue of issues) {
|
|
364
|
+
await lnr("issue", issue.identifier, "--archive");
|
|
365
|
+
}
|
|
366
|
+
expect(true).toBe(true);
|
|
367
|
+
},
|
|
368
|
+
30000
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
test("delete test project", async () => {
|
|
372
|
+
if (projectId) {
|
|
373
|
+
const out = await lnr("project", TEST_PROJECT_NAME, "--delete");
|
|
374
|
+
expect(out).toContain("deleted");
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2e tests — READ-ONLY operations only.
|
|
3
|
+
* safe to run with any Linear API key.
|
|
4
|
+
*
|
|
5
|
+
* run: LINEAR_API_KEY=xxx bun test packages/cli/src/e2e-readonly.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect } from "bun:test";
|
|
9
|
+
import { getApiKey, getClient } from "@bdsqqq/lnr-core";
|
|
10
|
+
|
|
11
|
+
const API_KEY = getApiKey();
|
|
12
|
+
if (!API_KEY) {
|
|
13
|
+
console.log("skipping e2e tests: no API key found (set LINEAR_API_KEY or add .lnr.json)");
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const client = getClient();
|
|
18
|
+
const org = await client.organization;
|
|
19
|
+
console.log(`testing org: ${org.name}`);
|
|
20
|
+
|
|
21
|
+
async function lnr(...args: string[]): Promise<string> {
|
|
22
|
+
const proc = Bun.spawn(["bun", "run", "dev", "--", ...args], {
|
|
23
|
+
cwd: import.meta.dir + "/../..",
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
});
|
|
27
|
+
const stdout = await new Response(proc.stdout).text();
|
|
28
|
+
const stderr = await new Response(proc.stderr).text();
|
|
29
|
+
const exitCode = await proc.exited;
|
|
30
|
+
|
|
31
|
+
if (exitCode !== 0) {
|
|
32
|
+
throw new Error(`lnr ${args.join(" ")} failed (${exitCode}):\n${stderr || stdout}`);
|
|
33
|
+
}
|
|
34
|
+
return stdout.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("e2e: read-only", () => {
|
|
38
|
+
test("me command", async () => {
|
|
39
|
+
const out = await lnr("me");
|
|
40
|
+
expect(out).toContain("@");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("list teams", async () => {
|
|
44
|
+
const out = await lnr("teams");
|
|
45
|
+
expect(out).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("list projects", async () => {
|
|
49
|
+
const out = await lnr("projects");
|
|
50
|
+
expect(out).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("list users", async () => {
|
|
54
|
+
const out = await lnr("users");
|
|
55
|
+
expect(out).toContain("@");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("list views", async () => {
|
|
59
|
+
try {
|
|
60
|
+
const out = await lnr("views");
|
|
61
|
+
expect(out).toBeDefined();
|
|
62
|
+
} catch {
|
|
63
|
+
// empty is fine
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("list templates", async () => {
|
|
68
|
+
try {
|
|
69
|
+
const out = await lnr("templates");
|
|
70
|
+
expect(out).toBeDefined();
|
|
71
|
+
} catch {
|
|
72
|
+
// empty is fine
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("list notifications", async () => {
|
|
77
|
+
try {
|
|
78
|
+
const out = await lnr("notifications");
|
|
79
|
+
expect(out).toBeDefined();
|
|
80
|
+
} catch {
|
|
81
|
+
// empty is fine
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("list agent sessions", async () => {
|
|
86
|
+
try {
|
|
87
|
+
const out = await lnr("agent-sessions");
|
|
88
|
+
expect(out).toBeDefined();
|
|
89
|
+
} catch {
|
|
90
|
+
// empty is fine
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("list initiatives (enterprise)", async () => {
|
|
95
|
+
const out = await lnr("initiatives");
|
|
96
|
+
expect(out).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("list roadmaps (enterprise)", async () => {
|
|
100
|
+
const out = await lnr("roadmaps");
|
|
101
|
+
expect(out).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
});
|