cueloop 0.1.0-alpha.84 → 0.1.0-alpha.86

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 (3) hide show
  1. package/package.json +5 -5
  2. package/src/main.ts +5 -1
  3. package/src/pr.ts +427 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.84",
3
+ "version": "0.1.0-alpha.86",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a message, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -30,10 +30,10 @@
30
30
  "build:binary": "bun build --compile --minify --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.84",
34
- "@cueloop/client": "0.1.0-alpha.84",
35
- "@cueloop/daemon": "0.1.0-alpha.84",
36
- "@cueloop/schema": "0.1.0-alpha.84",
33
+ "@cueloop/adapters": "0.1.0-alpha.86",
34
+ "@cueloop/client": "0.1.0-alpha.86",
35
+ "@cueloop/daemon": "0.1.0-alpha.86",
36
+ "@cueloop/schema": "0.1.0-alpha.86",
37
37
  "@modelcontextprotocol/server": "2.0.0",
38
38
  "@valibot/to-json-schema": "1.4.0",
39
39
  "valibot": "^1.4.2"
package/src/main.ts CHANGED
@@ -119,6 +119,9 @@ const commandHandlers: CommandHandlers = {
119
119
  refine: async (rest) => (await import("./refine-command")).refineCommand(rest),
120
120
  update: async (rest) => (await import("./update-command")).updateCommand(rest),
121
121
  review: (rest) => reviewEntry(rest),
122
+ "review-config": async () => (await import("./pr")).reviewConfigCommand(),
123
+ "review-comment": async (rest) => (await import("./pr")).reviewCommentCommand(rest),
124
+ "review-open": async (rest) => (await import("./pr")).reviewOpenCommand(rest),
122
125
  "review-post": async (rest) => (await import("./pr")).reviewPostCommand(rest),
123
126
  dev: async () => (await import("./dev-command")).devCommand(),
124
127
  };
@@ -346,7 +349,8 @@ function printHelp(): void {
346
349
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
347
350
  " cueloop refine mine past reviews into a markdown report + writeback proposals",
348
351
  " cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
349
- " cueloop review-post <id> <pr> post a resolved session's message back to the PR",
352
+ " cueloop review-post <id> explicitly post selected agent findings to the PR",
353
+ " cueloop review-comment <id> add or update an agent PR finding (see --help)",
350
354
  " cueloop daemon run the daemon in the foreground",
351
355
  " cueloop stop stop the local daemon",
352
356
  " cueloop restart stop the local daemon and start a fresh one",
package/src/pr.ts CHANGED
@@ -1,19 +1,300 @@
1
1
  /**
2
2
  * PR review entry: `cueloop review <pr>` fetches the PR diff through the
3
- * user's `gh` CLI (auth fully delegated), opens a diff session, and posts the
4
- * message back to the PR as a real review when the session resolves.
5
- * `cueloop review-post <session-id> <pr>` is the non-interactive post-back
6
- * half for agents and scripts.
3
+ * user's `gh` CLI (auth fully delegated) and opens a diff session. Publishing
4
+ * is a separate explicit operation so resolving a Thread never mutates GitHub.
7
5
  */
8
6
 
9
- import type { Thread } from "@cueloop/schema";
7
+ import { readFileSync } from "node:fs";
8
+ import { createHash } from "node:crypto";
9
+ import {
10
+ REVIEW_SEVERITIES,
11
+ type Annotation,
12
+ type ReviewSeverity,
13
+ type Thread,
14
+ } from "@cueloop/schema";
15
+ import { isAgentReviewComment } from "@cueloop/adapters/github-review-comment";
16
+ import type { GitHubReviewEvent } from "@cueloop/adapters/forge-review";
17
+ import { parseArgs, stringFlag } from "./args";
10
18
  import { join } from "node:path";
11
19
  import { createGitHubForgeReviewPort } from "@cueloop/adapters/forge-review";
12
20
  import { createDeliveredMessageStore } from "@cueloop/adapters/delivered-message-store";
21
+ import { createTerminalThreadSurfacePort } from "@cueloop/adapters/terminal-thread-surface-port";
13
22
  import { DaemonClient } from "@cueloop/daemon/client";
14
23
  import { cueloopHome } from "@cueloop/daemon/paths";
15
24
  import { openReview } from "@cueloop/daemon/thread-review";
16
- import { parseArgs } from "./args";
25
+
26
+ function pullRequestBrief(title: string, body: string): string {
27
+ return `# ${title}\n\n## PR description\n\n${body || "No description provided."}`;
28
+ }
29
+
30
+ function textFlag(
31
+ flags: Record<string, string | boolean>,
32
+ name: string,
33
+ fileName: string,
34
+ ): string | undefined {
35
+ const inline = stringFlag(flags, name);
36
+ const path = stringFlag(flags, fileName);
37
+
38
+ if (inline !== undefined && path !== undefined)
39
+ throw new Error(`use either --${name} or --${fileName}, not both`);
40
+
41
+ return path === undefined ? inline : readFileSync(path, "utf8");
42
+ }
43
+
44
+ function requiredFlag(flags: Record<string, string | boolean>, name: string): string {
45
+ const value = stringFlag(flags, name);
46
+
47
+ if (!value) throw new Error(`--${name} is required`);
48
+
49
+ return value;
50
+ }
51
+
52
+ function nextFindingId(annotations: Annotation[]): string {
53
+ const highest = annotations.reduce((maximum, annotation) => {
54
+ const match = /^C(\d+)$/.exec(annotation.id);
55
+
56
+ return match ? Math.max(maximum, Number(match[1])) : maximum;
57
+ }, 0);
58
+
59
+ return `C${highest + 1}`;
60
+ }
61
+
62
+ function isReviewSeverity(value: string): value is ReviewSeverity {
63
+ return REVIEW_SEVERITIES.some((severity) => severity === value);
64
+ }
65
+
66
+ function reviewEvent(value: string): GitHubReviewEvent | undefined {
67
+ if (value === "comment") return "COMMENT";
68
+ if (value === "approve") return "APPROVE";
69
+ if (value === "request-changes") return "REQUEST_CHANGES";
70
+
71
+ return undefined;
72
+ }
73
+
74
+ function reviewSummary(event: GitHubReviewEvent, summary: string): string | undefined {
75
+ const trimmed = summary.trim();
76
+
77
+ if (trimmed) return trimmed;
78
+ if (event === "APPROVE") return undefined;
79
+
80
+ return "Review submitted with cueloop.";
81
+ }
82
+
83
+ function reviewPublicationId(
84
+ session: Thread,
85
+ event: GitHubReviewEvent,
86
+ commentIds: string[],
87
+ ): string {
88
+ const fingerprint = JSON.stringify({
89
+ sessionId: session.id,
90
+ messageId: session.message?.id,
91
+ event,
92
+ head: session.artifact.meta.prHeadSha,
93
+ comments: commentIds.toSorted(),
94
+ });
95
+
96
+ return `github-review:${createHash("sha256").update(fingerprint).digest("hex")}`;
97
+ }
98
+
99
+ async function reanchorReviewComments(
100
+ session: Thread,
101
+ comments: Annotation[],
102
+ ): Promise<Annotation[]> {
103
+ const { diffRows, resolveReviewAnchorRow } = await import("@cueloop/client");
104
+ const rows = diffRows(session.artifact.content);
105
+
106
+ return comments.map((annotation) => {
107
+ const finding = annotation.reviewComment!;
108
+ const resolveLine = (anchor: Annotation["anchor"]) => {
109
+ return resolveReviewAnchorRow(rows, anchor, finding.path, finding.side)?.row;
110
+ };
111
+ const matched = resolveLine(annotation.anchor);
112
+
113
+ if (!matched) {
114
+ throw new Error(
115
+ `finding ${annotation.id} is outdated or ambiguous - refresh the finding before posting`,
116
+ );
117
+ }
118
+ const line = finding.side === "RIGHT" ? matched.newLine! : matched.oldLine!;
119
+ let startLine: number | undefined;
120
+
121
+ if (finding.startLine !== undefined) {
122
+ if (!finding.startAnchor) {
123
+ throw new Error(
124
+ `finding ${annotation.id} has no start anchor - refresh the finding before posting`,
125
+ );
126
+ }
127
+ const matchedStart = resolveLine(finding.startAnchor);
128
+
129
+ if (!matchedStart) {
130
+ throw new Error(
131
+ `finding ${annotation.id} start is outdated or ambiguous - refresh the finding before posting`,
132
+ );
133
+ }
134
+ startLine = finding.side === "RIGHT" ? matchedStart.newLine! : matchedStart.oldLine!;
135
+
136
+ if (startLine >= line) {
137
+ throw new Error(
138
+ `finding ${annotation.id} range is outdated - refresh the finding before posting`,
139
+ );
140
+ }
141
+ }
142
+
143
+ return {
144
+ ...annotation,
145
+ reviewComment: {
146
+ ...finding,
147
+ line,
148
+ startLine,
149
+ },
150
+ };
151
+ });
152
+ }
153
+
154
+ interface ReviewFindingInput {
155
+ path: string;
156
+ side: "LEFT" | "RIGHT";
157
+ severity: ReviewSeverity;
158
+ line: number;
159
+ startLine?: number;
160
+ title: string;
161
+ body: string;
162
+ suggestion?: string;
163
+ prompt?: string;
164
+ id?: string;
165
+ }
166
+
167
+ function reviewFindingInput(flags: Record<string, string | boolean>): ReviewFindingInput {
168
+ const side = requiredFlag(flags, "side").toUpperCase();
169
+ const severity = requiredFlag(flags, "severity");
170
+ const line = Number(requiredFlag(flags, "line"));
171
+ const startLineFlag = stringFlag(flags, "start-line");
172
+ const startLine = startLineFlag === undefined ? undefined : Number(startLineFlag);
173
+ const body = textFlag(flags, "body", "body-file")?.trim();
174
+ const suggestion = textFlag(flags, "suggestion", "suggestion-file");
175
+
176
+ if (!body) throw new Error("--body or --body-file is required");
177
+ if (side !== "LEFT" && side !== "RIGHT") throw new Error("--side must be LEFT or RIGHT");
178
+ if (!isReviewSeverity(severity)) throw new Error("--severity must be p0, p1, or p2");
179
+ if (!Number.isInteger(line) || line < 1) throw new Error("--line must be a positive integer");
180
+ if (startLine !== undefined && (!Number.isInteger(startLine) || startLine < 1))
181
+ throw new Error("--start-line must be a positive integer");
182
+ if (suggestion?.trim() && side !== "RIGHT")
183
+ throw new Error("suggestions must target the RIGHT side of a diff");
184
+
185
+ return {
186
+ path: requiredFlag(flags, "path"),
187
+ side,
188
+ severity,
189
+ line,
190
+ startLine,
191
+ title: requiredFlag(flags, "title"),
192
+ body,
193
+ suggestion,
194
+ prompt: textFlag(flags, "prompt", "prompt-file"),
195
+ id: stringFlag(flags, "id"),
196
+ };
197
+ }
198
+
199
+ async function reviewFindingAnnotation(
200
+ session: Thread,
201
+ flags: Record<string, string | boolean>,
202
+ ): Promise<Omit<Annotation, "createdAt">> {
203
+ const input = reviewFindingInput(flags);
204
+ const { diffRowAnchor, diffRows, fileRowRange } = await import("@cueloop/client");
205
+ const rows = diffRows(session.artifact.content);
206
+ const rowIndex = rows.findIndex(
207
+ (candidate) =>
208
+ candidate.file === input.path &&
209
+ (input.side === "RIGHT"
210
+ ? candidate.newLine === input.line
211
+ : candidate.oldLine === input.line) &&
212
+ (candidate.kind === "add" || candidate.kind === "del" || candidate.kind === "ctx"),
213
+ );
214
+
215
+ if (rowIndex === -1)
216
+ throw new Error(
217
+ `${input.path}:${input.line} is not present on the ${input.side} side of this diff`,
218
+ );
219
+ const fileRange = fileRowRange(rows, input.path)!;
220
+ const fileRows = rows.slice(fileRange.start, fileRange.end);
221
+ let startAnchor: Annotation["anchor"] | undefined;
222
+
223
+ if (input.startLine !== undefined) {
224
+ if (input.startLine >= input.line) throw new Error("--start-line must be before --line");
225
+ const startRowIndex = rows.findIndex(
226
+ (candidate) =>
227
+ candidate.file === input.path &&
228
+ (input.side === "RIGHT"
229
+ ? candidate.newLine === input.startLine
230
+ : candidate.oldLine === input.startLine),
231
+ );
232
+
233
+ if (startRowIndex === -1)
234
+ throw new Error(
235
+ `${input.path}:${input.startLine} is not present on the ${input.side} side of this diff`,
236
+ );
237
+ startAnchor = diffRowAnchor(fileRows, startRowIndex - fileRange.start);
238
+ }
239
+ const id = input.id ?? nextFindingId(session.annotations);
240
+ const existing = session.annotations.find((annotation) => annotation.id === id);
241
+
242
+ if (existing && !isAgentReviewComment(existing)) {
243
+ throw new Error(`comment ID ${id} already belongs to another reviewer`);
244
+ }
245
+
246
+ return {
247
+ id,
248
+ kind: "comment",
249
+ anchor: diffRowAnchor(fileRows, rowIndex - fileRange.start),
250
+ target: { kind: "file", path: input.path, rev: input.side === "RIGHT" ? "worktree" : "head" },
251
+ body: input.body,
252
+ author: "agent",
253
+ reviewComment: {
254
+ severity: input.severity,
255
+ title: input.title,
256
+ path: input.path,
257
+ line: input.line,
258
+ startLine: input.startLine,
259
+ startAnchor,
260
+ side: input.side,
261
+ suggestion: input.suggestion,
262
+ prompt: input.prompt,
263
+ },
264
+ };
265
+ }
266
+
267
+ export async function reviewCommentCommand(argv: string[]): Promise<number> {
268
+ const { positional, flags } = parseArgs(argv);
269
+ const sessionId = positional[0];
270
+
271
+ if (!sessionId) {
272
+ console.error(
273
+ "usage: cueloop review-comment <session-id> --path <file> --line <n> --side LEFT|RIGHT --severity p0|p1|p2 --title <title> --body-file <path>",
274
+ );
275
+
276
+ return 2;
277
+ }
278
+ try {
279
+ const client = await DaemonClient.connect({ autostart: true, role: "agent", author: "agent" });
280
+
281
+ try {
282
+ const session = await client.sessionGet(sessionId);
283
+ const annotation = await reviewFindingAnnotation(session, flags);
284
+
285
+ await client.sessionComment(sessionId, annotation, "agent");
286
+ console.log(annotation.id);
287
+ } finally {
288
+ client.close();
289
+ }
290
+ } catch (error) {
291
+ console.error(error instanceof Error ? error.message : String(error));
292
+
293
+ return 1;
294
+ }
295
+
296
+ return 0;
297
+ }
17
298
 
18
299
  /** The gh binary is injectable so tests can stub it. */
19
300
  function ghBin(): string {
@@ -32,11 +313,11 @@ export async function reviewCommand(argv: string[]): Promise<number> {
32
313
  const pr = positional[0];
33
314
 
34
315
  if (!pr) {
35
- console.error("usage: cueloop review <pr> [--no-tui]");
316
+ console.error("usage: cueloop review <pr> [--head-sha <sha>] [--no-tui]");
36
317
 
37
318
  return 2;
38
319
  }
39
- let imported: { content: string; title: string };
320
+ let imported: Awaited<ReturnType<ReturnType<typeof forge>["importPullRequest"]>>;
40
321
 
41
322
  try {
42
323
  imported = await forge().importPullRequest(pr);
@@ -45,6 +326,15 @@ export async function reviewCommand(argv: string[]): Promise<number> {
45
326
 
46
327
  return 1;
47
328
  }
329
+ const expectedHeadSha = stringFlag(flags, "head-sha");
330
+
331
+ if (expectedHeadSha && imported.pullRequest.headRefOid !== expectedHeadSha) {
332
+ console.error(
333
+ `pull request head changed: reviewed ${expectedHeadSha}, current ${imported.pullRequest.headRefOid}; review the current head before opening a Thread`,
334
+ );
335
+
336
+ return 1;
337
+ }
48
338
  const client = await DaemonClient.connect({ autostart: true });
49
339
  // A PR diff is a partial patch with no full file contents, so no `files` here;
50
340
  // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
@@ -53,6 +343,12 @@ export async function reviewCommand(argv: string[]): Promise<number> {
53
343
  content: imported.content,
54
344
  title: imported.title,
55
345
  pr,
346
+ prBrief:
347
+ textFlag(flags, "brief", "brief-file") ??
348
+ pullRequestBrief(imported.title, imported.pullRequest.body),
349
+ prBaseSha: imported.pullRequest.baseRefOid,
350
+ prHeadSha: imported.pullRequest.headRefOid,
351
+ prUrl: imported.pullRequest.url,
56
352
  });
57
353
  const session = review.session;
58
354
 
@@ -64,64 +360,160 @@ export async function reviewCommand(argv: string[]): Promise<number> {
64
360
  return 0;
65
361
  }
66
362
 
67
- const { runClient, reviewLayout } = await import("@cueloop/client");
363
+ const { runClient, pullRequestReviewLayout } = await import("@cueloop/client");
364
+
365
+ await runClient({ sessionId: session.id, layout: pullRequestReviewLayout() });
68
366
 
69
- await runClient({ sessionId: session.id, layout: reviewLayout() });
367
+ return 0;
368
+ }
70
369
 
71
- const after = await getSession(session.id);
370
+ export async function reviewConfigCommand(): Promise<number> {
371
+ const { loadConfig } = await import("@cueloop/client");
372
+ const config = loadConfig();
72
373
 
73
- if (after.status !== "resolved" || !after.message) {
74
- console.log(`session ${session.id} is unresolved - nothing was posted to PR ${pr}`);
374
+ console.log(JSON.stringify(config.review));
75
375
 
76
- return 0;
376
+ return 0;
377
+ }
378
+
379
+ export async function reviewOpenCommand(argv: string[]): Promise<number> {
380
+ const sessionId = argv[0];
381
+
382
+ if (!sessionId) {
383
+ console.error("usage: cueloop review-open <session-id>");
384
+
385
+ return 2;
77
386
  }
387
+ const client = await DaemonClient.connect({ autostart: true });
388
+
389
+ try {
390
+ const session = await client.sessionGet(sessionId);
391
+ const result = await createTerminalThreadSurfacePort(client).openThreads(
392
+ sessionId,
393
+ "changes",
394
+ session,
395
+ );
78
396
 
79
- return postMessage(after, pr);
397
+ if (result !== "opened" && result !== "focused") {
398
+ console.error(`open manually: cueloop ${sessionId}`);
399
+
400
+ return 1;
401
+ }
402
+ } finally {
403
+ client.close();
404
+ }
405
+
406
+ return 0;
80
407
  }
81
408
 
82
409
  export async function reviewPostCommand(argv: string[]): Promise<number> {
83
- const { positional } = parseArgs(argv);
410
+ const { positional, flags } = parseArgs(argv);
84
411
  const sessionId = positional[0];
85
- const pr = positional[1];
86
412
 
87
- if (!sessionId || !pr) {
88
- console.error("usage: cueloop review-post <session-id> <pr>");
413
+ if (!sessionId) {
414
+ console.error(
415
+ "usage: cueloop review-post <session-id> [--comments C1,C2] [--event comment|approve|request-changes]",
416
+ );
89
417
 
90
418
  return 2;
91
419
  }
92
420
  const session = await getSession(sessionId);
421
+ const pr = session.artifact.meta.pr;
93
422
 
94
- if (session.status !== "resolved" || !session.message) {
95
- console.error(`session ${sessionId} is unresolved - nothing was posted to PR ${pr}`);
423
+ if (
424
+ !pr ||
425
+ !session.artifact.meta.prUrl ||
426
+ !session.artifact.meta.prBaseSha ||
427
+ !session.artifact.meta.prHeadSha
428
+ ) {
429
+ console.error(`session ${sessionId} is not a GitHub pull request review`);
96
430
 
97
431
  return 1;
98
432
  }
433
+ if (session.status !== "resolved" || !session.message) {
434
+ console.error(`session ${sessionId} is unresolved - approve or request changes before posting`);
99
435
 
100
- return postMessage(session, pr);
101
- }
436
+ return 1;
437
+ }
438
+ const selectedIds = new Set(
439
+ (stringFlag(flags, "comments") ?? "")
440
+ .split(",")
441
+ .map((value) => value.trim())
442
+ .filter(Boolean),
443
+ );
444
+ const selectedComments = session.annotations.filter(
445
+ (annotation) =>
446
+ isAgentReviewComment(annotation) &&
447
+ annotation.resolution === undefined &&
448
+ (selectedIds.size === 0 || selectedIds.has(annotation.id)),
449
+ );
102
450
 
103
- async function getSession(id: string): Promise<Thread> {
104
- const client = await DaemonClient.connect({ autostart: true });
451
+ if (selectedIds.size > 0 && selectedComments.length !== selectedIds.size) {
452
+ console.error("one or more selected comment IDs are missing or not publishable agent findings");
105
453
 
106
- try {
107
- return await client.sessionGet(id);
108
- } finally {
109
- client.close();
454
+ return 1;
110
455
  }
111
- }
456
+ const eventFlag = stringFlag(flags, "event") ?? "comment";
457
+ const event = reviewEvent(eventFlag);
112
458
 
113
- /** Post the resolved session's message to the PR: feedback.md is the review body. */
114
- async function postMessage(session: Thread, pr: string): Promise<number> {
115
- const message = session.message!;
459
+ if (!event) {
460
+ console.error("--event must be comment, approve, or request-changes");
461
+
462
+ return 2;
463
+ }
116
464
 
117
465
  try {
118
- await forge().postPullRequestMessage(pr, message);
466
+ const forgePort = forge();
467
+ const remote = await forgePort.readPullRequest(
468
+ session.artifact.meta.prUrl,
469
+ session.artifact.meta.cwd,
470
+ );
471
+
472
+ if (
473
+ remote.baseRefOid !== session.artifact.meta.prBaseSha ||
474
+ remote.headRefOid !== session.artifact.meta.prHeadSha
475
+ ) {
476
+ throw new Error("pull request changed since this review - refresh before posting");
477
+ }
478
+ const comments = await reanchorReviewComments(session, selectedComments);
479
+ const number = Number(new URL(session.artifact.meta.prUrl).pathname.split("/").at(-1));
480
+
481
+ if (!Number.isInteger(number)) throw new Error("pull request URL has no numeric PR number");
482
+ const posted = await forgePort.postPullRequestComments({
483
+ publicationId: reviewPublicationId(
484
+ session,
485
+ event,
486
+ comments.map((comment) => comment.id),
487
+ ),
488
+ pullRequest: {
489
+ number,
490
+ url: session.artifact.meta.prUrl,
491
+ headRefOid: session.artifact.meta.prHeadSha,
492
+ },
493
+ comments,
494
+ event,
495
+ body: reviewSummary(event, session.message.summary),
496
+ cwd: session.artifact.meta.cwd,
497
+ });
498
+
499
+ console.log(
500
+ `posted ${comments.length} review comment${comments.length === 1 ? "" : "s"} to PR ${pr}${posted.url ? `: ${posted.url}` : ""}`,
501
+ );
119
502
  } catch (error) {
120
503
  console.error(error instanceof Error ? error.message : String(error));
121
504
 
122
505
  return 1;
123
506
  }
124
- console.log(`posted ${message.outcome} review to PR ${pr} (session ${session.id})`);
125
507
 
126
508
  return 0;
127
509
  }
510
+
511
+ async function getSession(id: string): Promise<Thread> {
512
+ const client = await DaemonClient.connect({ autostart: true });
513
+
514
+ try {
515
+ return await client.sessionGet(id);
516
+ } finally {
517
+ client.close();
518
+ }
519
+ }