@agentsdance/codejury 0.1.0 → 0.1.2
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 +32 -5
- package/bin/jury.js +163 -83
- package/lib/config.js +3 -2
- package/lib/directories.js +27 -0
- package/lib/findings.js +5 -1
- package/lib/loop.js +24 -10
- package/lib/repository.js +326 -9
- package/lib/store.js +5 -1
- package/package.json +1 -1
- package/web/index.html +55 -50
package/lib/config.js
CHANGED
|
@@ -147,8 +147,9 @@ export async function loadConfig(dir = process.cwd()) {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
/**
|
|
151
|
-
export function
|
|
150
|
+
/** Select the agent that judges findings and owns the working tree. */
|
|
151
|
+
export function judgeAgent(cfg, name) {
|
|
152
|
+
if (name) return cfg.agents.find((a) => a.name === name) ?? null;
|
|
152
153
|
return cfg.main ?? null;
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One directory policy for every command. An explicit path wins; an empty path
|
|
2
|
+
// or an unsuitable implicit cwd falls back to ~/.jury and creates it.
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export function expandDirectory(value, { cwd = process.cwd(), home = homedir() } = {}) {
|
|
8
|
+
const raw = String(value ?? "").trim();
|
|
9
|
+
if (raw === "~") return home;
|
|
10
|
+
if (/^~[\\/]/.test(raw)) return path.join(home, raw.slice(2));
|
|
11
|
+
return path.resolve(cwd, raw);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveJuryDirectory(
|
|
15
|
+
value,
|
|
16
|
+
{ cwd = process.cwd(), home = homedir(), isUsable = async () => true } = {},
|
|
17
|
+
) {
|
|
18
|
+
if (typeof value === "string" && value.trim()) {
|
|
19
|
+
return expandDirectory(value, { cwd, home });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (value === undefined && await isUsable(cwd)) return path.resolve(cwd);
|
|
23
|
+
|
|
24
|
+
const fallback = path.join(home, ".jury");
|
|
25
|
+
await mkdir(fallback, { recursive: true });
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
package/lib/findings.js
CHANGED
|
@@ -11,8 +11,12 @@ export const VERDICTS = ["accepted", "deferred", "rejected", "superseded"];
|
|
|
11
11
|
/** Fold the log down to one record per finding id. */
|
|
12
12
|
export async function findingsIn(dir) {
|
|
13
13
|
const byId = new Map();
|
|
14
|
+
let judge = "claude"; // Backward compatibility for runs without judge metadata.
|
|
14
15
|
for (const e of await readEvents(dir)) {
|
|
15
16
|
switch (e.t) {
|
|
17
|
+
case "target":
|
|
18
|
+
judge = e.target?.judge ?? judge;
|
|
19
|
+
break;
|
|
16
20
|
case "finding.raised":
|
|
17
21
|
byId.set(e.id, {
|
|
18
22
|
id: e.id, round: e.round, agent: e.agent, claim: e.claim,
|
|
@@ -56,7 +60,7 @@ export async function findingsIn(dir) {
|
|
|
56
60
|
f.turns = (f.turns ?? 0) + 1;
|
|
57
61
|
// Only a reviewer pushing back re-opens the argument; our own turn in
|
|
58
62
|
// the thread is not a rebuttal against ourselves.
|
|
59
|
-
if (e.who && e.who !==
|
|
63
|
+
if (e.who && e.who !== judge) f.contested = true;
|
|
60
64
|
}
|
|
61
65
|
break;
|
|
62
66
|
}
|
package/lib/loop.js
CHANGED
|
@@ -23,6 +23,15 @@ import { appendEvent, writeArtifact } from "./store.js";
|
|
|
23
23
|
// recorded rather than resolved by fiat.
|
|
24
24
|
export const MAX_TURNS = 3;
|
|
25
25
|
|
|
26
|
+
/** The judge in force at the end of a run, with a legacy-run fallback. */
|
|
27
|
+
export function currentJudge(events) {
|
|
28
|
+
let judge = "claude";
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
if (e.t === "target" && e.target?.judge) judge = e.target.judge;
|
|
31
|
+
}
|
|
32
|
+
return judge;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* One finding's state across the whole run, which is what decides whether the
|
|
28
37
|
* loop may stop. `turns` counts round-trips on this specific claim, not rounds.
|
|
@@ -70,9 +79,13 @@ export function conversation(events) {
|
|
|
70
79
|
return t;
|
|
71
80
|
};
|
|
72
81
|
const claims = new Map();
|
|
82
|
+
let judge = "claude"; // Runs created before --judge existed were Claude-led.
|
|
73
83
|
|
|
74
84
|
for (const e of events) {
|
|
75
85
|
switch (e.t) {
|
|
86
|
+
case "target":
|
|
87
|
+
judge = e.target?.judge ?? judge;
|
|
88
|
+
break;
|
|
76
89
|
case "finding.raised":
|
|
77
90
|
// Deliberately NOT a turn of its own. The reviewer already said this in
|
|
78
91
|
// its report, verbatim and in its own shape; a parsed copy underneath
|
|
@@ -87,14 +100,14 @@ export function conversation(events) {
|
|
|
87
100
|
break;
|
|
88
101
|
case "finding.reproduced":
|
|
89
102
|
thread(claims.get(e.id)?.agent ?? "?").turns.push({
|
|
90
|
-
who:
|
|
103
|
+
who: e.who ?? judge, kind: "reproduced", id: e.id,
|
|
91
104
|
claim: claims.get(e.id)?.claim ?? "",
|
|
92
105
|
text: typeof e.evidence === "string" ? e.evidence : "reproduced", ts: e.ts,
|
|
93
106
|
});
|
|
94
107
|
break;
|
|
95
108
|
case "finding.resolved":
|
|
96
109
|
thread(claims.get(e.id)?.agent ?? "?").turns.push({
|
|
97
|
-
who:
|
|
110
|
+
who: e.who ?? judge, kind: "verdict", id: e.id,
|
|
98
111
|
claim: claims.get(e.id)?.claim ?? "",
|
|
99
112
|
verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts,
|
|
100
113
|
});
|
|
@@ -116,7 +129,7 @@ export function conversation(events) {
|
|
|
116
129
|
who, kind: "rebuttal", id: e.id,
|
|
117
130
|
claim: claims.get(e.id)?.claim ?? "", ts: e.ts,
|
|
118
131
|
};
|
|
119
|
-
if (who ===
|
|
132
|
+
if (who === judge) turn.text = e.text ?? "";
|
|
120
133
|
thread(claims.get(e.id)?.agent ?? e.agent ?? "?").turns.push(turn);
|
|
121
134
|
}
|
|
122
135
|
break;
|
|
@@ -179,7 +192,7 @@ export function conversation(events) {
|
|
|
179
192
|
break;
|
|
180
193
|
case "reply.sent":
|
|
181
194
|
thread(e.agent).turns.push({
|
|
182
|
-
who:
|
|
195
|
+
who: e.who ?? judge, kind: "reply", text: e.text ?? "", resumed: e.resumed, ts: e.ts,
|
|
183
196
|
});
|
|
184
197
|
break;
|
|
185
198
|
case "reply.answered":
|
|
@@ -190,7 +203,7 @@ export function conversation(events) {
|
|
|
190
203
|
break;
|
|
191
204
|
case "commit.pushed":
|
|
192
205
|
for (const t of threads.values()) {
|
|
193
|
-
t.turns.push({ who:
|
|
206
|
+
t.turns.push({ who: e.who ?? judge, kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
|
|
194
207
|
}
|
|
195
208
|
break;
|
|
196
209
|
}
|
|
@@ -208,7 +221,7 @@ export function conversation(events) {
|
|
|
208
221
|
* records the reproduction attempt *before* the verdict, so an "accepted" with
|
|
209
222
|
* nothing behind it is refused by findings.gate rather than believed.
|
|
210
223
|
*/
|
|
211
|
-
export async function triage(finding, { ask, dir, worktree, round }) {
|
|
224
|
+
export async function triage(finding, { ask, dir, worktree, round, judge }) {
|
|
212
225
|
const verdict = await ask({ kind: "triage", finding, worktree, round });
|
|
213
226
|
// Order matters. gate() reads the folded log, so the reproduction has to be
|
|
214
227
|
// on disk before the resolve is checked against it.
|
|
@@ -216,6 +229,7 @@ export async function triage(finding, { ask, dir, worktree, round }) {
|
|
|
216
229
|
await appendEvent(dir, {
|
|
217
230
|
t: "finding.reproduced", id: finding.id,
|
|
218
231
|
evidence: verdict.reproduced, test: verdict.test ?? null,
|
|
232
|
+
...(judge ? { who: judge } : {}),
|
|
219
233
|
});
|
|
220
234
|
}
|
|
221
235
|
return verdict;
|
|
@@ -229,11 +243,11 @@ export async function triage(finding, { ask, dir, worktree, round }) {
|
|
|
229
243
|
* open finding again, which the next round will re-raise. Crashing here would
|
|
230
244
|
* throw away a whole round of reviewer time over one badly-formed answer.
|
|
231
245
|
*/
|
|
232
|
-
export async function record(dir, findings, id, { verdict, reason, test }) {
|
|
246
|
+
export async function record(dir, findings, id, { verdict, reason, test, who }) {
|
|
233
247
|
const f = findings.get(id);
|
|
234
248
|
const why = gate(f, { verdict, test });
|
|
235
249
|
if (why) return { ok: false, why };
|
|
236
|
-
await appendEvent(dir, { t: "finding.resolved", id, verdict, reason: reason ?? "", test: test ?? null });
|
|
250
|
+
await appendEvent(dir, { t: "finding.resolved", id, verdict, reason: reason ?? "", test: test ?? null, ...(who ? { who } : {}) });
|
|
237
251
|
return { ok: true };
|
|
238
252
|
}
|
|
239
253
|
|
|
@@ -278,7 +292,7 @@ export async function refreshSettled(dir) {
|
|
|
278
292
|
* not, and its rebuttal is recorded against the findings it concerns so the
|
|
279
293
|
* turn counter can eventually end the argument.
|
|
280
294
|
*/
|
|
281
|
-
export async function replyRound({ dir, pool, cfg, worktree, sha, round, findings, sessions, dryRun, onLog, onChunk }) {
|
|
295
|
+
export async function replyRound({ dir, pool, cfg, worktree, sha, round, findings, sessions, dryRun, judge, onLog, onChunk }) {
|
|
282
296
|
const names = pool.map((a) => a.name);
|
|
283
297
|
const out = await Promise.all(pool.map(async (a) => {
|
|
284
298
|
const thread = threadFor(a.name, findings);
|
|
@@ -298,7 +312,7 @@ export async function replyRound({ dir, pool, cfg, worktree, sha, round, finding
|
|
|
298
312
|
// lands in that conversation rather than whichever ran most recently.
|
|
299
313
|
const sessionId = sessions?.get(a.name) ?? null;
|
|
300
314
|
const { argv, resumed } = replyArgv(a, { promptText: text, worktree, sha, sessionId });
|
|
301
|
-
await appendEvent(dir, { t: "reply.sent", agent: a.name, resumed, sessionId, text });
|
|
315
|
+
await appendEvent(dir, { t: "reply.sent", agent: a.name, resumed, sessionId, text, ...(judge ? { who: judge } : {}) });
|
|
302
316
|
|
|
303
317
|
const r = await runAgent({ ...a, argv }, {
|
|
304
318
|
worktree, prompt: text, stopToken: cfg.stopToken, dryRun,
|
package/lib/repository.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// PR's name. Keep this check independent of a hosting CLI so it also works for
|
|
6
6
|
// self-hosted GitHub and GitLab instances.
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
8
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
|
|
@@ -89,6 +89,285 @@ function githubNumber(prUrl) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function mergeRequestNumber(prUrl) {
|
|
93
|
+
try {
|
|
94
|
+
const parts = new URL(prUrl).pathname.split("/").filter(Boolean);
|
|
95
|
+
const marker = parts.indexOf("merge_requests");
|
|
96
|
+
return marker >= 0 && /^\d+$/.test(parts[marker + 1] ?? "") ? parts[marker + 1] : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cloneUrlForMergeRequest(prUrl, repoPath) {
|
|
103
|
+
const url = new URL(prUrl);
|
|
104
|
+
url.pathname = `/${repoPath}.git`;
|
|
105
|
+
url.search = "";
|
|
106
|
+
url.hash = "";
|
|
107
|
+
return url.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function branchAtHead(stdout, sha) {
|
|
111
|
+
const branches = String(stdout ?? "").split("\n").flatMap((line) => {
|
|
112
|
+
const [hash, ref] = line.trim().split(/\s+/, 2);
|
|
113
|
+
return hash === sha && ref?.startsWith("refs/heads/") ? [ref.slice(11)] : [];
|
|
114
|
+
});
|
|
115
|
+
return branches.length === 1 ? branches[0] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class MergeRequestCheckoutError extends Error {}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetch a merge request's head, falling back to numbered revision refs.
|
|
122
|
+
*
|
|
123
|
+
* Not every GitLab-compatible host publishes refs/merge-requests/<id>/head.
|
|
124
|
+
* Some expose the individual revisions instead, as
|
|
125
|
+
* refs/merge-requests/<id>/<id>/<revision>, where the highest revision is the
|
|
126
|
+
* current head. Returns the advertised sha when that fallback was used, so the
|
|
127
|
+
* caller can check the fetched commit against what the remote said: a request
|
|
128
|
+
* updated mid-fetch would otherwise be reviewed as though it were the head.
|
|
129
|
+
*/
|
|
130
|
+
async function fetchMergeRequestHead(exec, worktree, number) {
|
|
131
|
+
const options = { cwd: worktree };
|
|
132
|
+
try {
|
|
133
|
+
await exec("git", ["fetch", "origin", `refs/merge-requests/${number}/head`], options);
|
|
134
|
+
return null;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Only a genuinely absent ref justifies looking elsewhere. An auth or
|
|
137
|
+
// transport failure keeps its own error, or every network problem would be
|
|
138
|
+
// reported as an unusual ref layout.
|
|
139
|
+
if (!/couldn't find remote ref/i.test(String(err.stderr ?? "") + String(err.message))) throw err;
|
|
140
|
+
const prefix = `refs/merge-requests/${number}/${number}/`;
|
|
141
|
+
const { stdout } = await exec("git", ["ls-remote", "--refs", "origin", `${prefix}*`], options);
|
|
142
|
+
const revisions = String(stdout).split("\n").flatMap((line) => {
|
|
143
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
144
|
+
if (!/^[a-f\d]{40,64}$/i.test(sha ?? "") || !ref?.startsWith(prefix)) return [];
|
|
145
|
+
const revision = ref.slice(prefix.length);
|
|
146
|
+
// Numeric, so revision 10 sorts above revision 9 rather than beside 1.
|
|
147
|
+
return /^[1-9]\d*$/.test(revision) ? [{ sha, ref, revision: BigInt(revision) }] : [];
|
|
148
|
+
});
|
|
149
|
+
revisions.sort((a, b) => (a.revision > b.revision ? -1 : a.revision < b.revision ? 1 : 0));
|
|
150
|
+
if (!revisions.length) throw err; // no revisions either: the original error is the true one
|
|
151
|
+
const latest = revisions[0];
|
|
152
|
+
await exec("git", ["fetch", "--quiet", "origin", latest.ref], options);
|
|
153
|
+
return latest.sha;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Make origin/<trunk> resolvable in a clone taken from local disk. Best effort:
|
|
159
|
+
* offline the review may still fail, but it fails saying so rather than
|
|
160
|
+
* appearing to resolve and then diffing against a ref that is not there.
|
|
161
|
+
*/
|
|
162
|
+
async function ensureTrunkRef(worktree, trunk, { exec }) {
|
|
163
|
+
try {
|
|
164
|
+
await exec("git", ["rev-parse", "--verify", `refs/remotes/origin/${trunk}^{commit}`], { cwd: worktree });
|
|
165
|
+
return;
|
|
166
|
+
} catch { /* not present: try to bring it in */ }
|
|
167
|
+
try {
|
|
168
|
+
await exec("git", ["fetch", "--quiet", "origin",
|
|
169
|
+
`refs/heads/${trunk}:refs/remotes/origin/${trunk}`], { cwd: worktree });
|
|
170
|
+
} catch { /* offline: leave it, the review reports the real failure */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a repository already contains a specific commit — and is the
|
|
175
|
+
* repository the request actually names. The identity check is not optional:
|
|
176
|
+
* an unrelated checkout holding the same commit would be reviewed under this
|
|
177
|
+
* request's name.
|
|
178
|
+
*/
|
|
179
|
+
async function hasCommit(dir, sha, { exec, target }) {
|
|
180
|
+
if (!dir || !sha || !await sameRepository(dir, target, { exec })) return false;
|
|
181
|
+
try {
|
|
182
|
+
await exec("git", ["-C", dir, "rev-parse", "--verify", `${sha}^{commit}`]);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The commit a merge request points at, if this machine already has it.
|
|
191
|
+
*
|
|
192
|
+
* The head ref is the fragile part of resolving a merge request: GitLab prunes
|
|
193
|
+
* refs/merge-requests/<id>/head once a request is merged or old, so fetching it
|
|
194
|
+
* fails for requests that are otherwise perfectly reviewable. A repository that
|
|
195
|
+
* already contains the commits does not need that ref at all — and reviewing a
|
|
196
|
+
* branch you already have is the common case, not the exception.
|
|
197
|
+
*
|
|
198
|
+
* Read-only by construction: this resolves a revision and never checks anything
|
|
199
|
+
* out, because `jury <url>` run from master must not move the caller's tree.
|
|
200
|
+
*/
|
|
201
|
+
/**
|
|
202
|
+
* Whether a directory is a checkout of the repository the URL names.
|
|
203
|
+
*
|
|
204
|
+
* The URL names one repository; a local checkout of a DIFFERENT one that
|
|
205
|
+
* happens to hold the same ref or commit would review unrelated code under this
|
|
206
|
+
* request's name. Every local shortcut passes through here first.
|
|
207
|
+
*/
|
|
208
|
+
async function sameRepository(dir, target, { exec }) {
|
|
209
|
+
if (!dir || !target) return false;
|
|
210
|
+
try {
|
|
211
|
+
const { stdout } = await exec("git", ["-C", dir, "remote", "-v"]);
|
|
212
|
+
return String(stdout).split("\n").some((line) => {
|
|
213
|
+
const url = line.split(/\s+/)[1];
|
|
214
|
+
return url && repositoryFromRemote(url)?.display === target.display;
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
return false; // not a repository, or no remotes: nothing to match against
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function localHead(dir, target, number, { exec, namespace, branch = null }) {
|
|
222
|
+
if (!dir || !await sameRepository(dir, target, { exec })) return null;
|
|
223
|
+
|
|
224
|
+
// Only the numbered ref. FETCH_HEAD was also consulted here, and it is not
|
|
225
|
+
// evidence of anything: it holds whatever the last fetch left behind, which
|
|
226
|
+
// after `git fetch origin main` is main. That resolved the wrong commit under
|
|
227
|
+
// this request's name, and — since branchAtHead then matched main on the
|
|
228
|
+
// remote — could make main the push target of a review that never examined it.
|
|
229
|
+
try {
|
|
230
|
+
const { stdout } = await exec(
|
|
231
|
+
"git", ["-C", dir, "rev-parse", "--verify", `${namespace}/${number}/head^{commit}`],
|
|
232
|
+
);
|
|
233
|
+
if (stdout.trim()) return stdout.trim();
|
|
234
|
+
} catch { /* an ordinary clone has no numbered ref; try the branch below */ }
|
|
235
|
+
|
|
236
|
+
// The numbered ref is the exception, not the rule: a normal clone or a
|
|
237
|
+
// source-branch checkout has none of them, only branches. Requiring it made
|
|
238
|
+
// this path unreachable for exactly the people the error told to "check out
|
|
239
|
+
// the source branch and pass --dir" — advice that could not work.
|
|
240
|
+
if (!branch) return null;
|
|
241
|
+
for (const rev of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
|
|
242
|
+
try {
|
|
243
|
+
const { stdout } = await exec("git", ["-C", dir, "rev-parse", "--verify", `${rev}^{commit}`]);
|
|
244
|
+
if (stdout.trim()) return stdout.trim();
|
|
245
|
+
} catch { /* try the next candidate */ }
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
251
|
+
allowPush, exec, makeTemp, remove, dir, home = homedir(),
|
|
252
|
+
}) {
|
|
253
|
+
// Where the clone lands when one is needed: the same ~/.jury the rest of the
|
|
254
|
+
// CLI already uses, rather than the system temp directory. The checkout is
|
|
255
|
+
// still removed when the review ends — this puts jury's working files under
|
|
256
|
+
// one predictable, inspectable root, and does not make them persist.
|
|
257
|
+
const worktree = await makeTemp(path.join(home, ".jury", "checkouts", `jury-mr-${number}-`));
|
|
258
|
+
let advertisedSha = null;
|
|
259
|
+
// Refresh the cached ref before trusting it. A ref fetched before the author
|
|
260
|
+
// pushed again names an obsolete head, and nothing about matching repository
|
|
261
|
+
// identity makes it current — a silent review of superseded code. When the
|
|
262
|
+
// refresh fails (offline, pruned) the cached ref is still better than nothing,
|
|
263
|
+
// which is the whole point of the local path.
|
|
264
|
+
if (dir && await sameRepository(dir, target, { exec })) {
|
|
265
|
+
try {
|
|
266
|
+
await exec("git", ["-C", dir, "fetch", "--quiet", "origin",
|
|
267
|
+
`refs/merge-requests/${number}/head:refs/merge-requests/${number}/head`, "--force"]);
|
|
268
|
+
} catch { /* keep whatever is cached */ }
|
|
269
|
+
}
|
|
270
|
+
const local = await localHead(dir, target, number, { exec, namespace: "refs/merge-requests" });
|
|
271
|
+
try {
|
|
272
|
+
if (local) {
|
|
273
|
+
// The commits are already here. Clone from disk rather than the network:
|
|
274
|
+
// no credentials, no head ref, and nothing the host may have pruned.
|
|
275
|
+
await exec("git", ["clone", "--quiet", "--no-checkout", dir, worktree]);
|
|
276
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-mr-${number}`, local], { cwd: worktree });
|
|
277
|
+
// Cloning from disk points origin at a filesystem path. Left alone, the
|
|
278
|
+
// branch lookup below would read the local clone and pushTarget would
|
|
279
|
+
// push into it — a review that never reaches the real remote. Repoint
|
|
280
|
+
// origin at the repository the URL actually names.
|
|
281
|
+
await exec("git", ["remote", "set-url", "origin",
|
|
282
|
+
cloneUrlForMergeRequest(prUrl, target.path)], { cwd: worktree });
|
|
283
|
+
} else {
|
|
284
|
+
await exec("git", ["clone", "--quiet", "--no-checkout", cloneUrlForMergeRequest(prUrl, target.path), worktree]);
|
|
285
|
+
advertisedSha = await fetchMergeRequestHead(exec, worktree, number);
|
|
286
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-mr-${number}`, "FETCH_HEAD"], { cwd: worktree });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { stdout: actualOut } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
|
|
290
|
+
const sha = actualOut.trim();
|
|
291
|
+
if (advertisedSha && sha !== advertisedSha) {
|
|
292
|
+
throw new MergeRequestCheckoutError(
|
|
293
|
+
`merge request !${number} changed while it was being fetched; run the review again`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
// origin/HEAD comes from the clone SOURCE. Cloning the caller's repository
|
|
297
|
+
// copies whatever they had checked out, so a user sitting on the request's
|
|
298
|
+
// own source branch got that branch reported as trunk — and the review then
|
|
299
|
+
// diffed the branch against itself and saw nothing. Ask the real remote
|
|
300
|
+
// when the clone came from disk; fall back to the copied ref offline.
|
|
301
|
+
let trunk = "";
|
|
302
|
+
if (local) {
|
|
303
|
+
try {
|
|
304
|
+
const { stdout } = await exec("git", ["ls-remote", "--symref", "origin", "HEAD"], { cwd: worktree });
|
|
305
|
+
trunk = String(stdout).match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m)?.[1] ?? "";
|
|
306
|
+
} catch { /* offline: fall through to the local ref */ }
|
|
307
|
+
}
|
|
308
|
+
if (!trunk && !local) {
|
|
309
|
+
// Only for a network clone, where origin/HEAD came from the host. In a
|
|
310
|
+
// clone taken from disk it was copied from whatever the caller had
|
|
311
|
+
// checked out — often this request's own source branch, which would make
|
|
312
|
+
// the review diff the branch against itself and report an empty change.
|
|
313
|
+
const { stdout: trunkOut } = await exec(
|
|
314
|
+
"git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: worktree },
|
|
315
|
+
);
|
|
316
|
+
trunk = trunkOut.trim().replace(/^origin\//, "");
|
|
317
|
+
}
|
|
318
|
+
// A network clone with no default branch is a broken repository, and saying
|
|
319
|
+
// so is right. A local clone that simply could not reach the host is not:
|
|
320
|
+
// the CLI applies its own --trunk after this returns, so failing here would
|
|
321
|
+
// refuse a run the user had already told where trunk is. Leave it empty and
|
|
322
|
+
// let that flag win; the CLI reports a missing trunk itself if there is none.
|
|
323
|
+
if (!trunk && !local) throw new MergeRequestCheckoutError(
|
|
324
|
+
`merge request !${number} repository has no default branch`,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Only when a push target is actually needed. This ran unconditionally, so
|
|
328
|
+
// a --no-push review with every commit already on disk still failed the
|
|
329
|
+
// moment the network was unreachable — the one case the local path exists
|
|
330
|
+
// to serve.
|
|
331
|
+
let branch = null;
|
|
332
|
+
if (allowPush) {
|
|
333
|
+
const { stdout: headsOut } = await exec("git", ["ls-remote", "--heads", "origin"], { cwd: worktree });
|
|
334
|
+
branch = branchAtHead(headsOut, sha);
|
|
335
|
+
}
|
|
336
|
+
if (allowPush && !branch) {
|
|
337
|
+
throw new MergeRequestCheckoutError(
|
|
338
|
+
`resolved merge request !${number}, but its source branch is not uniquely available on origin — ` +
|
|
339
|
+
"use --no-push for a read-only review, or check out the source branch and pass --dir",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
worktree,
|
|
345
|
+
state: "OPEN",
|
|
346
|
+
trunk,
|
|
347
|
+
branch: branch ?? `merge-request/${number}`,
|
|
348
|
+
sha,
|
|
349
|
+
pushTarget: branch ? { remote: "origin", branch } : null,
|
|
350
|
+
cleanup: () => remove(worktree),
|
|
351
|
+
};
|
|
352
|
+
} catch (err) {
|
|
353
|
+
await remove(worktree);
|
|
354
|
+
if (err instanceof MergeRequestCheckoutError) throw err;
|
|
355
|
+
// git's own words, not a guess. --quiet used to hide them and only the
|
|
356
|
+
// first line survived, so a pruned head ref — the usual cause — was
|
|
357
|
+
// reported as a probable authentication problem. Whatever git said is the
|
|
358
|
+
// one thing that distinguishes the cases.
|
|
359
|
+
const said = [err.stderr, err.message].map((t) => String(t ?? "").trim()).find(Boolean) ?? "";
|
|
360
|
+
const missingRef = /couldn't find remote ref|no matching remote head/i.test(said);
|
|
361
|
+
throw new Error(
|
|
362
|
+
`could not resolve ${target.display} merge request !${number}: ${said.split("\n")[0]} — ` +
|
|
363
|
+
(missingRef
|
|
364
|
+
? `${target.host} has no refs/merge-requests/${number}/head; hosts prune it once a request ` +
|
|
365
|
+
"is merged or old. Check out the source branch and pass --dir."
|
|
366
|
+
: "check Git authentication and refs/merge-requests support, or check out the source branch and pass --dir"),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
92
371
|
function remoteForRepository(origin, repoPath) {
|
|
93
372
|
const scp = String(origin).match(/^((?:[^@/\s]+@)?[^:/\s]+):(.+)$/);
|
|
94
373
|
if (scp && !/^[a-z][a-z\d+.-]*:\/\//i.test(origin)) return `${scp[1]}:${repoPath}.git`;
|
|
@@ -100,7 +379,7 @@ function remoteForRepository(origin, repoPath) {
|
|
|
100
379
|
}
|
|
101
380
|
|
|
102
381
|
/**
|
|
103
|
-
* Resolve a GitHub PR to an isolated checkout of its exact head.
|
|
382
|
+
* Resolve a GitHub PR or GitLab-style MR to an isolated checkout of its exact head.
|
|
104
383
|
*
|
|
105
384
|
* The checkout is a clone rather than a mutation of the caller's repository:
|
|
106
385
|
* running `jury <url>` from master (or from an unrelated repo) must not switch
|
|
@@ -110,15 +389,31 @@ function remoteForRepository(origin, repoPath) {
|
|
|
110
389
|
export async function resolvePrCheckout(prUrl, {
|
|
111
390
|
allowPush = true,
|
|
112
391
|
exec = run,
|
|
113
|
-
|
|
392
|
+
// Creates the parent too: a checkout under ~/.jury has a directory that may
|
|
393
|
+
// not exist yet, and a caller that injects this must not need the real one.
|
|
394
|
+
makeTemp = async (prefix) => {
|
|
395
|
+
await mkdir(path.dirname(prefix), { recursive: true });
|
|
396
|
+
return mkdtemp(prefix);
|
|
397
|
+
},
|
|
114
398
|
remove = (dir) => rm(dir, { recursive: true, force: true }),
|
|
399
|
+
// The caller's checkout, when it has one. Consulted before the network: a
|
|
400
|
+
// repository that already holds the request's commits needs no head ref.
|
|
401
|
+
dir = null,
|
|
402
|
+
home = homedir(),
|
|
115
403
|
} = {}) {
|
|
116
404
|
const target = repositoryFromPrUrl(prUrl);
|
|
117
405
|
const number = githubNumber(prUrl);
|
|
406
|
+
const mrNumber = mergeRequestNumber(prUrl);
|
|
407
|
+
if (target && mrNumber) {
|
|
408
|
+
return resolveMergeRequestCheckout(prUrl, target, mrNumber, {
|
|
409
|
+
allowPush, exec, makeTemp, remove, dir, home,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
118
412
|
if (!target || !number) {
|
|
119
413
|
throw new Error(
|
|
120
414
|
`cannot resolve the PR head from "${prUrl}" automatically — ` +
|
|
121
|
-
"
|
|
415
|
+
"supported URL forms are GitHub /pull/<id> and GitLab-style /merge_requests/<id>; " +
|
|
416
|
+
"otherwise run from its checked-out branch with --dir",
|
|
122
417
|
);
|
|
123
418
|
}
|
|
124
419
|
|
|
@@ -147,11 +442,33 @@ export async function resolvePrCheckout(prUrl, {
|
|
|
147
442
|
);
|
|
148
443
|
}
|
|
149
444
|
|
|
150
|
-
const worktree = await makeTemp(path.join(
|
|
445
|
+
const worktree = await makeTemp(path.join(home, ".jury", "checkouts", `jury-pr-${number}-`));
|
|
446
|
+
// `gh` already gave the authoritative head oid, so that — not a cached ref —
|
|
447
|
+
// is what to look for locally. Consulting refs/pull/<id>/head first checked
|
|
448
|
+
// out whatever it pointed at when it was last fetched, which then failed the
|
|
449
|
+
// assertion below: a stale ref blocked a review whose real head was sitting
|
|
450
|
+
// in the same repository.
|
|
451
|
+
// `gh` gives both the oid and the branch name, so a plain checkout of the PR
|
|
452
|
+
// branch resolves here without any refs/pull ref existing.
|
|
453
|
+
const local = dir && await hasCommit(dir, details.headRefOid, { exec, target })
|
|
454
|
+
? details.headRefOid
|
|
455
|
+
: await localHead(dir, target, number, {
|
|
456
|
+
exec, namespace: "refs/pull", branch: details.headRefName,
|
|
457
|
+
});
|
|
151
458
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
459
|
+
if (local) {
|
|
460
|
+
await exec("git", ["clone", "--quiet", "--no-checkout", dir, worktree]);
|
|
461
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, local], { cwd: worktree });
|
|
462
|
+
await exec("git", ["remote", "set-url", "origin", cloneUrlForMergeRequest(prUrl, target.path)],
|
|
463
|
+
{ cwd: worktree });
|
|
464
|
+
// Same as the merge request path: a clone of a developer's checkout has
|
|
465
|
+
// their local branches, not origin/<base>, and every review prompt needs it.
|
|
466
|
+
await ensureTrunkRef(worktree, details.baseRefName, { exec });
|
|
467
|
+
} else {
|
|
468
|
+
await exec("gh", ["repo", "clone", target.display, worktree, "--", "--quiet"]);
|
|
469
|
+
await exec("git", ["fetch", "origin", `refs/pull/${number}/head`], { cwd: worktree });
|
|
470
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, "FETCH_HEAD"], { cwd: worktree });
|
|
471
|
+
}
|
|
155
472
|
|
|
156
473
|
const { stdout: actual } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
|
|
157
474
|
if (actual.trim() !== details.headRefOid) {
|
package/lib/store.js
CHANGED
|
@@ -183,6 +183,9 @@ export function foldEvents(events, seed = {}) {
|
|
|
183
183
|
const lanes = new Map(); // agent -> lane, kept in launch order
|
|
184
184
|
let t0 = null;
|
|
185
185
|
let lastMin = 0;
|
|
186
|
+
// Events are replayed in order. Starting from the seed's latest judge would
|
|
187
|
+
// misattribute old Claude turns when a resumed run switches judges.
|
|
188
|
+
let judge = "claude";
|
|
186
189
|
|
|
187
190
|
const laneFor = (agent) => {
|
|
188
191
|
let lane = lanes.get(agent);
|
|
@@ -248,7 +251,7 @@ export function foldEvents(events, seed = {}) {
|
|
|
248
251
|
case "reply.sent":
|
|
249
252
|
for (const f of findings.values()) {
|
|
250
253
|
if (f.with === e.agent && f.res !== "open") {
|
|
251
|
-
f.turns.push({ who:
|
|
254
|
+
f.turns.push({ who: e.who ?? judge, kind: "reply", resumed: e.resumed, at: min });
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
257
|
break;
|
|
@@ -268,6 +271,7 @@ export function foldEvents(events, seed = {}) {
|
|
|
268
271
|
}
|
|
269
272
|
case "target":
|
|
270
273
|
run.target = { ...run.target, ...e.target };
|
|
274
|
+
judge = e.target?.judge ?? judge;
|
|
271
275
|
break;
|
|
272
276
|
}
|
|
273
277
|
}
|