@makerx/yoke 0.4.1 → 0.6.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/README.md +17 -3
- package/dist/cli.js +1548 -23
- package/package.json +6 -3
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Command } from "commander";
|
|
|
7
7
|
// package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@makerx/yoke",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.6.0",
|
|
11
11
|
description: "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
|
|
12
12
|
license: "MIT",
|
|
13
13
|
author: "MakerX",
|
|
@@ -41,7 +41,9 @@ var package_default = {
|
|
|
41
41
|
prepublishOnly: "npm run build"
|
|
42
42
|
},
|
|
43
43
|
dependencies: {
|
|
44
|
-
commander: "^15.0.0"
|
|
44
|
+
commander: "^15.0.0",
|
|
45
|
+
"log-update": "^8.0.0",
|
|
46
|
+
zod: "^4.6.7"
|
|
45
47
|
},
|
|
46
48
|
devDependencies: {
|
|
47
49
|
"@makerx/verify": "^1.3.0",
|
|
@@ -52,13 +54,161 @@ var package_default = {
|
|
|
52
54
|
"oxlint-tsgolint": "^7.0.2004",
|
|
53
55
|
tsup: "^9.0.0",
|
|
54
56
|
typescript: "^6.0.3",
|
|
55
|
-
vitest: "^5.0.5"
|
|
57
|
+
vitest: "^5.0.5",
|
|
58
|
+
yaml: "^2.9.1"
|
|
56
59
|
},
|
|
57
60
|
engines: {
|
|
58
61
|
node: ">=24"
|
|
59
62
|
}
|
|
60
63
|
};
|
|
61
64
|
|
|
65
|
+
// src/features/config/settings.ts
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
function setting(description) {
|
|
68
|
+
return z.string().optional().meta({ description });
|
|
69
|
+
}
|
|
70
|
+
function secret(description) {
|
|
71
|
+
return z.string().optional().meta({ description, secret: true });
|
|
72
|
+
}
|
|
73
|
+
var providerHelp = "litellm or foundry. Unset: your own login";
|
|
74
|
+
var modelHelp = "See `yoke models`. Required with a provider";
|
|
75
|
+
var settingsSchema = z.object({
|
|
76
|
+
"litellm.baseUrl": setting("LiteLLM proxy URL"),
|
|
77
|
+
"litellm.apiKey": secret("LiteLLM API key. Env or --user config only"),
|
|
78
|
+
"foundry.baseUrl": setting("https://<resource>.services.ai.azure.com"),
|
|
79
|
+
"foundry.apiKey": secret("Foundry API key. Env or --user config only"),
|
|
80
|
+
"review.reviewer1.harness": setting("claude or codex. Default: claude"),
|
|
81
|
+
"review.reviewer1.provider": setting(providerHelp),
|
|
82
|
+
"review.reviewer1.model": setting(modelHelp),
|
|
83
|
+
"review.reviewer2.harness": setting("claude or codex. Default: codex"),
|
|
84
|
+
"review.reviewer2.provider": setting(providerHelp),
|
|
85
|
+
"review.reviewer2.model": setting(modelHelp),
|
|
86
|
+
"review.synthesis.harness": setting("claude or codex. Default: reviewer1 harness"),
|
|
87
|
+
"review.synthesis.provider": setting(providerHelp),
|
|
88
|
+
"review.synthesis.model": setting(modelHelp)
|
|
89
|
+
});
|
|
90
|
+
var settingKeys = settingsSchema.keyof().options;
|
|
91
|
+
function isSettingKey(key) {
|
|
92
|
+
return settingKeys.includes(key);
|
|
93
|
+
}
|
|
94
|
+
function isSecret(key) {
|
|
95
|
+
return settingsSchema.shape[key].meta()?.secret === true;
|
|
96
|
+
}
|
|
97
|
+
function settingDescription(key) {
|
|
98
|
+
return settingsSchema.shape[key].meta()?.description ?? "";
|
|
99
|
+
}
|
|
100
|
+
function snake(key) {
|
|
101
|
+
return key.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replaceAll(".", "_");
|
|
102
|
+
}
|
|
103
|
+
function settingEnv(key) {
|
|
104
|
+
return `YOKE_${snake(key).toUpperCase()}`;
|
|
105
|
+
}
|
|
106
|
+
function settingFlag(key) {
|
|
107
|
+
if (isSecret(key)) return void 0;
|
|
108
|
+
return `--${snake(key.replace(/^review\./, "")).toLowerCase().replaceAll("_", "-")}`;
|
|
109
|
+
}
|
|
110
|
+
function resolveSettings(flags, env, config2) {
|
|
111
|
+
const resolved = {};
|
|
112
|
+
for (const key of settingKeys) {
|
|
113
|
+
const flag = isSecret(key) ? void 0 : flags[key];
|
|
114
|
+
const value = [flag, env[settingEnv(key)], config2[key]].map((candidate) => candidate?.trim()).find(Boolean);
|
|
115
|
+
if (value) resolved[key] = value;
|
|
116
|
+
}
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
function displayValue(key, value) {
|
|
120
|
+
if (!value) return "<not set>";
|
|
121
|
+
return isSecret(key) ? "****" : value;
|
|
122
|
+
}
|
|
123
|
+
var gap = " ";
|
|
124
|
+
function wrap(text2, width) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
let line = "";
|
|
127
|
+
for (const word of text2.split(" ")) {
|
|
128
|
+
if (line && line.length + 1 + word.length > width) {
|
|
129
|
+
lines.push(line);
|
|
130
|
+
line = word;
|
|
131
|
+
} else line = line ? `${line} ${word}` : word;
|
|
132
|
+
}
|
|
133
|
+
return line ? [...lines, line] : lines;
|
|
134
|
+
}
|
|
135
|
+
function listSettings(settings, columns = Infinity) {
|
|
136
|
+
const rows = settingKeys.map((key) => ({
|
|
137
|
+
key,
|
|
138
|
+
value: displayValue(key, settings[key]),
|
|
139
|
+
description: settingDescription(key)
|
|
140
|
+
}));
|
|
141
|
+
const keyWidth = Math.max(...rows.map(({ key }) => key.length));
|
|
142
|
+
const indent = " ".repeat(keyWidth + gap.length);
|
|
143
|
+
const blocks = rows.map(
|
|
144
|
+
({ key, value, description }) => [`${key.padEnd(keyWidth)}${gap}${value}`, ...wrap(description, columns - indent.length).map((line) => indent + line)].join("\n")
|
|
145
|
+
);
|
|
146
|
+
return blocks.join("\n\n");
|
|
147
|
+
}
|
|
148
|
+
function howToSet(key, placeholder = "<value>") {
|
|
149
|
+
const flag = settingFlag(key);
|
|
150
|
+
const ways = [...flag ? [`pass ${flag}`] : [], `set ${settingEnv(key)}`, `run \`yoke config set --user ${key} ${placeholder}\``];
|
|
151
|
+
return `${ways.slice(0, -1).join(", ")} or ${ways.at(-1)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/features/config/userConfig.ts
|
|
155
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
156
|
+
import { homedir } from "os";
|
|
157
|
+
import { dirname, join } from "path";
|
|
158
|
+
var ConfigError = class extends Error {
|
|
159
|
+
constructor(message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = "ConfigError";
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
function userConfigPath(env = process.env, home = homedir()) {
|
|
165
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "yoke", "config.json");
|
|
166
|
+
}
|
|
167
|
+
function isTree(value) {
|
|
168
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
function getPath(tree, path) {
|
|
171
|
+
return path.reduce((node, name) => isTree(node) ? node[name] : void 0, tree);
|
|
172
|
+
}
|
|
173
|
+
function setPath(tree, [name = "", ...rest], value) {
|
|
174
|
+
if (!rest.length) return { ...tree, [name]: value };
|
|
175
|
+
const child = tree[name];
|
|
176
|
+
return { ...tree, [name]: setPath(isTree(child) ? child : {}, rest, value) };
|
|
177
|
+
}
|
|
178
|
+
function readConfigFile(path) {
|
|
179
|
+
if (!existsSync(path)) return {};
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new ConfigError(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
185
|
+
}
|
|
186
|
+
if (!isTree(parsed)) throw new ConfigError(`${path} must be a JSON object`);
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
function readUserConfig(path) {
|
|
190
|
+
const file = readConfigFile(path);
|
|
191
|
+
const settings = {};
|
|
192
|
+
for (const key of settingKeys) {
|
|
193
|
+
const value = getPath(file, key.split("."));
|
|
194
|
+
if (typeof value === "string") settings[key] = value;
|
|
195
|
+
}
|
|
196
|
+
return settings;
|
|
197
|
+
}
|
|
198
|
+
function setSetting(key, value, user, path) {
|
|
199
|
+
if (!user)
|
|
200
|
+
throw new ConfigError(
|
|
201
|
+
"Repo-level config is not supported: it would put the API key in the repo. Pass --user to write the user-level yoke config."
|
|
202
|
+
);
|
|
203
|
+
if (!isSettingKey(key)) throw new ConfigError(`Unknown setting "${key}"; expected one of: ${settingKeys.join(", ")}`);
|
|
204
|
+
const file = setPath(readConfigFile(path), key.split("."), value);
|
|
205
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
206
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}
|
|
207
|
+
`, { mode: 384 });
|
|
208
|
+
chmodSync(path, 384);
|
|
209
|
+
return `set ${key} in ${path}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
62
212
|
// src/features/pr/guide.ts
|
|
63
213
|
var raiseGuide = `
|
|
64
214
|
Compose a short PR in the format below, get the user's approval in the terminal, push, then create it as a draft with \`yoke pr raise\`, which rejects a title or body that breaks the rules.
|
|
@@ -162,9 +312,9 @@ function parseBody(body) {
|
|
|
162
312
|
let inFence = false;
|
|
163
313
|
for (const line of body.split(/\r?\n/)) {
|
|
164
314
|
if (fencePattern.test(line)) inFence = !inFence;
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
167
|
-
sections.push({ name:
|
|
315
|
+
const heading2 = inFence ? null : headingPattern.exec(line);
|
|
316
|
+
if (heading2?.[1] && heading2[2]) {
|
|
317
|
+
sections.push({ name: heading2[2], level: heading2[1].length, content: "" });
|
|
168
318
|
continue;
|
|
169
319
|
}
|
|
170
320
|
const current = sections.at(-1);
|
|
@@ -242,31 +392,1321 @@ var PrValidationError = class extends Error {
|
|
|
242
392
|
violations;
|
|
243
393
|
};
|
|
244
394
|
var runGh = (args) => execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
245
|
-
function raisePr({ title, body, base, draft },
|
|
395
|
+
function raisePr({ title, body, base, draft }, run2 = runGh) {
|
|
246
396
|
const violations = validatePr(title, body);
|
|
247
397
|
if (violations.length) throw new PrValidationError(violations);
|
|
248
398
|
const args = ["pr", "create", "--title", title.trim(), "--body", body.trim()];
|
|
249
399
|
if (base) args.push("--base", base);
|
|
250
400
|
if (draft) args.push("--draft");
|
|
251
|
-
const output =
|
|
401
|
+
const output = run2(args).trim().split("\n");
|
|
252
402
|
return output.at(-1) ?? "";
|
|
253
403
|
}
|
|
254
404
|
|
|
405
|
+
// src/features/review/pr.ts
|
|
406
|
+
import { execFile } from "child_process";
|
|
407
|
+
import { join as join2 } from "path";
|
|
408
|
+
var ReviewError = class extends Error {
|
|
409
|
+
constructor(message) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.name = "ReviewError";
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var maxBuffer = 256 * 1024 * 1024;
|
|
415
|
+
var run = (command, args, cwd, input) => new Promise((resolve, reject) => {
|
|
416
|
+
const child = execFile(command, args, { cwd, encoding: "utf8", maxBuffer }, (error, stdout, stderr) => {
|
|
417
|
+
if (!error) return resolve(stdout);
|
|
418
|
+
const detail = stderr.trim();
|
|
419
|
+
reject(new ReviewError(`\`${command} ${args.join(" ")}\` failed${detail ? `: ${detail}` : ""}`));
|
|
420
|
+
});
|
|
421
|
+
child.stdin?.on("error", () => {
|
|
422
|
+
});
|
|
423
|
+
child.stdin?.end(input);
|
|
424
|
+
});
|
|
425
|
+
var fields = "number,url,title,baseRefName,baseRefOid,headRefName,headRefOid,files";
|
|
426
|
+
function isPullRequest(value) {
|
|
427
|
+
return typeof value === "object" && value !== null && "number" in value && "url" in value && "headRefOid" in value && "files" in value;
|
|
428
|
+
}
|
|
429
|
+
async function fetchPr(number, exec2 = run) {
|
|
430
|
+
if (number !== void 0 && !/^\d+$/.test(number)) throw new ReviewError(`PR number must be a positive integer, got "${number}"`);
|
|
431
|
+
const args = ["pr", "view", ...number ? [number] : [], "--json", fields];
|
|
432
|
+
const pr2 = JSON.parse(await exec2("gh", args));
|
|
433
|
+
if (!isPullRequest(pr2)) throw new ReviewError("`gh pr view` returned an unexpected response");
|
|
434
|
+
return pr2;
|
|
435
|
+
}
|
|
436
|
+
async function fetchDiff(pr2, exec2 = run) {
|
|
437
|
+
const diff = await exec2("gh", ["pr", "diff", String(pr2.number)]);
|
|
438
|
+
const current = await fetchPr(String(pr2.number), exec2);
|
|
439
|
+
if (current.headRefOid !== pr2.headRefOid || current.baseRefOid !== pr2.baseRefOid)
|
|
440
|
+
throw new ReviewError(`#${pr2.number} changed while its diff was being fetched; run the review again`);
|
|
441
|
+
return diff;
|
|
442
|
+
}
|
|
443
|
+
var commentFields = '{path, line: (.line // .original_line), author: (.user.login // "ghost"), body}';
|
|
444
|
+
var reviewFields = '{path: null, line: null, author: (.user.login // "ghost"), body}';
|
|
445
|
+
function isPriorComment(value) {
|
|
446
|
+
return typeof value === "object" && value !== null && "body" in value && typeof value.body === "string" && "author" in value;
|
|
447
|
+
}
|
|
448
|
+
function jsonLines(output) {
|
|
449
|
+
return output.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)).filter(isPriorComment);
|
|
450
|
+
}
|
|
451
|
+
async function fetchPriorComments(pr2, exec2 = run) {
|
|
452
|
+
const pulls = `repos/{owner}/{repo}/pulls/${pr2.number}`;
|
|
453
|
+
const [comments, reviews] = await Promise.all([
|
|
454
|
+
exec2("gh", ["api", "--paginate", `${pulls}/comments`, "--jq", `.[] | ${commentFields}`]),
|
|
455
|
+
exec2("gh", ["api", "--paginate", `${pulls}/reviews`, "--jq", `.[] | select(.body != "") | ${reviewFields}`])
|
|
456
|
+
]);
|
|
457
|
+
return [...jsonLines(comments), ...jsonLines(reviews)];
|
|
458
|
+
}
|
|
459
|
+
function reviewsPath(pr2) {
|
|
460
|
+
return `repos/{owner}/{repo}/pulls/${pr2.number}/reviews`;
|
|
461
|
+
}
|
|
462
|
+
function isPendingReview(value) {
|
|
463
|
+
return typeof value === "object" && value !== null && "id" in value && typeof value.id === "number" && "node_id" in value && typeof value.node_id === "string";
|
|
464
|
+
}
|
|
465
|
+
async function openPendingReview(pr2, exec2 = run) {
|
|
466
|
+
const args = ["api", "--method", "POST", reviewsPath(pr2), "--input", "-", "--jq", "{id, node_id}"];
|
|
467
|
+
const review = JSON.parse(await exec2("gh", args, void 0, JSON.stringify({ commit_id: pr2.headRefOid })));
|
|
468
|
+
if (!isPendingReview(review)) throw new ReviewError("Opening a pending review returned an unexpected response");
|
|
469
|
+
return { id: review.id, nodeId: review.node_id };
|
|
470
|
+
}
|
|
471
|
+
var addThread = `mutation($review: ID!, $path: String!, $line: Int!, $body: String!) {
|
|
472
|
+
addPullRequestReviewThread(input: {pullRequestReviewId: $review, path: $path, line: $line, side: RIGHT, body: $body}) { thread { id } }
|
|
473
|
+
}`;
|
|
474
|
+
async function addReviewComment(review, { path, line, body }, exec2 = run) {
|
|
475
|
+
const variables = [
|
|
476
|
+
"-f",
|
|
477
|
+
`query=${addThread}`,
|
|
478
|
+
"-f",
|
|
479
|
+
`review=${review.nodeId}`,
|
|
480
|
+
"-f",
|
|
481
|
+
`path=${path}`,
|
|
482
|
+
"-F",
|
|
483
|
+
`line=${line}`,
|
|
484
|
+
"-f",
|
|
485
|
+
`body=${body}`
|
|
486
|
+
];
|
|
487
|
+
await exec2("gh", ["api", "graphql", ...variables]);
|
|
488
|
+
}
|
|
489
|
+
async function submitPendingReview(pr2, review, body, exec2 = run) {
|
|
490
|
+
const args = ["api", "--method", "POST", `${reviewsPath(pr2)}/${review.id}/events`, "--input", "-", "--jq", ".html_url"];
|
|
491
|
+
return (await exec2("gh", args, void 0, JSON.stringify({ event: "COMMENT", body }))).trim();
|
|
492
|
+
}
|
|
493
|
+
async function discardPendingReview(pr2, review, exec2 = run) {
|
|
494
|
+
await exec2("gh", ["api", "--method", "DELETE", `${reviewsPath(pr2)}/${review.id}`]);
|
|
495
|
+
}
|
|
496
|
+
var github = {
|
|
497
|
+
open: (pr2) => openPendingReview(pr2),
|
|
498
|
+
add: (review, comment) => addReviewComment(review, comment),
|
|
499
|
+
submit: (pr2, review, body) => submitPendingReview(pr2, review, body),
|
|
500
|
+
discard: (pr2, review) => discardPendingReview(pr2, review)
|
|
501
|
+
};
|
|
502
|
+
function repoSlug(remoteUrl) {
|
|
503
|
+
return remoteUrl.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/").toLowerCase();
|
|
504
|
+
}
|
|
505
|
+
async function prRemote(pr2, root, exec2) {
|
|
506
|
+
const url = new URL(pr2.url);
|
|
507
|
+
const [, owner = "", repo = ""] = url.pathname.split("/");
|
|
508
|
+
const slug2 = `${owner}/${repo}`.toLowerCase();
|
|
509
|
+
const remotes = (await exec2("git", ["remote", "-v"], root)).split("\n").map((line) => line.split(/\s+/));
|
|
510
|
+
return remotes.find(([, remoteUrl]) => remoteUrl && repoSlug(remoteUrl) === slug2)?.[0] ?? `${url.origin}/${owner}/${repo}.git`;
|
|
511
|
+
}
|
|
512
|
+
async function checkoutHead(pr2, reviewDir, exec2 = run) {
|
|
513
|
+
const root = (await exec2("git", ["rev-parse", "--show-toplevel"])).trim();
|
|
514
|
+
const atHead = (await exec2("git", ["rev-parse", "HEAD"], root)).trim() === pr2.headRefOid;
|
|
515
|
+
if (atHead && !(await exec2("git", ["status", "--porcelain"], root)).trim()) return { cwd: root, remove: async () => {
|
|
516
|
+
} };
|
|
517
|
+
const tree = join2(reviewDir, "head");
|
|
518
|
+
await exec2("git", ["fetch", "--quiet", await prRemote(pr2, root, exec2), `pull/${pr2.number}/head`], root);
|
|
519
|
+
await exec2("git", ["worktree", "add", "--quiet", "--detach", tree, pr2.headRefOid], root);
|
|
520
|
+
return {
|
|
521
|
+
cwd: tree,
|
|
522
|
+
remove: async () => {
|
|
523
|
+
await exec2("git", ["worktree", "remove", "--force", tree], root);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/features/review/provider.ts
|
|
529
|
+
var providerKinds = ["litellm", "foundry"];
|
|
530
|
+
var harnesses = ["claude", "codex"];
|
|
531
|
+
function codexKeyEnv(kind) {
|
|
532
|
+
return `YOKE_${kind.toUpperCase()}_API_KEY`;
|
|
533
|
+
}
|
|
534
|
+
function isProviderKind(value) {
|
|
535
|
+
return providerKinds.includes(value);
|
|
536
|
+
}
|
|
537
|
+
function deriveEndpoints(kind, baseUrl) {
|
|
538
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
539
|
+
if (kind === "foundry") return { anthropic: `${base}/anthropic`, responses: `${base}/openai/v1` };
|
|
540
|
+
return { anthropic: base, responses: `${base}/v1` };
|
|
541
|
+
}
|
|
542
|
+
function isHarness(value) {
|
|
543
|
+
return harnesses.includes(value);
|
|
544
|
+
}
|
|
545
|
+
function ownAuthOverride({ harness, model }) {
|
|
546
|
+
return { args: harness === "codex" ? ["-m", model] : ["--model", model], env: {} };
|
|
547
|
+
}
|
|
548
|
+
function buildCodexOverride({ kind, apiKey }, endpoints, model) {
|
|
549
|
+
const keyEnv = codexKeyEnv(kind);
|
|
550
|
+
return {
|
|
551
|
+
args: [
|
|
552
|
+
"-c",
|
|
553
|
+
"model_providers.yoke.name=yoke",
|
|
554
|
+
"-c",
|
|
555
|
+
`model_providers.yoke.base_url=${endpoints.responses}`,
|
|
556
|
+
"-c",
|
|
557
|
+
`model_providers.yoke.env_key=${keyEnv}`,
|
|
558
|
+
"-c",
|
|
559
|
+
"model_providers.yoke.wire_api=responses",
|
|
560
|
+
"-c",
|
|
561
|
+
"model_provider=yoke",
|
|
562
|
+
"-m",
|
|
563
|
+
model
|
|
564
|
+
],
|
|
565
|
+
env: { [keyEnv]: apiKey }
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function claudeProviderEnv(kind, endpoints, apiKey) {
|
|
569
|
+
if (kind === "foundry")
|
|
570
|
+
return { CLAUDE_CODE_USE_FOUNDRY: "1", ANTHROPIC_FOUNDRY_BASE_URL: endpoints.anthropic, ANTHROPIC_FOUNDRY_API_KEY: apiKey };
|
|
571
|
+
return { ANTHROPIC_BASE_URL: endpoints.anthropic, ANTHROPIC_AUTH_TOKEN: apiKey };
|
|
572
|
+
}
|
|
573
|
+
function buildClaudeOverride(provider, endpoints, model) {
|
|
574
|
+
return {
|
|
575
|
+
args: ["--model", model],
|
|
576
|
+
env: {
|
|
577
|
+
...claudeProviderEnv(provider.kind, endpoints, provider.apiKey),
|
|
578
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
579
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
580
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
581
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
582
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function claudeAuthHeaders({ kind, apiKey }) {
|
|
587
|
+
if (kind === "foundry") return { "x-api-key": apiKey };
|
|
588
|
+
return { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey };
|
|
589
|
+
}
|
|
590
|
+
function buildProbe({ harness, model }, provider) {
|
|
591
|
+
const endpoints = deriveEndpoints(provider.kind, provider.baseUrl);
|
|
592
|
+
if (harness === "codex")
|
|
593
|
+
return {
|
|
594
|
+
url: `${endpoints.responses}/responses`,
|
|
595
|
+
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
596
|
+
body: { model, input: "ping", max_output_tokens: 16 }
|
|
597
|
+
};
|
|
598
|
+
return {
|
|
599
|
+
url: `${endpoints.anthropic}/v1/messages`,
|
|
600
|
+
headers: { ...claudeAuthHeaders(provider), "anthropic-version": "2023-06-01" },
|
|
601
|
+
body: { model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function buildOverride({ harness, model }, provider) {
|
|
605
|
+
const endpoints = deriveEndpoints(provider.kind, provider.baseUrl);
|
|
606
|
+
if (harness === "codex") return buildCodexOverride(provider, endpoints, model);
|
|
607
|
+
return buildClaudeOverride(provider, endpoints, model);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/features/review/slots.ts
|
|
611
|
+
var slotNames = ["reviewer1", "reviewer2", "synthesis"];
|
|
612
|
+
function readProvider(settings, kind) {
|
|
613
|
+
const baseUrlKey = `${kind}.baseUrl`;
|
|
614
|
+
const apiKeyKey = `${kind}.apiKey`;
|
|
615
|
+
const baseUrl = settings[baseUrlKey];
|
|
616
|
+
const apiKey = settings[apiKeyKey];
|
|
617
|
+
if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
|
|
618
|
+
return [
|
|
619
|
+
baseUrl ? null : `${baseUrlKey} is not set; ${howToSet(baseUrlKey, "<url>")}`,
|
|
620
|
+
apiKey ? null : `${apiKeyKey} is not set; ${howToSet(apiKeyKey, "<key>")}`
|
|
621
|
+
].filter((error) => error !== null);
|
|
622
|
+
}
|
|
623
|
+
function readSpec(settings, slot, errors) {
|
|
624
|
+
const keys = {
|
|
625
|
+
harness: `review.${slot}.harness`,
|
|
626
|
+
provider: `review.${slot}.provider`,
|
|
627
|
+
model: `review.${slot}.model`
|
|
628
|
+
};
|
|
629
|
+
const harness = settings[keys.harness];
|
|
630
|
+
const provider = settings[keys.provider];
|
|
631
|
+
const model = settings[keys.model];
|
|
632
|
+
if (!harness && !provider && !model) return void 0;
|
|
633
|
+
if (harness && !isHarness(harness))
|
|
634
|
+
errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
|
|
635
|
+
if (provider && !isProviderKind(provider))
|
|
636
|
+
errors.push(
|
|
637
|
+
`${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
|
|
638
|
+
);
|
|
639
|
+
if (provider && !model)
|
|
640
|
+
errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
|
|
641
|
+
return {
|
|
642
|
+
...harness && isHarness(harness) ? { harness } : {},
|
|
643
|
+
...provider && isProviderKind(provider) ? { provider } : {},
|
|
644
|
+
...model ? { model } : {}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
|
|
648
|
+
const harness = chosen ?? fallback;
|
|
649
|
+
if (!model) return { harness };
|
|
650
|
+
const slot = { harness, model };
|
|
651
|
+
const provider = kind && providers[kind];
|
|
652
|
+
if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
|
|
653
|
+
return { ...slot, override: ownAuthOverride(slot) };
|
|
654
|
+
}
|
|
655
|
+
function resolveSlots(settings) {
|
|
656
|
+
const specs = {};
|
|
657
|
+
const errors = [];
|
|
658
|
+
for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
|
|
659
|
+
const providers = {};
|
|
660
|
+
for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
|
|
661
|
+
const read = readProvider(settings, kind);
|
|
662
|
+
if (Array.isArray(read)) errors.push(...read);
|
|
663
|
+
else providers[kind] = read;
|
|
664
|
+
}
|
|
665
|
+
if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
666
|
+
const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
|
|
667
|
+
return {
|
|
668
|
+
reviewer1,
|
|
669
|
+
reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
|
|
670
|
+
synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/features/review/models.ts
|
|
675
|
+
var timeout = 6e4;
|
|
676
|
+
var maxBodyChars = 300;
|
|
677
|
+
var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
|
|
678
|
+
function modelsRequest({ kind, baseUrl, apiKey }) {
|
|
679
|
+
const url = `${deriveEndpoints(kind, baseUrl).responses}/models`;
|
|
680
|
+
return { url, headers: kind === "foundry" ? { "api-key": apiKey } : { Authorization: `Bearer ${apiKey}` } };
|
|
681
|
+
}
|
|
682
|
+
function isModelList(value) {
|
|
683
|
+
return typeof value === "object" && value !== null && "data" in value && Array.isArray(value.data) && value.data.every((model) => typeof model === "object" && model !== null && "id" in model && typeof model.id === "string");
|
|
684
|
+
}
|
|
685
|
+
async function get(url, headers, fetchModels) {
|
|
686
|
+
try {
|
|
687
|
+
return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
|
|
688
|
+
} catch (error) {
|
|
689
|
+
throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function providerModels(provider, fetchModels) {
|
|
693
|
+
const { url, headers } = modelsRequest(provider);
|
|
694
|
+
const response = await get(url, headers, fetchModels);
|
|
695
|
+
if (!response.ok)
|
|
696
|
+
throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
|
|
697
|
+
const body = await response.json().catch(() => void 0);
|
|
698
|
+
if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
|
|
699
|
+
return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
|
|
700
|
+
}
|
|
701
|
+
function isConfigured(settings, kind) {
|
|
702
|
+
return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`]);
|
|
703
|
+
}
|
|
704
|
+
async function listModels(settings, fetchModels = fetch) {
|
|
705
|
+
const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
|
|
706
|
+
if (!kinds.length)
|
|
707
|
+
throw new ReviewError(
|
|
708
|
+
`No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
|
|
709
|
+
);
|
|
710
|
+
const providers = kinds.map((kind) => readProvider(settings, kind));
|
|
711
|
+
const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
|
|
712
|
+
if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
713
|
+
const ready = providers.filter((provider) => !Array.isArray(provider));
|
|
714
|
+
const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
|
|
715
|
+
return lists.flat().toSorted();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/features/review/review.ts
|
|
719
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
720
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
721
|
+
import { join as join7 } from "path";
|
|
722
|
+
|
|
723
|
+
// src/features/review/findings.ts
|
|
724
|
+
var severities = ["blocker", "major", "minor", "nit"];
|
|
725
|
+
var sources = ["confirmed", "disputed", "reviewer1-only", "reviewer2-only", "already-raised"];
|
|
726
|
+
function oneOf(values, raw) {
|
|
727
|
+
const value = raw.toLowerCase();
|
|
728
|
+
return values.find((v) => v === value) ?? null;
|
|
729
|
+
}
|
|
730
|
+
function field(block, name) {
|
|
731
|
+
return block.match(new RegExp(`^-\\s+${name}:\\s*(.*)$`, "im"))?.[1]?.trim() ?? "";
|
|
732
|
+
}
|
|
733
|
+
function parseLocation(raw) {
|
|
734
|
+
const location = raw.replace(/^`+|`+$/g, "").trim();
|
|
735
|
+
if (!location || location.toLowerCase() === "n/a") return { file: null, line: null };
|
|
736
|
+
const match = location.match(/^(.+?):(\d+)(?:-\d+)?$/);
|
|
737
|
+
if (!match?.[1] || !match[2]) return { file: location, line: null };
|
|
738
|
+
return { file: match[1], line: Number(match[2]) };
|
|
739
|
+
}
|
|
740
|
+
function parseFinding(block) {
|
|
741
|
+
const [heading2 = "", ...rest] = block.split("\n");
|
|
742
|
+
const body = rest.join("\n");
|
|
743
|
+
return {
|
|
744
|
+
title: heading2.trim(),
|
|
745
|
+
severity: oneOf(severities, field(body, "Severity")),
|
|
746
|
+
source: oneOf(sources, field(body, "Source")),
|
|
747
|
+
...parseLocation(field(body, "Location")),
|
|
748
|
+
impact: field(body, "Impact"),
|
|
749
|
+
recommendation: field(body, "Recommendation")
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function parseSummary(markdown) {
|
|
753
|
+
return markdown.match(/^##\s+Summary\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m)?.[1]?.trim() ?? "";
|
|
754
|
+
}
|
|
755
|
+
function rank(finding) {
|
|
756
|
+
const severity = finding.severity ? severities.indexOf(finding.severity) : severities.length;
|
|
757
|
+
const source = finding.source ? sources.indexOf(finding.source) : sources.length;
|
|
758
|
+
return severity * (sources.length + 1) + source;
|
|
759
|
+
}
|
|
760
|
+
function parseSynthesis(markdown) {
|
|
761
|
+
const findings = markdown.split(/^###\s+Finding:\s*/m).slice(1).map(parseFinding);
|
|
762
|
+
return { summary: parseSummary(markdown), findings: findings.toSorted((a, b) => rank(a) - rank(b)) };
|
|
763
|
+
}
|
|
764
|
+
function formatLocation({ file, line }) {
|
|
765
|
+
if (!file) return "no location";
|
|
766
|
+
return line === null ? file : `${file}:${line}`;
|
|
767
|
+
}
|
|
768
|
+
function formatSynthesis({ summary, findings }) {
|
|
769
|
+
const lines = [summary || "No summary.", ""];
|
|
770
|
+
if (!findings.length) lines.push("No findings.");
|
|
771
|
+
for (const finding of findings) {
|
|
772
|
+
lines.push(`[${finding.severity ?? "unrated"}] ${formatLocation(finding)} \u2014 ${finding.title}`);
|
|
773
|
+
if (finding.source) lines.push(` Source: ${finding.source}`);
|
|
774
|
+
if (finding.impact) lines.push(` Impact: ${finding.impact}`);
|
|
775
|
+
if (finding.recommendation) lines.push(` Recommendation: ${finding.recommendation}`);
|
|
776
|
+
lines.push("");
|
|
777
|
+
}
|
|
778
|
+
return lines.join("\n").trimEnd();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/features/review/harness.ts
|
|
782
|
+
import { spawn } from "child_process";
|
|
783
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
784
|
+
|
|
785
|
+
// src/features/review/progress.ts
|
|
786
|
+
var maxSummary = 80;
|
|
787
|
+
function isJson(value) {
|
|
788
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
789
|
+
}
|
|
790
|
+
function truncate(value) {
|
|
791
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
792
|
+
return collapsed.length <= maxSummary ? collapsed : `${collapsed.slice(0, maxSummary - 1)}\u2026`;
|
|
793
|
+
}
|
|
794
|
+
function text(input, key) {
|
|
795
|
+
const value = input[key];
|
|
796
|
+
return typeof value === "string" ? value : void 0;
|
|
797
|
+
}
|
|
798
|
+
function toolSummary(input) {
|
|
799
|
+
if (!isJson(input)) return "";
|
|
800
|
+
const pattern = text(input, "pattern");
|
|
801
|
+
if (pattern) {
|
|
802
|
+
const scope = text(input, "path");
|
|
803
|
+
return scope ? `${pattern} in ${scope}` : pattern;
|
|
804
|
+
}
|
|
805
|
+
const found = text(input, "file_path") ?? text(input, "path") ?? text(input, "url");
|
|
806
|
+
if (found) return found;
|
|
807
|
+
const described = text(input, "command") ?? text(input, "query") ?? text(input, "description");
|
|
808
|
+
return described ? truncate(described) : "";
|
|
809
|
+
}
|
|
810
|
+
function describeTool(tool, summary) {
|
|
811
|
+
return summary ? `${tool} ${summary}` : tool;
|
|
812
|
+
}
|
|
813
|
+
function claudeEvent(event) {
|
|
814
|
+
if (event.type === "result" && typeof event.result === "string") return { kind: "final", text: event.result };
|
|
815
|
+
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") return { kind: "model", model: event.model };
|
|
816
|
+
const content = isJson(event.message) ? event.message.content : void 0;
|
|
817
|
+
if (event.type !== "assistant" || !Array.isArray(content)) return { kind: "ignore" };
|
|
818
|
+
const uses = content.filter((block) => isJson(block) && block.type === "tool_use");
|
|
819
|
+
if (!uses.length) return { kind: "ignore" };
|
|
820
|
+
return { kind: "activity", text: uses.map((use) => describeTool(text(use, "name") ?? "tool", toolSummary(use.input))).join(", ") };
|
|
821
|
+
}
|
|
822
|
+
function unwrapShell(command) {
|
|
823
|
+
const wrapped = command.match(/^\/bin\/(?:ba|z)?sh\s+-lc\s+["'](.*)["']\s*$/s);
|
|
824
|
+
return truncate(wrapped?.[1]?.replace(/\\"/g, '"') ?? command);
|
|
825
|
+
}
|
|
826
|
+
function codexError(event) {
|
|
827
|
+
if (event.type === "error") return text(event, "message");
|
|
828
|
+
if (event.type === "turn.failed" && isJson(event.error)) return text(event.error, "message");
|
|
829
|
+
return void 0;
|
|
830
|
+
}
|
|
831
|
+
function codexEvent(event) {
|
|
832
|
+
const message = codexError(event);
|
|
833
|
+
if (message) return { kind: "error", message };
|
|
834
|
+
const item = event.item;
|
|
835
|
+
if (event.type !== "item.started" || !isJson(item)) return { kind: "ignore" };
|
|
836
|
+
const command = text(item, "command");
|
|
837
|
+
if (item.type === "command_execution" && command) return { kind: "activity", text: describeTool("shell", unwrapShell(command)) };
|
|
838
|
+
return { kind: "ignore" };
|
|
839
|
+
}
|
|
840
|
+
function parseHarnessEvent(harness, line) {
|
|
841
|
+
let event;
|
|
842
|
+
try {
|
|
843
|
+
event = JSON.parse(line);
|
|
844
|
+
} catch {
|
|
845
|
+
return { kind: "ignore" };
|
|
846
|
+
}
|
|
847
|
+
if (!isJson(event)) return { kind: "ignore" };
|
|
848
|
+
return harness === "claude" ? claudeEvent(event) : codexEvent(event);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/features/review/harness.ts
|
|
852
|
+
var tailLines = 20;
|
|
853
|
+
function harnessArgs({ harness, reviewDir, outputPath, override }) {
|
|
854
|
+
const extra = override?.args ?? [];
|
|
855
|
+
if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
|
|
856
|
+
return [
|
|
857
|
+
"-p",
|
|
858
|
+
"--add-dir",
|
|
859
|
+
reviewDir,
|
|
860
|
+
"--disallowedTools",
|
|
861
|
+
"Edit,Write,NotebookEdit",
|
|
862
|
+
"--output-format",
|
|
863
|
+
"stream-json",
|
|
864
|
+
"--verbose",
|
|
865
|
+
...extra
|
|
866
|
+
];
|
|
867
|
+
}
|
|
868
|
+
function windowsQuote(arg) {
|
|
869
|
+
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
870
|
+
}
|
|
871
|
+
function tail(text2) {
|
|
872
|
+
return text2.trim().split("\n").slice(-tailLines).join("\n");
|
|
873
|
+
}
|
|
874
|
+
function spawnError(harness, error) {
|
|
875
|
+
if (error.code === "ENOENT") return `${harness} CLI not found on PATH; install it or sign in, then retry`;
|
|
876
|
+
return `${harness} failed to start: ${error.message}`;
|
|
877
|
+
}
|
|
878
|
+
function exitError(harness, code, output) {
|
|
879
|
+
const detail = tail(output);
|
|
880
|
+
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
881
|
+
}
|
|
882
|
+
function hasReview(path) {
|
|
883
|
+
return existsSync2(path) && readFileSync2(path, "utf8").trim().length > 0;
|
|
884
|
+
}
|
|
885
|
+
var runHarness = (run2) => new Promise((resolve) => {
|
|
886
|
+
const shell = process.platform === "win32";
|
|
887
|
+
const args = harnessArgs(run2);
|
|
888
|
+
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
889
|
+
cwd: run2.cwd,
|
|
890
|
+
env: { ...process.env, ...run2.override?.env },
|
|
891
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
892
|
+
shell
|
|
893
|
+
});
|
|
894
|
+
let pending = "";
|
|
895
|
+
let finalText = "";
|
|
896
|
+
let model;
|
|
897
|
+
let lastError = "";
|
|
898
|
+
let stderr = "";
|
|
899
|
+
const onLine = (line) => {
|
|
900
|
+
const event = parseHarnessEvent(run2.harness, line);
|
|
901
|
+
if (event.kind === "activity") run2.onProgress?.(event.text);
|
|
902
|
+
if (event.kind === "final") finalText = event.text;
|
|
903
|
+
if (event.kind === "model") model = event.model;
|
|
904
|
+
if (event.kind === "error") lastError = event.message;
|
|
905
|
+
};
|
|
906
|
+
child.stdout.setEncoding("utf8");
|
|
907
|
+
child.stderr.setEncoding("utf8");
|
|
908
|
+
child.stdout.on("data", (chunk) => {
|
|
909
|
+
const lines = (pending + chunk).split("\n");
|
|
910
|
+
pending = lines.pop() ?? "";
|
|
911
|
+
lines.forEach(onLine);
|
|
912
|
+
});
|
|
913
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
914
|
+
child.stdin.on("error", () => {
|
|
915
|
+
});
|
|
916
|
+
child.stdin.end(run2.prompt);
|
|
917
|
+
child.on("error", (error) => resolve({ ok: false, error: spawnError(run2.harness, error) }));
|
|
918
|
+
child.on("close", (code) => {
|
|
919
|
+
onLine(pending);
|
|
920
|
+
if (code !== 0)
|
|
921
|
+
return resolve({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
922
|
+
if (run2.harness === "claude") writeFileSync2(run2.outputPath, finalText);
|
|
923
|
+
if (!hasReview(run2.outputPath)) return resolve({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
924
|
+
resolve(model ? { ok: true, model } : { ok: true });
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// src/features/review/comments.ts
|
|
929
|
+
var markerPrefix = "<!-- yoke-review:";
|
|
930
|
+
function slug(text2) {
|
|
931
|
+
return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
932
|
+
}
|
|
933
|
+
function findingMarker({ file, title }) {
|
|
934
|
+
return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
|
|
935
|
+
}
|
|
936
|
+
function hasMarker(prior, marker) {
|
|
937
|
+
return prior.some((comment) => comment.body.includes(marker));
|
|
938
|
+
}
|
|
939
|
+
function withoutMarkers(body) {
|
|
940
|
+
return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
|
|
941
|
+
}
|
|
942
|
+
function heading({ path, line }) {
|
|
943
|
+
if (!path) return "### PR review";
|
|
944
|
+
return line === null ? `### ${path}` : `### ${path}:${line}`;
|
|
945
|
+
}
|
|
946
|
+
function formatPriorComments(prior) {
|
|
947
|
+
if (!prior.length) return "";
|
|
948
|
+
const blocks = prior.map((comment) => `${heading(comment)}
|
|
949
|
+
|
|
950
|
+
**${comment.author}**: ${withoutMarkers(comment.body)}`);
|
|
951
|
+
return `## Prior review comments
|
|
952
|
+
|
|
953
|
+
The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
|
|
954
|
+
|
|
955
|
+
${blocks.join("\n\n")}
|
|
956
|
+
`;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/features/review/request.ts
|
|
960
|
+
var fileHeader = /^\+\+\+ (.+)$/;
|
|
961
|
+
var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
962
|
+
var gutterWidth = 6;
|
|
963
|
+
function hunkLine(text2, hunk) {
|
|
964
|
+
if (text2.startsWith("-")) hunk.oldLeft--;
|
|
965
|
+
else if (text2.startsWith("+")) hunk.newLeft--;
|
|
966
|
+
else if (text2.startsWith(" ")) {
|
|
967
|
+
hunk.oldLeft--;
|
|
968
|
+
hunk.newLeft--;
|
|
969
|
+
} else return null;
|
|
970
|
+
return text2.startsWith("-") ? null : hunk.next++;
|
|
971
|
+
}
|
|
972
|
+
var escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
973
|
+
function escapedBytes(escape) {
|
|
974
|
+
const code = escape.slice(1);
|
|
975
|
+
if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
|
|
976
|
+
return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
|
|
977
|
+
}
|
|
978
|
+
function unquote(quoted) {
|
|
979
|
+
const parts = quoted.split(/(\\[0-7]{3}|\\.)/);
|
|
980
|
+
return Buffer.concat(parts.map((part, i) => i % 2 ? escapedBytes(part) : Buffer.from(part))).toString("utf8");
|
|
981
|
+
}
|
|
982
|
+
function diffPath(raw) {
|
|
983
|
+
const quoted = raw.match(/^"(.*)"$/)?.[1];
|
|
984
|
+
const path = quoted === void 0 ? raw : unquote(quoted);
|
|
985
|
+
return path.startsWith("b/") ? path.slice(2) : path;
|
|
986
|
+
}
|
|
987
|
+
function parseDiff(diff) {
|
|
988
|
+
let file = null;
|
|
989
|
+
const hunk = { next: 0, oldLeft: 0, newLeft: 0 };
|
|
990
|
+
return diff.split("\n").map((text2) => {
|
|
991
|
+
if (hunk.oldLeft > 0 || hunk.newLeft > 0) return { text: text2, file, line: hunkLine(text2, hunk) };
|
|
992
|
+
const header = text2.match(fileHeader)?.[1];
|
|
993
|
+
if (header) file = header === "/dev/null" ? null : diffPath(header);
|
|
994
|
+
const range = text2.match(hunkHeader);
|
|
995
|
+
if (range) Object.assign(hunk, { oldLeft: Number(range[1] ?? 1), next: Number(range[2]), newLeft: Number(range[3] ?? 1) });
|
|
996
|
+
return { text: text2, file, line: null };
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
function gutter(line) {
|
|
1000
|
+
return `${(line === null ? "" : String(line)).padStart(gutterWidth)} `;
|
|
1001
|
+
}
|
|
1002
|
+
function withNewFileLineNumbers(diff) {
|
|
1003
|
+
return parseDiff(diff).map(({ text: text2, line }) => gutter(line) + text2).join("\n");
|
|
1004
|
+
}
|
|
1005
|
+
function commentableLines(diff) {
|
|
1006
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1007
|
+
for (const { file, line } of parseDiff(diff)) {
|
|
1008
|
+
if (file === null || line === null) continue;
|
|
1009
|
+
const numbers = lines.get(file) ?? /* @__PURE__ */ new Set();
|
|
1010
|
+
lines.set(file, numbers.add(line));
|
|
1011
|
+
}
|
|
1012
|
+
return lines;
|
|
1013
|
+
}
|
|
1014
|
+
function buildRequest(pr2, diff, prior = []) {
|
|
1015
|
+
const files = pr2.files.map((f) => `- ${f.path}`).join("\n") || "(none)";
|
|
1016
|
+
const priorSection = formatPriorComments(prior);
|
|
1017
|
+
return `# Code review request
|
|
1018
|
+
|
|
1019
|
+
- PR: #${pr2.number} ${pr2.title}
|
|
1020
|
+
- Head: \`${pr2.headRefName}\` (\`${pr2.headRefOid}\`)
|
|
1021
|
+
- Base: \`${pr2.baseRefName}\` (\`${pr2.baseRefOid}\`)
|
|
1022
|
+
|
|
1023
|
+
## Changed files
|
|
1024
|
+
|
|
1025
|
+
${files}
|
|
1026
|
+
|
|
1027
|
+
## Diff
|
|
1028
|
+
|
|
1029
|
+
Each added or context line is prefixed with a left gutter showing its line number in the new file. When citing a finding as \`file:line\`, use that gutter number \u2014 do **not** count lines in this document. Removed lines (\`-\`) and headers have a blank gutter and cannot be commented on.
|
|
1030
|
+
|
|
1031
|
+
\`\`\`diff
|
|
1032
|
+
${withNewFileLineNumbers(diff.trimEnd())}
|
|
1033
|
+
\`\`\`
|
|
1034
|
+
${priorSection && `
|
|
1035
|
+
${priorSection}`}`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// src/features/review/post.ts
|
|
1039
|
+
function isAlreadyRaised(finding, prior) {
|
|
1040
|
+
return finding.source === "already-raised" || hasMarker(prior, findingMarker(finding));
|
|
1041
|
+
}
|
|
1042
|
+
function details(finding) {
|
|
1043
|
+
return [
|
|
1044
|
+
...finding.impact ? [`Impact: ${finding.impact}`] : [],
|
|
1045
|
+
...finding.recommendation ? [`Recommendation: ${finding.recommendation}`] : []
|
|
1046
|
+
];
|
|
1047
|
+
}
|
|
1048
|
+
function commentBody(finding) {
|
|
1049
|
+
return [`**${finding.severity ?? "finding"}: ${finding.title}**`, ...details(finding), findingMarker(finding)].join("\n\n");
|
|
1050
|
+
}
|
|
1051
|
+
function planPost({ findings }, diff, prior) {
|
|
1052
|
+
const lines = commentableLines(diff);
|
|
1053
|
+
const plan = { comments: [], outside: [], skipped: [] };
|
|
1054
|
+
for (const finding of findings) {
|
|
1055
|
+
const { file, line } = finding;
|
|
1056
|
+
if (isAlreadyRaised(finding, prior)) plan.skipped.push(finding);
|
|
1057
|
+
else if (file && line !== null && lines.get(file)?.has(line))
|
|
1058
|
+
plan.comments.push({ path: file, line, body: commentBody(finding), finding });
|
|
1059
|
+
else plan.outside.push(finding);
|
|
1060
|
+
}
|
|
1061
|
+
return plan;
|
|
1062
|
+
}
|
|
1063
|
+
function outsideEntry(finding) {
|
|
1064
|
+
const location = finding.file ? `\`${formatLocation(finding)}\`` : "no location";
|
|
1065
|
+
const entry = [`- **${finding.severity ?? "finding"}** ${location} \u2014 ${finding.title}`, ...details(finding).map((d) => ` ${d}`)];
|
|
1066
|
+
return [...entry, ` ${findingMarker(finding)}`].join("\n");
|
|
1067
|
+
}
|
|
1068
|
+
function reviewBody(summary, outside, footer = "") {
|
|
1069
|
+
const sections = ["## Adversarial review", ...summary ? [summary] : []];
|
|
1070
|
+
if (outside.length) sections.push(`### Findings outside the diff
|
|
1071
|
+
|
|
1072
|
+
${outside.map(outsideEntry).join("\n")}`);
|
|
1073
|
+
if (footer) sections.push(footer);
|
|
1074
|
+
return sections.join("\n\n");
|
|
1075
|
+
}
|
|
1076
|
+
async function addComments(review, comments, { api, log }) {
|
|
1077
|
+
const rejected = [];
|
|
1078
|
+
for (const { finding, ...comment } of comments) {
|
|
1079
|
+
try {
|
|
1080
|
+
await api.add(review, comment);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
1083
|
+
rejected.push(finding);
|
|
1084
|
+
log(`GitHub rejected the comment on ${comment.path}:${comment.line}, so it goes in the review body: ${error.message}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return rejected;
|
|
1088
|
+
}
|
|
1089
|
+
async function postFindings(pr2, synthesis, diff, prior, deps) {
|
|
1090
|
+
const { api, log } = deps;
|
|
1091
|
+
const { comments, outside, skipped } = planPost(synthesis, diff, prior);
|
|
1092
|
+
if (skipped.length) log(`skipped ${skipped.length} finding(s) already raised on the PR`);
|
|
1093
|
+
if (!comments.length && !outside.length) return log("nothing new to post");
|
|
1094
|
+
const review = await api.open(pr2);
|
|
1095
|
+
try {
|
|
1096
|
+
const rejected = await addComments(review, comments, deps);
|
|
1097
|
+
const url = await api.submit(pr2, review, reviewBody(synthesis.summary, [...outside, ...rejected], deps.footer));
|
|
1098
|
+
log(`posted ${comments.length - rejected.length} inline comment(s) and ${outside.length + rejected.length} in the review body: ${url}`);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
try {
|
|
1101
|
+
await api.discard(pr2, review);
|
|
1102
|
+
} catch (discardError) {
|
|
1103
|
+
log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
|
|
1104
|
+
}
|
|
1105
|
+
throw error;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/features/review/preflight.ts
|
|
1110
|
+
import { execFile as execFile2 } from "child_process";
|
|
1111
|
+
var commandTimeout = 3e4;
|
|
1112
|
+
var probeTimeout = 6e4;
|
|
1113
|
+
var maxBodyChars2 = 300;
|
|
1114
|
+
function exec(harness, args) {
|
|
1115
|
+
return new Promise((resolve) => {
|
|
1116
|
+
const options = { encoding: "utf8", timeout: commandTimeout, shell: process.platform === "win32" };
|
|
1117
|
+
execFile2(harness, args, options, (error, stdout) => resolve({ ok: !error, stdout }));
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
async function claudeSignedIn() {
|
|
1121
|
+
const { ok, stdout } = await exec("claude", ["auth", "status", "--json"]);
|
|
1122
|
+
if (!ok) return false;
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(stdout);
|
|
1125
|
+
return typeof parsed === "object" && parsed !== null && "loggedIn" in parsed && parsed.loggedIn === true;
|
|
1126
|
+
} catch {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function reach({ url, headers, body }) {
|
|
1131
|
+
try {
|
|
1132
|
+
const response = await fetch(url, {
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
1135
|
+
body: JSON.stringify(body),
|
|
1136
|
+
signal: AbortSignal.timeout(probeTimeout)
|
|
1137
|
+
});
|
|
1138
|
+
if (response.ok) return void 0;
|
|
1139
|
+
const text2 = (await response.text()).trim().slice(0, maxBodyChars2);
|
|
1140
|
+
return `HTTP ${response.status}${text2 ? `: ${text2}` : ""}`;
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (!(error instanceof Error)) return String(error);
|
|
1143
|
+
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
1144
|
+
return cause ? `${error.message} (${cause})` : error.message;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
var checks = {
|
|
1148
|
+
onPath: async (harness) => (await exec(harness, ["--version"])).ok,
|
|
1149
|
+
signedIn: async (harness) => harness === "claude" ? claudeSignedIn() : (await exec("codex", ["login", "status"])).ok,
|
|
1150
|
+
reach
|
|
1151
|
+
};
|
|
1152
|
+
var loginCommand = { claude: "claude auth login", codex: "codex login" };
|
|
1153
|
+
function signedOut({ slot: { harness }, keys }) {
|
|
1154
|
+
const settings = keys.map((key) => `${key}.provider and ${key}.model`).join(", and ");
|
|
1155
|
+
return `${harness} is not signed in (${keys.join(", ")}); run \`${loginCommand[harness]}\`, or run it through LiteLLM or Foundry by setting ${settings} (see \`yoke models\`)`;
|
|
1156
|
+
}
|
|
1157
|
+
async function slotProblem(use, check) {
|
|
1158
|
+
const { harness, model, probe } = use.slot;
|
|
1159
|
+
if (!await check.onPath(harness)) return `${harness} CLI not found on PATH; install it`;
|
|
1160
|
+
if (!probe) return await check.signedIn(harness) ? void 0 : signedOut(use);
|
|
1161
|
+
const error = await check.reach(probe);
|
|
1162
|
+
return error && `${harness} model "${model}" at ${probe.url}: ${error}`;
|
|
1163
|
+
}
|
|
1164
|
+
function slotsToRun(slots) {
|
|
1165
|
+
const uses = [
|
|
1166
|
+
["review.reviewer1", slots.reviewer1],
|
|
1167
|
+
["review.reviewer2", slots.reviewer2],
|
|
1168
|
+
["review.synthesis", slots.synthesis ?? { harness: slots.reviewer1.harness }]
|
|
1169
|
+
];
|
|
1170
|
+
const distinct = /* @__PURE__ */ new Map();
|
|
1171
|
+
for (const [key, slot] of uses) {
|
|
1172
|
+
const id = `${slot.harness}:${slot.provider ?? "own"}:${slot.model ?? ""}`;
|
|
1173
|
+
const use = distinct.get(id);
|
|
1174
|
+
if (use) use.keys.push(key);
|
|
1175
|
+
else distinct.set(id, { slot, keys: [key] });
|
|
1176
|
+
}
|
|
1177
|
+
return [...distinct.values()];
|
|
1178
|
+
}
|
|
1179
|
+
async function assertViable(slots, check = checks) {
|
|
1180
|
+
const problems = await Promise.all(slotsToRun(slots).map((use) => slotProblem(use, check)));
|
|
1181
|
+
const errors = problems.filter((problem) => problem !== void 0);
|
|
1182
|
+
if (errors.length) throw new ReviewError(["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/features/review/reviewLog.ts
|
|
1186
|
+
import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
1187
|
+
import { join as join3 } from "path";
|
|
1188
|
+
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
1189
|
+
let path;
|
|
1190
|
+
let buffered = [];
|
|
1191
|
+
return {
|
|
1192
|
+
append: (line) => {
|
|
1193
|
+
if (path) appendFileSync(path, `${line}
|
|
1194
|
+
`);
|
|
1195
|
+
else buffered.push(line);
|
|
1196
|
+
},
|
|
1197
|
+
attach: (reviewDir) => {
|
|
1198
|
+
mkdirSync2(reviewDir, { recursive: true });
|
|
1199
|
+
path = join3(reviewDir, "review.log");
|
|
1200
|
+
appendFileSync(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
1201
|
+
`);
|
|
1202
|
+
buffered = [];
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function logged(ui, append) {
|
|
1207
|
+
const both = (show) => (line) => {
|
|
1208
|
+
append(line);
|
|
1209
|
+
show(line);
|
|
1210
|
+
};
|
|
1211
|
+
return {
|
|
1212
|
+
log: both(ui.log),
|
|
1213
|
+
track: (label) => {
|
|
1214
|
+
append(`${label}\u2026`);
|
|
1215
|
+
const status = ui.track(label);
|
|
1216
|
+
return {
|
|
1217
|
+
update: (activity) => {
|
|
1218
|
+
append(`${label}: ${activity}`);
|
|
1219
|
+
status.update(activity);
|
|
1220
|
+
},
|
|
1221
|
+
succeed: both(status.succeed),
|
|
1222
|
+
fail: both(status.fail)
|
|
1223
|
+
};
|
|
1224
|
+
},
|
|
1225
|
+
stop: ui.stop
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/features/review/run.ts
|
|
1230
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync5 } from "fs";
|
|
1231
|
+
import { join as join6 } from "path";
|
|
1232
|
+
|
|
1233
|
+
// src/features/review/cachedSlots.ts
|
|
1234
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1235
|
+
import { join as join4 } from "path";
|
|
1236
|
+
function slotsPath(reviewDir) {
|
|
1237
|
+
return join4(reviewDir, "slots.json");
|
|
1238
|
+
}
|
|
1239
|
+
function isCachedSlots(value) {
|
|
1240
|
+
return typeof value === "object" && value !== null && Object.values(value).every((slot) => typeof slot === "string");
|
|
1241
|
+
}
|
|
1242
|
+
function readCachedSlots(reviewDir) {
|
|
1243
|
+
const path = slotsPath(reviewDir);
|
|
1244
|
+
if (!existsSync3(path)) return {};
|
|
1245
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
1246
|
+
return isCachedSlots(parsed) ? parsed : {};
|
|
1247
|
+
}
|
|
1248
|
+
function cachedSlot(reviewDir, step) {
|
|
1249
|
+
return readCachedSlots(reviewDir)[step];
|
|
1250
|
+
}
|
|
1251
|
+
function recordSlot(reviewDir, step, slot) {
|
|
1252
|
+
const slots = readCachedSlots(reviewDir);
|
|
1253
|
+
slots[step] = slot;
|
|
1254
|
+
writeFileSync3(slotsPath(reviewDir), `${JSON.stringify(slots, null, 2)}
|
|
1255
|
+
`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/features/review/prompts.ts
|
|
1259
|
+
var severityRubric = `Severity rubric:
|
|
1260
|
+
- **blocker** \u2014 ships broken behaviour: crash, data loss, security hole, breaks the build or existing tests, or violates a stated requirement.
|
|
1261
|
+
- **major** \u2014 likely bug, missing error handling on a real failure mode, or a regression in existing behaviour. Not "this could be cleaner" or "this might be slow."
|
|
1262
|
+
- **minor** \u2014 narrow correctness or clarity issue with limited blast radius; worth fixing but not urgent.
|
|
1263
|
+
- **nit** \u2014 style, naming, micro-refactors, comment wording; reviewer would not block on it.
|
|
1264
|
+
|
|
1265
|
+
Default to the lower tier when uncertain. Code-style preferences, refactor suggestions, and "I would have written it differently" belong in nit \u2014 not major. A finding is only major if you can name a concrete failure mode or regression.`;
|
|
1266
|
+
var reviewerPrompt = `You are acting as a reviewer for a proposed code change made by another engineer. The full review request \u2014 PR, base, changed files, and unified diff \u2014 is in the request file whose absolute path is given below.
|
|
1267
|
+
|
|
1268
|
+
Your working directory is the repository under review at the PR's head, so you can read any file in it for context beyond the diff. Do not modify any file.
|
|
1269
|
+
|
|
1270
|
+
Read the request file, then produce a thorough code review in Markdown.
|
|
1271
|
+
|
|
1272
|
+
## When to flag a finding
|
|
1273
|
+
|
|
1274
|
+
A finding is worth raising only if all of the following hold:
|
|
1275
|
+
|
|
1276
|
+
1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code.
|
|
1277
|
+
2. The issue is discrete and actionable \u2014 not a vague observation about the codebase or a tangle of several things.
|
|
1278
|
+
3. Fixing it does not demand more rigour than the rest of the codebase already shows (e.g. don't ask for exhaustive input validation in a repo of one-off scripts).
|
|
1279
|
+
4. The issue was introduced by this change. Do not flag pre-existing bugs.
|
|
1280
|
+
5. The original author would likely fix it if made aware.
|
|
1281
|
+
6. It does not rely on unstated assumptions about the codebase or author's intent.
|
|
1282
|
+
7. You can name the concretely affected code path. Speculation that a change *might* disrupt something elsewhere is not enough \u2014 identify the other code that is provably affected.
|
|
1283
|
+
8. It is clearly not an intentional change by the author.
|
|
1284
|
+
|
|
1285
|
+
## Never recommend adding a code comment
|
|
1286
|
+
|
|
1287
|
+
Never raise a finding whose recommendation is to add a code comment \u2014 including a comment that restates what the code does, labels a block, or narrates why the change was made. The absence of comments is never a defect: do not ask for explanatory comments, doc comments, or TODOs, and do not list "missing comments" as an observation.
|
|
1288
|
+
|
|
1289
|
+
Two cases are still legitimate findings:
|
|
1290
|
+
|
|
1291
|
+
1. A comment this change made stale or wrong. Recommend removing or correcting it.
|
|
1292
|
+
2. Genuinely misleading code. You may raise it, but the recommendation must be a clearer name, a smaller function, or a test.
|
|
1293
|
+
|
|
1294
|
+
## Tautological and vacuous tests
|
|
1295
|
+
|
|
1296
|
+
Flag tests added or changed by this change that cannot fail when the behaviour they claim to cover breaks. Do not flag pre-existing tests the change left untouched. Patterns:
|
|
1297
|
+
|
|
1298
|
+
1. Asserting a mock returns what it was stubbed to return.
|
|
1299
|
+
2. Computing the expected value with the code under test, or with a copy of its logic.
|
|
1300
|
+
3. Asserting only that a mocked function was called, when that mock is the whole subject of the test.
|
|
1301
|
+
4. Any test that would still pass if the implementation were broken.
|
|
1302
|
+
5. No assertions, or only \`toBeDefined\`/truthy checks.
|
|
1303
|
+
6. Snapshots of mocked output.
|
|
1304
|
+
|
|
1305
|
+
Recommendation: assert against an independent expected value (a literal or a hand-derived result) or real behaviour, or delete the test. Severity is **minor** by default; **major** when the test is the sole coverage of a behaviour this change introduces or modifies, since that behaviour is then effectively untested.
|
|
1306
|
+
|
|
1307
|
+
## How to write the comment (Impact + Recommendation)
|
|
1308
|
+
|
|
1309
|
+
1. Make clear *why* the issue is a bug.
|
|
1310
|
+
2. Communicate severity accurately \u2014 do not inflate.
|
|
1311
|
+
3. Keep it brief: at most one paragraph of prose. Avoid line breaks inside the natural-language flow unless needed for a code fragment.
|
|
1312
|
+
4. Do not paste code chunks longer than 3 lines. Wrap short snippets in inline code or a fenced block.
|
|
1313
|
+
5. State explicitly the scenarios, environments, or inputs needed for the bug to manifest \u2014 and signal up front that severity depends on those factors.
|
|
1314
|
+
6. Tone is matter-of-fact: not accusatory, not gushing. Read as a helpful assistant, not a performative human reviewer.
|
|
1315
|
+
7. Write so the author grasps the point on first read.
|
|
1316
|
+
8. Avoid flattery and filler ("Great job\u2026", "Thanks for\u2026"). They are not useful to the author.
|
|
1317
|
+
|
|
1318
|
+
Ignore trivial style unless it obscures meaning or violates a documented standard. One finding per distinct issue.
|
|
1319
|
+
|
|
1320
|
+
## How many findings to return
|
|
1321
|
+
|
|
1322
|
+
List every finding that the original author would want to know about and fix. Do not stop at the first qualifying one. If nothing clears the bar above, return no findings \u2014 empty is better than padded.
|
|
1323
|
+
|
|
1324
|
+
## Output format
|
|
1325
|
+
|
|
1326
|
+
For each finding include:
|
|
1327
|
+
- Severity (blocker, major, minor, nit) \u2014 see rubric below
|
|
1328
|
+
- File and line (e.g. \`src/foo.ts:42\`) when the finding is tied to a specific location. Take the line number from the diff's left gutter (its line in the new file); never count lines in the request file.
|
|
1329
|
+
- Impact: what could go wrong, including the conditions under which it manifests
|
|
1330
|
+
- Recommendation: a concrete change
|
|
1331
|
+
|
|
1332
|
+
${severityRubric}
|
|
1333
|
+
|
|
1334
|
+
Group findings by severity. If you have no findings in a category, omit it. End with a short overall summary.
|
|
1335
|
+
|
|
1336
|
+
Output only the review Markdown. Do not include any preamble or commentary about the process.`;
|
|
1337
|
+
var synthesisPrompt = `You are consolidating independent code reviews of the same change. The original review request and the reviews are in the files listed below.
|
|
1338
|
+
|
|
1339
|
+
If a review is listed as missing, that reviewer failed. Work from the reviews you have; treat every finding as coming only from the reviewer that succeeded, do not mark anything \`confirmed\` or \`disputed\`, and say in the Summary that one review is missing.
|
|
1340
|
+
|
|
1341
|
+
Never name a reviewer by its model or tool in a title, Summary, Impact or Recommendation; write "one reviewer" or "both reviewers". Tool and command names that are part of the code under review are fine.
|
|
1342
|
+
|
|
1343
|
+
Read the request and the available reviews, deduplicate findings, and produce a single consolidated review in Markdown with this exact structure:
|
|
1344
|
+
|
|
1345
|
+
# Code review synthesis
|
|
1346
|
+
|
|
1347
|
+
## Summary
|
|
1348
|
+
|
|
1349
|
+
A 2-3 sentence overall summary of the change's quality and the most important risks.
|
|
1350
|
+
|
|
1351
|
+
## Findings
|
|
1352
|
+
|
|
1353
|
+
For each finding, emit one block in this exact format:
|
|
1354
|
+
|
|
1355
|
+
### Finding: <short title>
|
|
1356
|
+
- Severity: blocker | major | minor | nit
|
|
1357
|
+
- Source: confirmed | disputed | reviewer1-only | reviewer2-only | already-raised
|
|
1358
|
+
- Location: \`path/to/file.ext:LINE\` or \`n/a\` when not tied to a specific line
|
|
1359
|
+
- Impact: one sentence on what could go wrong
|
|
1360
|
+
- Recommendation: one or two sentences with a concrete change
|
|
1361
|
+
|
|
1362
|
+
${severityRubric} If a reviewer marked something major but the impact reads as taste or hypothetical, downgrade it.
|
|
1363
|
+
|
|
1364
|
+
## Keep tautological-test findings
|
|
1365
|
+
|
|
1366
|
+
A finding that a test added or changed by this change is tautological or vacuous \u2014 it asserts a mock returns its stub, computes its expected value with the code under test, only checks a mock was called, has no real assertions, snapshots mocked output, or would still pass if the implementation broke \u2014 is a legitimate defect, not taste. Keep it even when only one reviewer raised it, and do not downgrade it below minor. Keep major when the test is the sole coverage of a behaviour the change introduces or modifies.
|
|
1367
|
+
|
|
1368
|
+
## Drop comment-adding findings
|
|
1369
|
+
|
|
1370
|
+
Omit entirely any finding whose recommendation is to add a code comment, and any finding whose defect is that comments are absent \u2014 even when both reviewers agreed on it. Keep a finding about a comment this change made stale or wrong, and a finding about genuinely misleading code whose recommendation is a clearer name, a smaller function, or a test.
|
|
1371
|
+
|
|
1372
|
+
Rules:
|
|
1373
|
+
- \`confirmed\` = both reviewers raised it.
|
|
1374
|
+
- \`disputed\` = the reviewers disagreed on the diagnosis or fix.
|
|
1375
|
+
- \`reviewer1-only\` / \`reviewer2-only\` = only that reviewer raised it.
|
|
1376
|
+
- \`already-raised\` = a comment under "Prior review comments" in the request substantively covers this finding \u2014 same file, same defect or recommendation \u2014 even when that thread is resolved or outdated. Cosmetic overlap is not enough. Prefer \`already-raised\` over the other source values when it applies.
|
|
1377
|
+
- Order findings by severity (blocker, major, minor, nit), then by source (confirmed first).
|
|
1378
|
+
- If a finding has no specific file:line, set Location to \`n/a\` exactly.
|
|
1379
|
+
- Do not invent findings beyond what the reviews support.
|
|
1380
|
+
|
|
1381
|
+
Output only the consolidated Markdown. No preamble, no commentary about your process.`;
|
|
1382
|
+
function buildReviewerPrompt(requestPath) {
|
|
1383
|
+
return `${reviewerPrompt}
|
|
1384
|
+
|
|
1385
|
+
The review request is at: ${requestPath}
|
|
1386
|
+
`;
|
|
1387
|
+
}
|
|
1388
|
+
function buildSynthesisPrompt(requestPath, reviews) {
|
|
1389
|
+
const files = reviews.map(({ reviewer, path }) => `- ${reviewer} review: ${path ?? "missing"}`);
|
|
1390
|
+
return `${synthesisPrompt}
|
|
1391
|
+
|
|
1392
|
+
Files:
|
|
1393
|
+
- Request: ${requestPath}
|
|
1394
|
+
${files.join("\n")}
|
|
1395
|
+
`;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// src/features/review/usedModels.ts
|
|
1399
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1400
|
+
import { join as join5 } from "path";
|
|
1401
|
+
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1402
|
+
function modelsPath(reviewDir) {
|
|
1403
|
+
return join5(reviewDir, "models.json");
|
|
1404
|
+
}
|
|
1405
|
+
function isUsedModels(value) {
|
|
1406
|
+
return typeof value === "object" && value !== null && Object.values(value).every((model) => typeof model === "string");
|
|
1407
|
+
}
|
|
1408
|
+
function readUsedModels(reviewDir) {
|
|
1409
|
+
const path = modelsPath(reviewDir);
|
|
1410
|
+
if (!existsSync4(path)) return {};
|
|
1411
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
1412
|
+
return isUsedModels(parsed) ? parsed : {};
|
|
1413
|
+
}
|
|
1414
|
+
function recordUsedModel(reviewDir, step, harness, model) {
|
|
1415
|
+
const models = readUsedModels(reviewDir);
|
|
1416
|
+
models[step] = model ? model.split("/").at(-1) ?? model : `${harness} default`;
|
|
1417
|
+
writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
|
|
1418
|
+
`);
|
|
1419
|
+
}
|
|
1420
|
+
function forgetUsedModel(reviewDir, step) {
|
|
1421
|
+
const models = readUsedModels(reviewDir);
|
|
1422
|
+
if (!(step in models)) return;
|
|
1423
|
+
delete models[step];
|
|
1424
|
+
writeFileSync4(modelsPath(reviewDir), `${JSON.stringify(models, null, 2)}
|
|
1425
|
+
`);
|
|
1426
|
+
}
|
|
1427
|
+
function list(items) {
|
|
1428
|
+
return items.length > 1 ? `${items.slice(0, -1).join(", ")} and ${items.at(-1)}` : items[0] ?? "";
|
|
1429
|
+
}
|
|
1430
|
+
function modelsFooter({ reviewer1, reviewer2, synthesis }) {
|
|
1431
|
+
const reviewers2 = [reviewer1, reviewer2].filter((model) => model !== void 0);
|
|
1432
|
+
const used = [...reviewers2.length ? [`reviewed with ${list(reviewers2)}`] : [], ...synthesis ? [`combined with ${synthesis}`] : []];
|
|
1433
|
+
return `---
|
|
1434
|
+
_Adversarial review by [yoke](${repoUrl})${used.length ? `: ${used.join("; ")}` : ""}._`;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// src/features/review/run.ts
|
|
1438
|
+
var reviewers = ["reviewer1", "reviewer2"];
|
|
1439
|
+
function seconds(since) {
|
|
1440
|
+
return `${Math.round((Date.now() - since) / 1e3)}s`;
|
|
1441
|
+
}
|
|
1442
|
+
function describeSlot({ harness, model, provider }) {
|
|
1443
|
+
return `${harness}: ${provider ? `${provider}/` : ""}${model ?? "default model"}`;
|
|
1444
|
+
}
|
|
1445
|
+
async function runStep({ name, label, slot, ...step }, context) {
|
|
1446
|
+
const started = Date.now();
|
|
1447
|
+
const status = context.track(label);
|
|
1448
|
+
const { harness, override } = slot;
|
|
1449
|
+
const result = await context.runHarness({
|
|
1450
|
+
...step,
|
|
1451
|
+
harness,
|
|
1452
|
+
override,
|
|
1453
|
+
cwd: context.cwd,
|
|
1454
|
+
reviewDir: context.reviewDir,
|
|
1455
|
+
onProgress: status.update
|
|
1456
|
+
});
|
|
1457
|
+
if (result.ok) {
|
|
1458
|
+
status.succeed(`${label} done in ${seconds(started)}`);
|
|
1459
|
+
recordUsedModel(context.reviewDir, name, harness, slot.model ?? result.model);
|
|
1460
|
+
recordSlot(context.reviewDir, name, describeSlot(slot));
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
status.fail(`${label} failed after ${seconds(started)}: ${result.error}`);
|
|
1464
|
+
rmSync(step.outputPath, { force: true });
|
|
1465
|
+
forgetUsedModel(context.reviewDir, name);
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
function hasOutput({ name, slot, outputPath }, context) {
|
|
1469
|
+
if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
|
|
1470
|
+
return existsSync5(outputPath) && readFileSync5(outputPath, "utf8").trim().length > 0;
|
|
1471
|
+
}
|
|
1472
|
+
async function runOrReuse(step, context) {
|
|
1473
|
+
if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
|
|
1474
|
+
context.log(`${step.label}: cached`);
|
|
1475
|
+
return { ok: true, ran: false };
|
|
1476
|
+
}
|
|
1477
|
+
function reviewPath(context, reviewer) {
|
|
1478
|
+
return join6(context.reviewDir, `${reviewer}.md`);
|
|
1479
|
+
}
|
|
1480
|
+
function reviewerLabel(reviewer, context) {
|
|
1481
|
+
return `${reviewer} (${describeSlot(context.slots[reviewer])})`;
|
|
1482
|
+
}
|
|
1483
|
+
function withReviewers(result, context) {
|
|
1484
|
+
const line = reviewers.map((reviewer) => reviewerLabel(reviewer, context)).join(", ");
|
|
1485
|
+
return { ...result, report: `Reviewers: ${line}
|
|
1486
|
+
|
|
1487
|
+
${result.report}` };
|
|
1488
|
+
}
|
|
1489
|
+
function synthesisResult(markdown) {
|
|
1490
|
+
const synthesis = parseSynthesis(markdown);
|
|
1491
|
+
if (!synthesis.summary && !synthesis.findings.length) return { report: markdown.trim(), synthesis: null, markdown: null };
|
|
1492
|
+
return { report: formatSynthesis(synthesis), synthesis, markdown };
|
|
1493
|
+
}
|
|
1494
|
+
function rawReviews(succeeded, context) {
|
|
1495
|
+
const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
|
|
1496
|
+
|
|
1497
|
+
${readFileSync5(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
|
|
1498
|
+
return { report, synthesis: null, markdown: null };
|
|
1499
|
+
}
|
|
1500
|
+
function synthesisSlot(succeeded, context) {
|
|
1501
|
+
const [first = "reviewer1"] = succeeded;
|
|
1502
|
+
return context.slots.synthesis ?? { harness: context.slots[first].harness };
|
|
1503
|
+
}
|
|
1504
|
+
async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
1505
|
+
const slot = synthesisSlot(succeeded, context);
|
|
1506
|
+
const outputPath = join6(context.reviewDir, "synthesis.md");
|
|
1507
|
+
if (reviewersRan) rmSync(outputPath, { force: true });
|
|
1508
|
+
const files = reviewers.map((reviewer) => ({
|
|
1509
|
+
reviewer: reviewerLabel(reviewer, context),
|
|
1510
|
+
path: succeeded.includes(reviewer) ? reviewPath(context, reviewer) : null
|
|
1511
|
+
}));
|
|
1512
|
+
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1513
|
+
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1514
|
+
return synthesisResult(readFileSync5(outputPath, "utf8"));
|
|
1515
|
+
context.log("showing the raw reviews instead");
|
|
1516
|
+
return rawReviews(succeeded, context);
|
|
1517
|
+
}
|
|
1518
|
+
async function runReview(request, context) {
|
|
1519
|
+
const requestPath = join6(context.reviewDir, "request.md");
|
|
1520
|
+
writeFileSync5(requestPath, request);
|
|
1521
|
+
const prompt = buildReviewerPrompt(requestPath);
|
|
1522
|
+
const results = await Promise.all(
|
|
1523
|
+
reviewers.map((reviewer) => {
|
|
1524
|
+
const slot = context.slots[reviewer];
|
|
1525
|
+
const outputPath = reviewPath(context, reviewer);
|
|
1526
|
+
return runOrReuse({ name: reviewer, label: reviewerLabel(reviewer, context), slot, prompt, outputPath }, context);
|
|
1527
|
+
})
|
|
1528
|
+
);
|
|
1529
|
+
const succeeded = reviewers.filter((_, i) => results[i]?.ok);
|
|
1530
|
+
if (!succeeded.length) throw new ReviewError("Every reviewer failed; nothing to report.");
|
|
1531
|
+
const synthesised = await synthesise(
|
|
1532
|
+
requestPath,
|
|
1533
|
+
succeeded,
|
|
1534
|
+
results.some((result) => result.ran),
|
|
1535
|
+
context
|
|
1536
|
+
);
|
|
1537
|
+
return withReviewers(synthesised, context);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// src/features/review/status.ts
|
|
1541
|
+
import { styleText } from "util";
|
|
1542
|
+
import { createLogUpdate } from "log-update";
|
|
1543
|
+
async function timed(ui, label, work) {
|
|
1544
|
+
const started = Date.now();
|
|
1545
|
+
const status = ui.track(label);
|
|
1546
|
+
const elapsed = () => `${Math.round((Date.now() - started) / 1e3)}s`;
|
|
1547
|
+
try {
|
|
1548
|
+
const result = await work();
|
|
1549
|
+
status.succeed(`${label} (${elapsed()})`);
|
|
1550
|
+
return result;
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
status.fail(`${label} failed after ${elapsed()}`);
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function lineUi(log, { activity }) {
|
|
1557
|
+
return {
|
|
1558
|
+
log,
|
|
1559
|
+
track: (label) => {
|
|
1560
|
+
log(`${label}\u2026`);
|
|
1561
|
+
const update = (text2) => activity ? log(`${label}: ${text2}`) : void 0;
|
|
1562
|
+
return { update, succeed: log, fail: log };
|
|
1563
|
+
},
|
|
1564
|
+
stop: () => {
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1569
|
+
var tickMs = 80;
|
|
1570
|
+
function spinnerUi(stream) {
|
|
1571
|
+
const update = createLogUpdate(stream);
|
|
1572
|
+
const running = [];
|
|
1573
|
+
let frame = 0;
|
|
1574
|
+
let timer;
|
|
1575
|
+
const paint = (colour, text2) => styleText(colour, text2, { stream });
|
|
1576
|
+
const render = () => update(running.map((entry) => `${paint("cyan", frames[frame] ?? "")} ${entry.text}`).join("\n"));
|
|
1577
|
+
const persist = (line) => {
|
|
1578
|
+
update.clear();
|
|
1579
|
+
update.persist(line);
|
|
1580
|
+
render();
|
|
1581
|
+
};
|
|
1582
|
+
const settle = (entry, line) => {
|
|
1583
|
+
running.splice(running.indexOf(entry), 1);
|
|
1584
|
+
persist(line);
|
|
1585
|
+
if (running.length || !timer) return;
|
|
1586
|
+
clearInterval(timer);
|
|
1587
|
+
timer = void 0;
|
|
1588
|
+
};
|
|
1589
|
+
return {
|
|
1590
|
+
log: persist,
|
|
1591
|
+
track: (label) => {
|
|
1592
|
+
const entry = { label, text: label };
|
|
1593
|
+
running.push(entry);
|
|
1594
|
+
timer ??= setInterval(() => {
|
|
1595
|
+
frame = (frame + 1) % frames.length;
|
|
1596
|
+
render();
|
|
1597
|
+
}, tickMs);
|
|
1598
|
+
render();
|
|
1599
|
+
return {
|
|
1600
|
+
update: (activity) => entry.text = `${label} \u2014 ${activity}`,
|
|
1601
|
+
succeed: (text2) => settle(entry, `${paint("green", "\u2713")} ${text2}`),
|
|
1602
|
+
fail: (text2) => settle(entry, `${paint("red", "\u2717")} ${text2}`)
|
|
1603
|
+
};
|
|
1604
|
+
},
|
|
1605
|
+
stop: () => {
|
|
1606
|
+
if (timer) clearInterval(timer);
|
|
1607
|
+
update.done();
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/features/review/review.ts
|
|
1613
|
+
function reviewRunDir(pr2, env = process.env, home = homedir2()) {
|
|
1614
|
+
const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
|
|
1615
|
+
const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
|
|
1616
|
+
return join7(env.XDG_CACHE_HOME || join7(home, ".cache"), "yoke", "reviews", owner, repo, run2);
|
|
1617
|
+
}
|
|
1618
|
+
var cachedOutputs = ["reviewer1.md", "reviewer2.md", "synthesis.md", "review.md", "models.json", "slots.json"];
|
|
1619
|
+
function prepareRunDir(reviewDir, force) {
|
|
1620
|
+
mkdirSync3(reviewDir, { recursive: true });
|
|
1621
|
+
if (force) for (const name of cachedOutputs) rmSync2(join7(reviewDir, name), { force: true });
|
|
1622
|
+
}
|
|
1623
|
+
async function reviewAtHead(pr2, reviewDir, request, slots, ui) {
|
|
1624
|
+
const checkoutDir = mkdtempSync(join7(tmpdir(), "yoke-review-"));
|
|
1625
|
+
try {
|
|
1626
|
+
const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
|
|
1627
|
+
try {
|
|
1628
|
+
return await runReview(request, { cwd: checkout.cwd, reviewDir, slots, runHarness, log: ui.log, track: ui.track });
|
|
1629
|
+
} finally {
|
|
1630
|
+
await checkout.remove();
|
|
1631
|
+
}
|
|
1632
|
+
} finally {
|
|
1633
|
+
rmSync2(checkoutDir, { recursive: true, force: true });
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
function savedReview(path, post) {
|
|
1637
|
+
if (!post || !existsSync6(path)) return null;
|
|
1638
|
+
return parseSynthesis(readFileSync6(path, "utf8"));
|
|
1639
|
+
}
|
|
1640
|
+
function saveReview(path, markdown) {
|
|
1641
|
+
if (markdown) writeFileSync6(path, markdown);
|
|
1642
|
+
else rmSync2(path, { force: true });
|
|
1643
|
+
}
|
|
1644
|
+
async function reviewPr(number, settings, { post, force }, io) {
|
|
1645
|
+
const file = reviewLog();
|
|
1646
|
+
const ui = logged(io.ui, file.append);
|
|
1647
|
+
const out = (text2) => {
|
|
1648
|
+
file.append(text2);
|
|
1649
|
+
io.out(text2);
|
|
1650
|
+
};
|
|
1651
|
+
const { log } = ui;
|
|
1652
|
+
const slots = resolveSlots(settings);
|
|
1653
|
+
const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
|
|
1654
|
+
log(`#${pr2.number} ${pr2.title}`);
|
|
1655
|
+
const [diff, prior] = await timed(
|
|
1656
|
+
ui,
|
|
1657
|
+
"fetching the diff and earlier review comments",
|
|
1658
|
+
() => Promise.all([fetchDiff(pr2), fetchPriorComments(pr2)])
|
|
1659
|
+
);
|
|
1660
|
+
const reviewDir = reviewRunDir(pr2);
|
|
1661
|
+
prepareRunDir(reviewDir, force);
|
|
1662
|
+
file.attach(reviewDir);
|
|
1663
|
+
const path = join7(reviewDir, "review.md");
|
|
1664
|
+
let synthesis = savedReview(path, post);
|
|
1665
|
+
if (synthesis) {
|
|
1666
|
+
log(`posting the saved review of #${pr2.number} at ${pr2.headRefOid.slice(0, 7)}`);
|
|
1667
|
+
out(formatSynthesis(synthesis));
|
|
1668
|
+
} else {
|
|
1669
|
+
await timed(ui, "checking each reviewer can run", () => assertViable(slots));
|
|
1670
|
+
log(`review folder: ${reviewDir}`);
|
|
1671
|
+
const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots, ui);
|
|
1672
|
+
out(result.report);
|
|
1673
|
+
synthesis = result.synthesis;
|
|
1674
|
+
saveReview(path, result.markdown);
|
|
1675
|
+
}
|
|
1676
|
+
if (!post) return;
|
|
1677
|
+
if (!synthesis) throw new ReviewError("Not posting: the synthesis did not produce findings in the expected format.");
|
|
1678
|
+
const findings = synthesis;
|
|
1679
|
+
const footer = modelsFooter(readUsedModels(reviewDir));
|
|
1680
|
+
await timed(ui, `posting to #${pr2.number}`, () => postFindings(pr2, findings, diff, prior, { api: github, log, footer }));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
255
1683
|
// src/features/sync/sync.ts
|
|
256
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
257
|
-
import { homedir } from "os";
|
|
258
|
-
import { join } from "path";
|
|
1684
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
|
|
1685
|
+
import { homedir as homedir3 } from "os";
|
|
1686
|
+
import { join as join8 } from "path";
|
|
259
1687
|
|
|
260
1688
|
// src/skill.ts
|
|
261
1689
|
var ownerMarker = 'owner: "@makerx/yoke"';
|
|
262
|
-
function renderSkill({ name, description, body }) {
|
|
263
|
-
|
|
1690
|
+
function renderSkill({ name, description, body, disableModelInvocation }) {
|
|
1691
|
+
const invocation = disableModelInvocation ? ["disable-model-invocation: true"] : [];
|
|
1692
|
+
return [
|
|
1693
|
+
"---",
|
|
1694
|
+
`name: ${name}`,
|
|
1695
|
+
`description: ${JSON.stringify(`[yoke] ${description}`)}`,
|
|
1696
|
+
...invocation,
|
|
1697
|
+
"metadata:",
|
|
1698
|
+
` ${ownerMarker}`,
|
|
1699
|
+
"---",
|
|
1700
|
+
"",
|
|
1701
|
+
body.trim(),
|
|
1702
|
+
""
|
|
1703
|
+
].join("\n");
|
|
264
1704
|
}
|
|
265
1705
|
|
|
266
1706
|
// src/features/pr/skill.ts
|
|
267
1707
|
var prSkill = {
|
|
268
1708
|
name: "pr",
|
|
269
|
-
description: "Raise a PR with a concise What/Why/(How) description.
|
|
1709
|
+
description: "Raise or rewrite a PR with a concise What/Why/(How) description.",
|
|
270
1710
|
body: `
|
|
271
1711
|
Run \`yoke pr raise --help\` and follow it exactly. Do not use \`gh pr create\`.
|
|
272
1712
|
|
|
@@ -274,25 +1714,39 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
274
1714
|
`
|
|
275
1715
|
};
|
|
276
1716
|
|
|
1717
|
+
// src/features/review/skill.ts
|
|
1718
|
+
var reviewSkill = {
|
|
1719
|
+
name: "review",
|
|
1720
|
+
description: "Adversarially review a PR with Claude and Codex, then combine their findings.",
|
|
1721
|
+
disableModelInvocation: true,
|
|
1722
|
+
body: `
|
|
1723
|
+
Run \`yoke review $ARGUMENTS\` in the foreground, not as a background task, with the longest timeout your shell tool allows, so the user sees each reviewer's progress as it streams; two reviewers and a synthesis pass take several minutes. Then relay the findings it printed. Do not add findings of your own or re-review the code. Each reviewer's tool calls go to \`review.log\` in the review folder it prints, not to the output; read that log only if the user asks what a reviewer did.
|
|
1724
|
+
|
|
1725
|
+
If \`$ARGUMENTS\` does not already include \`--post\`, ask whether to post the findings to the PR. If the user agrees, run \`yoke review $ARGUMENTS --post\` with any \`--force\` removed from \`$ARGUMENTS\`; it posts the review it just saved rather than reviewing again, as one GitHub review with an inline comment per finding on a diff line, and skips findings already raised on the PR. Relay what it reports.
|
|
1726
|
+
|
|
1727
|
+
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
1728
|
+
`
|
|
1729
|
+
};
|
|
1730
|
+
|
|
277
1731
|
// src/features/sync/sync.ts
|
|
278
|
-
var bundledSkills = [prSkill];
|
|
279
|
-
function skillsRoot(user, cwd = process.cwd(), home =
|
|
280
|
-
return
|
|
1732
|
+
var bundledSkills = [prSkill, reviewSkill];
|
|
1733
|
+
function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
1734
|
+
return join8(user ? home : cwd, ".claude", "skills");
|
|
281
1735
|
}
|
|
282
1736
|
function isOwnedByUser(skillFile) {
|
|
283
|
-
return
|
|
1737
|
+
return existsSync7(skillFile) && !readFileSync7(skillFile, "utf8").includes(ownerMarker);
|
|
284
1738
|
}
|
|
285
1739
|
function syncSkills(destination, skills = bundledSkills) {
|
|
286
1740
|
const result = { written: [], skipped: [] };
|
|
287
1741
|
for (const skill of skills) {
|
|
288
|
-
const target =
|
|
289
|
-
const skillFile =
|
|
1742
|
+
const target = join8(destination, skill.name);
|
|
1743
|
+
const skillFile = join8(target, "SKILL.md");
|
|
290
1744
|
if (isOwnedByUser(skillFile)) {
|
|
291
1745
|
result.skipped.push(target);
|
|
292
1746
|
continue;
|
|
293
1747
|
}
|
|
294
|
-
|
|
295
|
-
|
|
1748
|
+
mkdirSync4(target, { recursive: true });
|
|
1749
|
+
writeFileSync7(skillFile, renderSkill(skill));
|
|
296
1750
|
result.written.push(skillFile);
|
|
297
1751
|
}
|
|
298
1752
|
return result;
|
|
@@ -318,4 +1772,75 @@ pr.command("raise").description("Validate a PR title and body, then create the P
|
|
|
318
1772
|
process.exitCode = 1;
|
|
319
1773
|
}
|
|
320
1774
|
});
|
|
321
|
-
|
|
1775
|
+
var providerKeys = settingKeys.filter((key) => !key.startsWith("review."));
|
|
1776
|
+
function addSettingOptions(command, keys) {
|
|
1777
|
+
for (const key of keys) {
|
|
1778
|
+
const flag = settingFlag(key);
|
|
1779
|
+
if (flag) command.option(`${flag} <value>`, settingDescription(key));
|
|
1780
|
+
}
|
|
1781
|
+
return command;
|
|
1782
|
+
}
|
|
1783
|
+
function optionName(flag) {
|
|
1784
|
+
return flag.slice(2).replace(/-(.)/g, (_, next) => next.toUpperCase());
|
|
1785
|
+
}
|
|
1786
|
+
function flagSettings(options, keys) {
|
|
1787
|
+
const flags = {};
|
|
1788
|
+
for (const key of keys) {
|
|
1789
|
+
const flag = settingFlag(key);
|
|
1790
|
+
const value = flag && options[optionName(flag)];
|
|
1791
|
+
if (typeof value === "string") flags[key] = value;
|
|
1792
|
+
}
|
|
1793
|
+
return flags;
|
|
1794
|
+
}
|
|
1795
|
+
var settingsHelp = "\nEach setting falls back to its YOKE_* env var, then `yoke config set --user`; API keys come from env or config only. Run `yoke config` to see them all.";
|
|
1796
|
+
addSettingOptions(
|
|
1797
|
+
program.command("review").description("Review the current branch's PR, or PR <number>, with Claude and Codex in parallel, then combine their findings").argument("[number]", "PR number; reviewed without checking it out").option("--post", "post the findings to the PR as one review, reusing the saved review of the PR head when there is one").option("--force", "discard this PR head's cached reviews and run every pass again").option("--verbose", "print every step and each reviewer's tool calls as log lines instead of live spinners"),
|
|
1798
|
+
settingKeys
|
|
1799
|
+
).addHelpText("after", settingsHelp).action(async (number, options) => {
|
|
1800
|
+
const verbose = options.verbose ?? false;
|
|
1801
|
+
const ui = process.stderr.isTTY && !verbose ? spinnerUi(process.stderr) : lineUi(console.error, { activity: verbose });
|
|
1802
|
+
try {
|
|
1803
|
+
const settings = resolveSettings(flagSettings(options, settingKeys), process.env, readUserConfig(userConfigPath()));
|
|
1804
|
+
const run2 = { post: options.post ?? false, force: options.force ?? false };
|
|
1805
|
+
await reviewPr(number, settings, run2, { out: console.log, ui });
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
1808
|
+
console.error(error.message);
|
|
1809
|
+
process.exitCode = 1;
|
|
1810
|
+
} finally {
|
|
1811
|
+
ui.stop();
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
addSettingOptions(
|
|
1815
|
+
program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
|
|
1816
|
+
providerKeys
|
|
1817
|
+
).addHelpText("after", settingsHelp).action(async (options) => {
|
|
1818
|
+
try {
|
|
1819
|
+
const models = await listModels(resolveSettings(flagSettings(options, providerKeys), process.env, readUserConfig(userConfigPath())));
|
|
1820
|
+
console.log(models.join("\n"));
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
1823
|
+
console.error(error.message);
|
|
1824
|
+
process.exitCode = 1;
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
var config = program.command("config").description("yoke settings");
|
|
1828
|
+
config.command("list", { isDefault: true }).description("List every setting and its value from YOKE_* env vars, then the user-level yoke config; secrets are masked").action(() => {
|
|
1829
|
+
try {
|
|
1830
|
+
console.log(listSettings(resolveSettings({}, process.env, readUserConfig(userConfigPath())), process.stdout.columns));
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
if (!(error instanceof ConfigError)) throw error;
|
|
1833
|
+
console.error(error.message);
|
|
1834
|
+
process.exitCode = 1;
|
|
1835
|
+
}
|
|
1836
|
+
});
|
|
1837
|
+
config.command("set").description(`Write a setting to the user-level yoke config (${settingKeys.join(", ")})`).argument("<key>", "setting key").argument("<value>", "setting value").option("--user", "write to the user-level config; required, since repo-level config is not supported").action((key, value, options) => {
|
|
1838
|
+
try {
|
|
1839
|
+
console.log(setSetting(key, value, options.user ?? false, userConfigPath()));
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
if (!(error instanceof ConfigError)) throw error;
|
|
1842
|
+
console.error(error.message);
|
|
1843
|
+
process.exitCode = 1;
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
await program.parseAsync();
|