cueloop 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +4 -4
- package/src/main.ts +135 -27
- package/src/open-target.test.ts +214 -0
- package/src/open-target.ts +106 -0
- package/src/session-commands.ts +11 -1
- package/src/share-command.test.ts +87 -0
- package/src/share-command.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.22",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
".": "./src/main.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.22",
|
|
14
|
+
"@cueloop/daemon": "0.1.0-alpha.22",
|
|
15
|
+
"@cueloop/client": "0.1.0-alpha.22"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
package/src/main.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* cueloop entry points: `cueloop` opens the TUI on the inbox,
|
|
4
|
-
* `cueloop diff`
|
|
5
|
-
*
|
|
6
|
-
* `cueloop
|
|
7
|
-
* `cueloop
|
|
3
|
+
* cueloop entry points: `cueloop` opens the TUI on the inbox, the verb-first
|
|
4
|
+
* openers `cueloop plan|diff|review` open the latest pending review of that
|
|
5
|
+
* type (or address one by id/title), `cueloop diff`/`cueloop review <pr>` also
|
|
6
|
+
* keep their create paths, `cueloop session *` mirrors the daemon API for
|
|
7
|
+
* agents and scripts, `cueloop serve` shares a session over ssh (read-only
|
|
8
|
+
* observers), `cueloop daemon` runs the daemon in the foreground.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { parseArgs, stringFlag } from "./args";
|
|
11
|
+
import { parseArgs, stringFlag, type ParsedArgs } from "./args";
|
|
12
|
+
import {
|
|
13
|
+
isDiffReview,
|
|
14
|
+
isPlanReview,
|
|
15
|
+
isPrReview,
|
|
16
|
+
isSessionId,
|
|
17
|
+
openTargetMessage,
|
|
18
|
+
resolveOpenTarget,
|
|
19
|
+
} from "./open-target";
|
|
11
20
|
import { sessionCommand } from "./session-commands";
|
|
12
21
|
import { workingTreeDiff } from "./working-tree";
|
|
13
22
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
23
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
24
|
import { openReview, resolveWorkspace } from "@cueloop/daemon/review";
|
|
15
25
|
|
|
16
26
|
const argv = process.argv.slice(2);
|
|
@@ -32,23 +42,10 @@ async function main(): Promise<number> {
|
|
|
32
42
|
await new Promise(() => {}); // run until signalled
|
|
33
43
|
return 0;
|
|
34
44
|
}
|
|
35
|
-
case "
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.error("working tree is clean - nothing to review");
|
|
40
|
-
return 1;
|
|
41
|
-
}
|
|
42
|
-
const client = await DaemonClient.connect({ autostart: true });
|
|
43
|
-
const review = await openReview(client, {
|
|
44
|
-
type: "diff",
|
|
45
|
-
content: diff,
|
|
46
|
-
workspace,
|
|
47
|
-
title: `working tree @ ${workspace.branch}`,
|
|
48
|
-
});
|
|
49
|
-
client.close();
|
|
50
|
-
return runTui(review.id);
|
|
51
|
-
}
|
|
45
|
+
case "plan":
|
|
46
|
+
return planCommand(argv.slice(1));
|
|
47
|
+
case "diff":
|
|
48
|
+
return diffCommand(argv.slice(1));
|
|
52
49
|
case "serve": {
|
|
53
50
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
54
51
|
const port = stringFlag(flags, "port");
|
|
@@ -75,10 +72,18 @@ async function main(): Promise<number> {
|
|
|
75
72
|
await new Promise(() => {}); // serve until signalled
|
|
76
73
|
return 0;
|
|
77
74
|
}
|
|
78
|
-
case "
|
|
79
|
-
const {
|
|
80
|
-
|
|
75
|
+
case "share": {
|
|
76
|
+
const { positional, flags } = parseArgs(argv.slice(1));
|
|
77
|
+
const { shareCommand } = await import("./share-command");
|
|
78
|
+
const port = stringFlag(flags, "port");
|
|
79
|
+
return shareCommand({
|
|
80
|
+
sessionId: positional[0],
|
|
81
|
+
host: stringFlag(flags, "host"),
|
|
82
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
83
|
+
});
|
|
81
84
|
}
|
|
85
|
+
case "review":
|
|
86
|
+
return reviewEntry(argv.slice(1));
|
|
82
87
|
case "review-post": {
|
|
83
88
|
const { reviewPostCommand } = await import("./pr");
|
|
84
89
|
return reviewPostCommand(argv.slice(1));
|
|
@@ -98,6 +103,103 @@ async function main(): Promise<number> {
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The id-or-title selector for a verb-first opener: the bare positional, or a
|
|
108
|
+
* value handed to `--open`/`--latest`. A bare `--latest`/`--open` flag carries
|
|
109
|
+
* no value, so the selector stays undefined and the opener defaults to the
|
|
110
|
+
* latest pending review.
|
|
111
|
+
*/
|
|
112
|
+
function openSelector(parsed: ParsedArgs): string | undefined {
|
|
113
|
+
return parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve one review of the verb's scope and open it in the TUI, or print the
|
|
118
|
+
* miss and fail. `emptyMessage` overrides the default no-pending line for the
|
|
119
|
+
* one caller that needs a scope-specific hint (a clean working tree).
|
|
120
|
+
*/
|
|
121
|
+
async function openReviewOfKind(
|
|
122
|
+
match: (session: ReviewSession) => boolean,
|
|
123
|
+
label: string,
|
|
124
|
+
selector: string | undefined,
|
|
125
|
+
emptyMessage?: string,
|
|
126
|
+
): Promise<number> {
|
|
127
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
128
|
+
let sessions: ReviewSession[];
|
|
129
|
+
try {
|
|
130
|
+
sessions = await client.sessionList();
|
|
131
|
+
} finally {
|
|
132
|
+
client.close();
|
|
133
|
+
}
|
|
134
|
+
const target = resolveOpenTarget(sessions, { match, selector });
|
|
135
|
+
if (target.kind === "session") return runTui(target.sessionId);
|
|
136
|
+
if (target.kind === "no-pending" && emptyMessage !== undefined) {
|
|
137
|
+
console.error(emptyMessage);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
console.error(openTargetMessage(label, target));
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `cueloop plan [id|title]` - open the latest pending plan, or address one. */
|
|
145
|
+
async function planCommand(argv: string[]): Promise<number> {
|
|
146
|
+
return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `cueloop diff` disambiguates create from open by intent:
|
|
151
|
+
* - a selector (`cueloop diff <id|title>`) or an explicit `--open`/`--latest`
|
|
152
|
+
* opens a pending diff review;
|
|
153
|
+
* - otherwise a dirty working tree still creates a review as before;
|
|
154
|
+
* - a clean working tree carries no create input, so it opens the latest
|
|
155
|
+
* pending diff review instead of erroring.
|
|
156
|
+
*/
|
|
157
|
+
async function diffCommand(argv: string[]): Promise<number> {
|
|
158
|
+
const parsed = parseArgs(argv);
|
|
159
|
+
const selector = openSelector(parsed);
|
|
160
|
+
const wantsOpen = selector !== undefined || "open" in parsed.flags || "latest" in parsed.flags;
|
|
161
|
+
if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector);
|
|
162
|
+
|
|
163
|
+
const workspace = await resolveWorkspace();
|
|
164
|
+
const diff = await workingTreeDiff();
|
|
165
|
+
if (!diff.trim()) {
|
|
166
|
+
return openReviewOfKind(
|
|
167
|
+
isDiffReview,
|
|
168
|
+
"diff",
|
|
169
|
+
undefined,
|
|
170
|
+
"working tree is clean and no pending diff review - nothing to open",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
174
|
+
const review = await openReview(client, {
|
|
175
|
+
type: "diff",
|
|
176
|
+
content: diff,
|
|
177
|
+
workspace,
|
|
178
|
+
title: `working tree @ ${workspace.branch}`,
|
|
179
|
+
});
|
|
180
|
+
client.close();
|
|
181
|
+
return runTui(review.id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `cueloop review` disambiguates create from open by intent:
|
|
186
|
+
* - bare `cueloop review`, `--open`/`--latest`, or a `ses_*` selector opens a
|
|
187
|
+
* pending PR review (a diff carrying a `pr` reference);
|
|
188
|
+
* - any other positional is a PR reference and takes the create path in pr.ts.
|
|
189
|
+
*/
|
|
190
|
+
async function reviewEntry(argv: string[]): Promise<number> {
|
|
191
|
+
const parsed = parseArgs(argv);
|
|
192
|
+
const explicitOpen = "open" in parsed.flags || "latest" in parsed.flags;
|
|
193
|
+
const selector = openSelector(parsed);
|
|
194
|
+
const looksLikeSessionId = selector !== undefined && isSessionId(selector);
|
|
195
|
+
const wantsCreate = !explicitOpen && !looksLikeSessionId && selector !== undefined;
|
|
196
|
+
if (wantsCreate) {
|
|
197
|
+
const { reviewCommand } = await import("./pr");
|
|
198
|
+
return reviewCommand(argv);
|
|
199
|
+
}
|
|
200
|
+
return openReviewOfKind(isPrReview, "PR", explicitOpen || looksLikeSessionId ? selector : undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
101
203
|
async function runTui(sessionId?: string): Promise<number> {
|
|
102
204
|
const { runClient } = await import("@cueloop/client");
|
|
103
205
|
return runClient({ sessionId });
|
|
@@ -111,12 +213,18 @@ function printHelp(): void {
|
|
|
111
213
|
"usage:",
|
|
112
214
|
" cueloop open the inbox",
|
|
113
215
|
" cueloop <session-id> open one session",
|
|
114
|
-
" cueloop
|
|
216
|
+
" cueloop plan [id|title] open the latest pending plan review (or one by id/title)",
|
|
217
|
+
" cueloop diff [id|title] open the latest pending diff review (or one by id/title);",
|
|
218
|
+
" with a dirty working tree and no argument, create one instead",
|
|
219
|
+
" cueloop review [id|title] open the latest pending PR review (or one by id/title)",
|
|
115
220
|
" cueloop review <pr> review a pull request (--no-tui prints the session)",
|
|
221
|
+
" cueloop <plan|diff|review> --latest open the latest pending review of that type",
|
|
116
222
|
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
117
223
|
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
118
224
|
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
119
225
|
" password-less - share the address deliberately)",
|
|
226
|
+
" cueloop share [session-id] hand a plan to a teammate: copies one",
|
|
227
|
+
" ssh line (--host cueloop.dev, --port 22)",
|
|
120
228
|
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
121
229
|
" cueloop daemon run the daemon in the foreground",
|
|
122
230
|
].join("\n"),
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Verb-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { ArtifactType, ReviewSession } from "@cueloop/schema";
|
|
5
|
+
import {
|
|
6
|
+
isDiffReview,
|
|
7
|
+
isPlanReview,
|
|
8
|
+
isPrReview,
|
|
9
|
+
isSessionId,
|
|
10
|
+
openTargetMessage,
|
|
11
|
+
resolveOpenTarget,
|
|
12
|
+
} from "./open-target";
|
|
13
|
+
|
|
14
|
+
function session(overrides: {
|
|
15
|
+
id: string;
|
|
16
|
+
type?: ArtifactType;
|
|
17
|
+
title?: string;
|
|
18
|
+
status?: "pending" | "resolved";
|
|
19
|
+
createdAt?: string;
|
|
20
|
+
pr?: string;
|
|
21
|
+
}): ReviewSession {
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: "1",
|
|
24
|
+
id: overrides.id,
|
|
25
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
26
|
+
artifact: {
|
|
27
|
+
type: overrides.type ?? "plan",
|
|
28
|
+
content: "",
|
|
29
|
+
meta: { title: overrides.title, pr: overrides.pr },
|
|
30
|
+
},
|
|
31
|
+
revisions: [],
|
|
32
|
+
annotations: [],
|
|
33
|
+
verdict: overrides.status === "resolved" ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" } : null,
|
|
34
|
+
status: overrides.status ?? "pending",
|
|
35
|
+
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isPlan = isPlanReview;
|
|
40
|
+
|
|
41
|
+
describe("verb scope predicates are disjoint", () => {
|
|
42
|
+
test("isPlanReview matches only plan artifacts", () => {
|
|
43
|
+
expect(isPlanReview(session({ id: "ses_p", type: "plan" }))).toBe(true);
|
|
44
|
+
expect(isPlanReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("isDiffReview matches a plain diff but NOT a diff carrying meta.pr", () => {
|
|
48
|
+
expect(isDiffReview(session({ id: "ses_d", type: "diff" }))).toBe(true);
|
|
49
|
+
expect(isDiffReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(false);
|
|
50
|
+
expect(isDiffReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("isPrReview matches a diff carrying meta.pr, and only that", () => {
|
|
54
|
+
expect(isPrReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(true);
|
|
55
|
+
expect(isPrReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
56
|
+
expect(isPrReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a PR review resolves under exactly one scope - never both diff and review", () => {
|
|
60
|
+
// Arrange
|
|
61
|
+
const prReview = session({ id: "ses_pr", type: "diff", pr: "42" });
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(isDiffReview(prReview)).toBe(false);
|
|
65
|
+
expect(isPrReview(prReview)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isSessionId", () => {
|
|
70
|
+
test("only the ses_ prefix reads as an id", () => {
|
|
71
|
+
expect(isSessionId("ses_abc")).toBe(true);
|
|
72
|
+
expect(isSessionId("some title")).toBe(false);
|
|
73
|
+
expect(isSessionId("42")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveOpenTarget: latest pending default", () => {
|
|
78
|
+
test("picks the most recent pending session in scope", () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const sessions = [
|
|
81
|
+
session({ id: "ses_old", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
82
|
+
session({ id: "ses_new", createdAt: "2026-03-01T00:00:00.000Z" }),
|
|
83
|
+
session({ id: "ses_mid", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_new" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ignores resolved sessions and other artifact types", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
const sessions = [
|
|
93
|
+
session({ id: "ses_resolved", createdAt: "2026-05-01T00:00:00.000Z", status: "resolved" }),
|
|
94
|
+
session({ id: "ses_diff", type: "diff", createdAt: "2026-04-01T00:00:00.000Z" }),
|
|
95
|
+
session({ id: "ses_plan", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_plan" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("no pending session in scope reports no-pending", () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const sessions = [session({ id: "ses_done", status: "resolved" })];
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "no-pending" });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("resolveOpenTarget: by id", () => {
|
|
112
|
+
test("an exact session id in scope opens that session, pending or not", () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const sessions = [
|
|
115
|
+
session({ id: "ses_a" }),
|
|
116
|
+
session({ id: "ses_b", status: "resolved" }),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
121
|
+
kind: "session",
|
|
122
|
+
sessionId: "ses_b",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("an id outside the scope does not match", () => {
|
|
127
|
+
// Arrange
|
|
128
|
+
const sessions = [session({ id: "ses_diff", type: "diff" })];
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_diff" })).toEqual({
|
|
132
|
+
kind: "no-match",
|
|
133
|
+
selector: "ses_diff",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("resolveOpenTarget: fuzzy by title", () => {
|
|
139
|
+
test("case-insensitive exact title wins over substring rivals", () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const sessions = [
|
|
142
|
+
session({ id: "ses_exact", title: "Rollout" }),
|
|
143
|
+
session({ id: "ses_sub", title: "Rollout plan v2" }),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
148
|
+
kind: "session",
|
|
149
|
+
sessionId: "ses_exact",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a unique case-insensitive substring match wins", () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
const sessions = [
|
|
156
|
+
session({ id: "ses_auth", title: "Auth rewrite" }),
|
|
157
|
+
session({ id: "ses_cache", title: "Cache warmup" }),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "AUTH" })).toEqual({
|
|
162
|
+
kind: "session",
|
|
163
|
+
sessionId: "ses_auth",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("several substring matches are ambiguous and list the candidate titles", () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const sessions = [
|
|
170
|
+
session({ id: "ses_1", title: "Payments rollout" }),
|
|
171
|
+
session({ id: "ses_2", title: "Rollout of search" }),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
175
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
176
|
+
kind: "ambiguous",
|
|
177
|
+
selector: "rollout",
|
|
178
|
+
titles: ["Payments rollout", "Rollout of search"],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("no title match reports no-match", () => {
|
|
183
|
+
// Arrange
|
|
184
|
+
const sessions = [session({ id: "ses_1", title: "Auth rewrite" })];
|
|
185
|
+
|
|
186
|
+
// Assert
|
|
187
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "billing" })).toEqual({
|
|
188
|
+
kind: "no-match",
|
|
189
|
+
selector: "billing",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("openTargetMessage", () => {
|
|
195
|
+
test("no-pending mirrors the clean-tree tone", () => {
|
|
196
|
+
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe("no pending plan review - nothing to open");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("no-match names the selector", () => {
|
|
200
|
+
expect(openTargetMessage("diff", { kind: "no-match", selector: "billing" })).toBe(
|
|
201
|
+
'no diff review matches "billing" - nothing to open',
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("ambiguous lists the candidate titles", () => {
|
|
206
|
+
// Act
|
|
207
|
+
const message = openTargetMessage("plan", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
210
|
+
expect(message).toContain('"rollout" matches several plan reviews');
|
|
211
|
+
expect(message).toContain(" - A");
|
|
212
|
+
expect(message).toContain(" - B");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verb-first review-open resolution: turn a verb (plan/diff/review) plus an
|
|
3
|
+
* optional id-or-title selector into a single session to open, or a clear
|
|
4
|
+
* miss. Pure over a session list so it unit-tests without a daemon or a TUI -
|
|
5
|
+
* main.ts fetches the sessions and prints the miss messages.
|
|
6
|
+
*
|
|
7
|
+
* Rules, in order:
|
|
8
|
+
* - no selector opens the latest pending session in scope (most recent
|
|
9
|
+
* createdAt, verdict still open) - the `--latest` default;
|
|
10
|
+
* - a selector that exactly equals a session id opens that session, pending
|
|
11
|
+
* or resolved;
|
|
12
|
+
* - otherwise the selector matches session titles, case-insensitive: an
|
|
13
|
+
* exact title wins, else a unique substring match wins, else the substring
|
|
14
|
+
* candidates are reported as ambiguous.
|
|
15
|
+
* Every lookup is scoped by the caller's `match` predicate, so a verb only
|
|
16
|
+
* ever resolves to its own artifact kind.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The verb scopes, kept disjoint on purpose so a single session never resolves
|
|
23
|
+
* under two verbs. A PR review is a diff artifact that carries a `pr`
|
|
24
|
+
* reference (openReview maps a top-level `pr` into `meta.pr`), so the plain
|
|
25
|
+
* diff scope must exclude those - otherwise `cueloop diff` and `cueloop
|
|
26
|
+
* review` would both open the same pending PR review.
|
|
27
|
+
*/
|
|
28
|
+
export function isPlanReview(session: ReviewSession): boolean {
|
|
29
|
+
return session.artifact.type === "plan";
|
|
30
|
+
}
|
|
31
|
+
export function isDiffReview(session: ReviewSession): boolean {
|
|
32
|
+
return session.artifact.type === "diff" && session.artifact.meta.pr === undefined;
|
|
33
|
+
}
|
|
34
|
+
export function isPrReview(session: ReviewSession): boolean {
|
|
35
|
+
return session.artifact.type === "diff" && session.artifact.meta.pr !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Session ids are minted with this prefix; the id-vs-title split lives here. */
|
|
39
|
+
export function isSessionId(value: string): boolean {
|
|
40
|
+
return value.startsWith("ses_");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OpenTargetQuery {
|
|
44
|
+
/** Only sessions this predicate accepts are eligible - the verb's artifact scope. */
|
|
45
|
+
match: (session: ReviewSession) => boolean;
|
|
46
|
+
/** Positional id-or-title selector; absent means "latest pending". */
|
|
47
|
+
selector?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OpenTarget =
|
|
51
|
+
| { kind: "session"; sessionId: string }
|
|
52
|
+
| { kind: "no-pending" }
|
|
53
|
+
| { kind: "no-match"; selector: string }
|
|
54
|
+
| { kind: "ambiguous"; selector: string; titles: string[] };
|
|
55
|
+
|
|
56
|
+
/** Most-recent-first, so the head of a sorted list is always the latest. */
|
|
57
|
+
function newestFirst(left: ReviewSession, right: ReviewSession): number {
|
|
58
|
+
return right.createdAt.localeCompare(left.createdAt);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQuery): OpenTarget {
|
|
62
|
+
const scoped = sessions.filter(query.match);
|
|
63
|
+
|
|
64
|
+
if (query.selector === undefined) {
|
|
65
|
+
const latestPending = scoped.filter((candidate) => candidate.status === "pending").sort(newestFirst)[0];
|
|
66
|
+
return latestPending ? { kind: "session", sessionId: latestPending.id } : { kind: "no-pending" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const selector = query.selector;
|
|
70
|
+
const byId = scoped.find((candidate) => candidate.id === selector);
|
|
71
|
+
if (byId) return { kind: "session", sessionId: byId.id };
|
|
72
|
+
|
|
73
|
+
const loweredSelector = selector.toLowerCase();
|
|
74
|
+
const titled = scoped.filter((candidate) => (candidate.artifact.meta.title ?? "").length > 0);
|
|
75
|
+
|
|
76
|
+
const exactTitle = titled
|
|
77
|
+
.filter((candidate) => candidate.artifact.meta.title!.toLowerCase() === loweredSelector)
|
|
78
|
+
.sort(newestFirst)[0];
|
|
79
|
+
if (exactTitle) return { kind: "session", sessionId: exactTitle.id };
|
|
80
|
+
|
|
81
|
+
const substringMatches = titled
|
|
82
|
+
.filter((candidate) => candidate.artifact.meta.title!.toLowerCase().includes(loweredSelector))
|
|
83
|
+
.sort(newestFirst);
|
|
84
|
+
if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
|
|
85
|
+
if (substringMatches.length === 0) return { kind: "no-match", selector };
|
|
86
|
+
return { kind: "ambiguous", selector, titles: substringMatches.map((candidate) => candidate.artifact.meta.title!) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The reviewer-facing line for a miss, in the "working tree is clean - nothing
|
|
91
|
+
* to review" tone. `label` is the human word for the verb's scope ("plan",
|
|
92
|
+
* "diff", "PR"). The "session" outcome has no message - the caller opens it.
|
|
93
|
+
*/
|
|
94
|
+
export function openTargetMessage(label: string, target: Exclude<OpenTarget, { kind: "session" }>): string {
|
|
95
|
+
switch (target.kind) {
|
|
96
|
+
case "no-pending":
|
|
97
|
+
return `no pending ${label} review - nothing to open`;
|
|
98
|
+
case "no-match":
|
|
99
|
+
return `no ${label} review matches "${target.selector}" - nothing to open`;
|
|
100
|
+
case "ambiguous":
|
|
101
|
+
return [
|
|
102
|
+
`"${target.selector}" matches several ${label} reviews - name one:`,
|
|
103
|
+
...target.titles.map((title) => ` - ${title}`),
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/session-commands.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { newAnnotationId, type ArtifactType, type VerdictKind } from "@cueloop/schema";
|
|
8
8
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
|
+
import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
|
|
9
10
|
import { openReview, verdictResponse, type ReviewNote } from "@cueloop/daemon/review";
|
|
10
11
|
import { parseArgs, stringFlag } from "./args";
|
|
11
12
|
|
|
@@ -39,6 +40,10 @@ export async function sessionCommand(argv: string[]): Promise<number> {
|
|
|
39
40
|
title: stringFlag(flags, "title"),
|
|
40
41
|
notes,
|
|
41
42
|
});
|
|
43
|
+
// herdr auto-open: a review created from inside herdr spawns a new tab
|
|
44
|
+
// that renders it. Guarded to genuinely new sessions and no-op outside
|
|
45
|
+
// herdr; a broken binary never blocks the create.
|
|
46
|
+
openHerdrPaneForReview(review.session);
|
|
42
47
|
out(review.session);
|
|
43
48
|
return 0;
|
|
44
49
|
}
|
|
@@ -91,7 +96,12 @@ export async function sessionCommand(argv: string[]): Promise<number> {
|
|
|
91
96
|
const id = required(positional[1], "session id");
|
|
92
97
|
const contentFile = stringFlag(flags, "content-file");
|
|
93
98
|
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
94
|
-
|
|
99
|
+
// --addressed a_1,a_2 - the annotation ids this revision acted on
|
|
100
|
+
const addressed = (stringFlag(flags, "addressed") ?? "")
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((annotationId) => annotationId.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
out(await client.sessionSubmitRevision(id, content, addressed));
|
|
95
105
|
return 0;
|
|
96
106
|
}
|
|
97
107
|
default:
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
+
import { shareSession, type ShareDeps } from "./share-command";
|
|
5
|
+
|
|
6
|
+
function sessionFixture(id: string): ReviewSession {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: SCHEMA_VERSION,
|
|
9
|
+
id,
|
|
10
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
11
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
12
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
13
|
+
annotations: [],
|
|
14
|
+
verdict: null,
|
|
15
|
+
status: "pending",
|
|
16
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A SessionClient that answers get/list from a fixed list. */
|
|
21
|
+
function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
22
|
+
return {
|
|
23
|
+
sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
|
|
24
|
+
sessionList: async () => sessions,
|
|
25
|
+
} as unknown as SessionClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
return {
|
|
31
|
+
publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true })),
|
|
32
|
+
out: (message: string) => void lines.push(message),
|
|
33
|
+
lines,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe(shareSession, () => {
|
|
39
|
+
test("publishes the named session and reports the copied ssh line", async () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
42
|
+
const deps = depsSpy({ publish });
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const code = await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(code).toBe(0);
|
|
49
|
+
expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("without an id, shares the most recent session", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
|
|
55
|
+
const deps = depsSpy({ publish });
|
|
56
|
+
|
|
57
|
+
// Act
|
|
58
|
+
await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), {}, deps);
|
|
59
|
+
|
|
60
|
+
// Assert
|
|
61
|
+
expect(deps.publish).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("prints the bare line when the clipboard is unavailable", async () => {
|
|
65
|
+
// Arrange
|
|
66
|
+
const deps = depsSpy({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(deps.lines).toEqual(["ssh p_abc123xy@cueloop.dev"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("reports nothing to share when the inbox is empty", async () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const deps = depsSpy();
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const code = await shareSession(fakeClient([]), {}, deps);
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(code).toBe(1);
|
|
84
|
+
expect(deps.lines[0]).toContain("no plan to share");
|
|
85
|
+
expect(deps.publish).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop share [session-id]` - hand a plan to a teammate as one SSH line.
|
|
3
|
+
* This side just picks the session and delegates to the shared `publishShare`
|
|
4
|
+
* transport (the same one the in-TUI Share button uses); the gateway seals and
|
|
5
|
+
* stores. The transport and output are injected so the orchestration is
|
|
6
|
+
* testable without a live gateway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { publishShare, type ShareResult, type ShareTarget } from "@cueloop/client";
|
|
11
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
12
|
+
|
|
13
|
+
export interface ShareParams {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
home?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ShareDeps {
|
|
21
|
+
publish: (session: ReviewSession, target: ShareTarget) => Promise<ShareResult>;
|
|
22
|
+
out: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
|
|
26
|
+
|
|
27
|
+
/** Connect to the local daemon, then share the chosen session. */
|
|
28
|
+
export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
|
|
29
|
+
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
30
|
+
try {
|
|
31
|
+
return await shareSession(client, params, deps);
|
|
32
|
+
} finally {
|
|
33
|
+
client.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The orchestration, over any SessionClient - the seam the tests drive. */
|
|
38
|
+
export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
|
|
39
|
+
const session = await pickSession(client, params.sessionId);
|
|
40
|
+
if (!session) {
|
|
41
|
+
deps.out("no plan to share - open a review first");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
|
|
45
|
+
deps.out(copied ? `share link copied - ${line}` : line);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The named session, or the most recent one when no id is given. */
|
|
50
|
+
async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
51
|
+
if (sessionId) return client.sessionGet(sessionId);
|
|
52
|
+
const sessions = await client.sessionList();
|
|
53
|
+
return sessions.at(-1) ?? null;
|
|
54
|
+
}
|