cueloop 0.1.0-alpha.85 → 0.1.0-alpha.87
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 +6 -6
- package/src/main.ts +5 -1
- 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.
|
|
3
|
+
"version": "0.1.0-alpha.87",
|
|
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,13 +30,13 @@
|
|
|
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.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.87",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.87",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.87",
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.87",
|
|
37
37
|
"@modelcontextprotocol/server": "2.0.0",
|
|
38
38
|
"@valibot/to-json-schema": "1.4.0",
|
|
39
|
-
"valibot": "^1.
|
|
39
|
+
"valibot": "^1.5.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"bun": ">=1.3.0"
|
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>
|
|
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)
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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, body?: string): string | undefined {
|
|
75
|
+
const trimmed = body?.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:
|
|
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,
|
|
363
|
+
const { runClient, pullRequestReviewLayout } = await import("@cueloop/client");
|
|
68
364
|
|
|
69
|
-
await runClient({ sessionId: session.id, layout:
|
|
365
|
+
await runClient({ sessionId: session.id, layout: pullRequestReviewLayout() });
|
|
70
366
|
|
|
71
|
-
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
72
369
|
|
|
73
|
-
|
|
74
|
-
|
|
370
|
+
export async function reviewConfigCommand(): Promise<number> {
|
|
371
|
+
const { loadConfig } = await import("@cueloop/client");
|
|
372
|
+
const config = loadConfig();
|
|
75
373
|
|
|
76
|
-
|
|
374
|
+
console.log(JSON.stringify(config.review));
|
|
375
|
+
|
|
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 });
|
|
78
388
|
|
|
79
|
-
|
|
389
|
+
try {
|
|
390
|
+
const session = await client.sessionGet(sessionId);
|
|
391
|
+
const result = await createTerminalThreadSurfacePort(client).openThreads(
|
|
392
|
+
sessionId,
|
|
393
|
+
"changes",
|
|
394
|
+
session,
|
|
395
|
+
);
|
|
396
|
+
|
|
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
|
|
88
|
-
console.error(
|
|
413
|
+
if (!sessionId) {
|
|
414
|
+
console.error(
|
|
415
|
+
"usage: cueloop review-post <session-id> [--comments C1,C2] [--event comment|approve|request-changes] [--body <text>|--body-file <path>]",
|
|
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 (
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
const message = session.message!;
|
|
459
|
+
if (!event) {
|
|
460
|
+
console.error("--event must be comment, approve, or request-changes");
|
|
116
461
|
|
|
462
|
+
return 2;
|
|
463
|
+
}
|
|
117
464
|
try {
|
|
118
|
-
|
|
465
|
+
const body = reviewSummary(event, textFlag(flags, "body", "body-file"));
|
|
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,
|
|
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
|
+
}
|