pi-bro 0.20.0 → 0.22.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/CHANGELOG.md +71 -0
- package/README.md +140 -64
- package/backend.ts +158 -232
- package/bro.ts +411 -1421
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +65 -11
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/review-source.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, access, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export type ReviewTarget = {
|
|
7
|
+
host: string;
|
|
8
|
+
repository: string;
|
|
9
|
+
repositoryId: number;
|
|
10
|
+
number: number;
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
url: string;
|
|
14
|
+
base: string;
|
|
15
|
+
head: string;
|
|
16
|
+
baseRef: string;
|
|
17
|
+
headRef: string;
|
|
18
|
+
};
|
|
19
|
+
export type ReviewSnapshot = {
|
|
20
|
+
target: ReviewTarget;
|
|
21
|
+
mergeBase: string;
|
|
22
|
+
gitDir: string;
|
|
23
|
+
checkout: string;
|
|
24
|
+
capturedAt: string;
|
|
25
|
+
files: { path: string; status: string }[];
|
|
26
|
+
diff: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function reviewCommand(
|
|
30
|
+
command: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
signal?: AbortSignal,
|
|
33
|
+
cwd?: string,
|
|
34
|
+
timeout = 120_000,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const env = {
|
|
37
|
+
...process.env,
|
|
38
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
39
|
+
GIT_LFS_SKIP_SMUDGE: "1",
|
|
40
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
41
|
+
};
|
|
42
|
+
for (const key of Object.keys(env))
|
|
43
|
+
if (
|
|
44
|
+
key.startsWith("GIT_") &&
|
|
45
|
+
!["GIT_TERMINAL_PROMPT", "GIT_LFS_SKIP_SMUDGE", "GIT_NO_REPLACE_OBJECTS"].includes(key)
|
|
46
|
+
)
|
|
47
|
+
delete (env as Record<string, string | undefined>)[key];
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
execFile(
|
|
50
|
+
command,
|
|
51
|
+
args,
|
|
52
|
+
{ cwd, env, signal, timeout, maxBuffer: 32 * 1024 * 1024, encoding: "utf8" },
|
|
53
|
+
(error, stdout, stderr) => {
|
|
54
|
+
if (error) reject(new Error(`${command}: ${stderr.trim() || error.message}`));
|
|
55
|
+
else resolve(stdout);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function resolveReviewTarget(
|
|
62
|
+
value: string,
|
|
63
|
+
cwd: string,
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
): Promise<ReviewTarget> {
|
|
66
|
+
value = value.trim();
|
|
67
|
+
const match =
|
|
68
|
+
/^https:\/\/([a-zA-Z0-9.-]+)\/([\w.-]+\/[\w.-]+)\/pull\/([1-9]\d*)(?:\/(?:files|commits|checks))?\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/.exec(
|
|
69
|
+
value.trim(),
|
|
70
|
+
);
|
|
71
|
+
if (!match && !/^[1-9]\d*$/.test(value))
|
|
72
|
+
throw Error(
|
|
73
|
+
"Use /bro guided-review <PR number or GitHub PR URL>. Discovery and branch review are not supported.",
|
|
74
|
+
);
|
|
75
|
+
let host = match?.[1] ?? "github.com";
|
|
76
|
+
let repository = match?.[2];
|
|
77
|
+
if (!repository) {
|
|
78
|
+
const current = JSON.parse(
|
|
79
|
+
await reviewCommand("gh", ["repo", "view", "--json", "nameWithOwner,url"], signal, cwd),
|
|
80
|
+
);
|
|
81
|
+
repository = current.nameWithOwner;
|
|
82
|
+
host = new URL(current.url).hostname;
|
|
83
|
+
}
|
|
84
|
+
const number = Number(match?.[3] ?? value);
|
|
85
|
+
const data = JSON.parse(
|
|
86
|
+
await reviewCommand(
|
|
87
|
+
"gh",
|
|
88
|
+
["api", "--hostname", host, `repos/${repository}/pulls/${number}`],
|
|
89
|
+
signal,
|
|
90
|
+
cwd,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
if (
|
|
94
|
+
!data.base?.repo?.id ||
|
|
95
|
+
!/^[a-f0-9]{40,64}$/.test(data.base.sha) ||
|
|
96
|
+
!/^[a-f0-9]{40,64}$/.test(data.head?.sha)
|
|
97
|
+
)
|
|
98
|
+
throw Error("GitHub returned incomplete PR identity.");
|
|
99
|
+
return {
|
|
100
|
+
host,
|
|
101
|
+
repository: data.base.repo.full_name,
|
|
102
|
+
repositoryId: data.base.repo.id,
|
|
103
|
+
number,
|
|
104
|
+
title: data.title,
|
|
105
|
+
body: data.body ?? "",
|
|
106
|
+
url: data.html_url,
|
|
107
|
+
base: data.base.sha,
|
|
108
|
+
head: data.head.sha,
|
|
109
|
+
baseRef: data.base.ref,
|
|
110
|
+
headRef: data.head.ref,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function captureReview(
|
|
115
|
+
target: ReviewTarget,
|
|
116
|
+
root: string,
|
|
117
|
+
signal: AbortSignal,
|
|
118
|
+
localSource?: string,
|
|
119
|
+
): Promise<ReviewSnapshot> {
|
|
120
|
+
for (const sha of [target.base, target.head])
|
|
121
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha)) throw Error("Invalid captured revision.");
|
|
122
|
+
const key = createHash("sha256")
|
|
123
|
+
.update(`${target.host}/${target.repositoryId}/${target.number}`)
|
|
124
|
+
.digest("hex");
|
|
125
|
+
const directory = join(root, "source", key);
|
|
126
|
+
const gitDir = join(directory, "objects.git");
|
|
127
|
+
const checkout = join(directory, target.head);
|
|
128
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
129
|
+
const hooks = join(directory, "empty-hooks");
|
|
130
|
+
await mkdir(hooks, { recursive: true });
|
|
131
|
+
const git = (...args: string[]) =>
|
|
132
|
+
reviewCommand(
|
|
133
|
+
"git",
|
|
134
|
+
[
|
|
135
|
+
"--git-dir",
|
|
136
|
+
gitDir,
|
|
137
|
+
"-c",
|
|
138
|
+
`core.hooksPath=${hooks}`,
|
|
139
|
+
"-c",
|
|
140
|
+
"credential.helper=",
|
|
141
|
+
"-c",
|
|
142
|
+
"credential.helper=!gh auth git-credential",
|
|
143
|
+
...args,
|
|
144
|
+
],
|
|
145
|
+
signal,
|
|
146
|
+
);
|
|
147
|
+
try {
|
|
148
|
+
await access(join(gitDir, "HEAD"));
|
|
149
|
+
} catch {
|
|
150
|
+
await reviewCommand("git", ["init", "--bare", gitDir], signal);
|
|
151
|
+
}
|
|
152
|
+
const remote = localSource ?? `https://${target.host}/${target.repository}.git`;
|
|
153
|
+
// Resolve ancestry before shallow capture; shallow merge-base can silently choose the wrong ancestor.
|
|
154
|
+
const mergeBase = localSource
|
|
155
|
+
? (
|
|
156
|
+
await reviewCommand(
|
|
157
|
+
"git",
|
|
158
|
+
["-C", localSource, "merge-base", target.base, target.head],
|
|
159
|
+
signal,
|
|
160
|
+
)
|
|
161
|
+
).trim()
|
|
162
|
+
: (
|
|
163
|
+
await reviewCommand(
|
|
164
|
+
"gh",
|
|
165
|
+
[
|
|
166
|
+
"api",
|
|
167
|
+
"--hostname",
|
|
168
|
+
target.host,
|
|
169
|
+
`repos/${target.repository}/compare/${target.base}...${target.head}`,
|
|
170
|
+
"--jq",
|
|
171
|
+
".merge_base_commit.sha",
|
|
172
|
+
],
|
|
173
|
+
signal,
|
|
174
|
+
)
|
|
175
|
+
).trim();
|
|
176
|
+
if (!/^[a-f0-9]{40,64}$/.test(mergeBase))
|
|
177
|
+
throw Error("GitHub returned an invalid merge base; capture was not continued.");
|
|
178
|
+
await reviewCommand(
|
|
179
|
+
"git",
|
|
180
|
+
[
|
|
181
|
+
"--git-dir",
|
|
182
|
+
gitDir,
|
|
183
|
+
"-c",
|
|
184
|
+
`core.hooksPath=${hooks}`,
|
|
185
|
+
"-c",
|
|
186
|
+
"credential.helper=",
|
|
187
|
+
"-c",
|
|
188
|
+
"credential.helper=!gh auth git-credential",
|
|
189
|
+
"fetch",
|
|
190
|
+
"--depth=1",
|
|
191
|
+
"--no-tags",
|
|
192
|
+
"--no-recurse-submodules",
|
|
193
|
+
remote,
|
|
194
|
+
`${mergeBase}:refs/bro/base/${mergeBase}`,
|
|
195
|
+
`${target.head}:refs/bro/head/${target.head}`,
|
|
196
|
+
],
|
|
197
|
+
signal,
|
|
198
|
+
undefined,
|
|
199
|
+
600_000,
|
|
200
|
+
);
|
|
201
|
+
const names = (
|
|
202
|
+
await git(
|
|
203
|
+
"diff",
|
|
204
|
+
"--name-status",
|
|
205
|
+
"-z",
|
|
206
|
+
"--no-renames",
|
|
207
|
+
"--ignore-submodules=none",
|
|
208
|
+
mergeBase,
|
|
209
|
+
target.head,
|
|
210
|
+
"--",
|
|
211
|
+
)
|
|
212
|
+
).split("\0");
|
|
213
|
+
const files: ReviewSnapshot["files"] = [];
|
|
214
|
+
for (let i = 0; i + 1 < names.length; i += 2)
|
|
215
|
+
files.push({ status: names[i]!, path: names[i + 1]! });
|
|
216
|
+
const diff = await git(
|
|
217
|
+
"diff",
|
|
218
|
+
"--no-ext-diff",
|
|
219
|
+
"--no-textconv",
|
|
220
|
+
"--no-color",
|
|
221
|
+
"--no-renames",
|
|
222
|
+
"--submodule=short",
|
|
223
|
+
"--ignore-submodules=none",
|
|
224
|
+
mergeBase,
|
|
225
|
+
target.head,
|
|
226
|
+
"--",
|
|
227
|
+
);
|
|
228
|
+
const ready = checkout + ".ready";
|
|
229
|
+
let reusable = false;
|
|
230
|
+
try {
|
|
231
|
+
await access(join(checkout, ".git"));
|
|
232
|
+
const head = (await reviewCommand("git", ["-C", checkout, "rev-parse", "HEAD"], signal)).trim();
|
|
233
|
+
if (head === target.head) {
|
|
234
|
+
const completed = await readFile(ready, "utf8").catch((error) => {
|
|
235
|
+
if (error.code === "ENOENT") return "";
|
|
236
|
+
throw error;
|
|
237
|
+
});
|
|
238
|
+
// Only explicitly completed, clean captures can be reused.
|
|
239
|
+
reusable =
|
|
240
|
+
completed === target.head &&
|
|
241
|
+
(
|
|
242
|
+
await reviewCommand(
|
|
243
|
+
"git",
|
|
244
|
+
["-C", checkout, "status", "--porcelain", "--untracked-files=all"],
|
|
245
|
+
signal,
|
|
246
|
+
)
|
|
247
|
+
).trim() === "";
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
signal.throwIfAborted();
|
|
251
|
+
}
|
|
252
|
+
if (!reusable) {
|
|
253
|
+
signal.throwIfAborted();
|
|
254
|
+
await rm(ready, { force: true });
|
|
255
|
+
const recovered = checkout + ".incomplete-" + randomUUID();
|
|
256
|
+
try {
|
|
257
|
+
await rename(checkout, recovered);
|
|
258
|
+
// Preserve interrupted files, including any local edits, rather than deleting them.
|
|
259
|
+
try {
|
|
260
|
+
await access(join(recovered, ".git"));
|
|
261
|
+
await git("worktree", "repair", recovered);
|
|
262
|
+
} catch {
|
|
263
|
+
signal.throwIfAborted();
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
267
|
+
}
|
|
268
|
+
await git("worktree", "prune", "--expire", "now");
|
|
269
|
+
await git("worktree", "add", "--detach", checkout, target.head);
|
|
270
|
+
const head = (await reviewCommand("git", ["-C", checkout, "rev-parse", "HEAD"], signal)).trim();
|
|
271
|
+
const changes = await reviewCommand("git", ["-C", checkout, "status", "--porcelain"], signal);
|
|
272
|
+
if (head !== target.head || changes.trim())
|
|
273
|
+
throw Error("Captured checkout is incomplete; retry capture.");
|
|
274
|
+
}
|
|
275
|
+
signal.throwIfAborted();
|
|
276
|
+
await writeFile(ready, target.head, { mode: 0o600 });
|
|
277
|
+
return { target, mergeBase, gitDir, checkout, capturedAt: new Date().toISOString(), files, diff };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function readEvidence(
|
|
281
|
+
snapshot: ReviewSnapshot,
|
|
282
|
+
path: string,
|
|
283
|
+
side: "old" | "new",
|
|
284
|
+
signal?: AbortSignal,
|
|
285
|
+
): Promise<string> {
|
|
286
|
+
if (!path || path.startsWith("/") || path.split("/").includes("..") || /[\0\r\n]/.test(path))
|
|
287
|
+
throw Error("Invalid evidence path.");
|
|
288
|
+
const revision = side === "old" ? snapshot.mergeBase : snapshot.target.head;
|
|
289
|
+
const source = await reviewCommand(
|
|
290
|
+
"git",
|
|
291
|
+
["--git-dir", snapshot.gitDir, "cat-file", "blob", `${revision}:${path}`],
|
|
292
|
+
signal,
|
|
293
|
+
);
|
|
294
|
+
if (source.includes("\0")) throw Error("Binary evidence is not available as source text.");
|
|
295
|
+
return source;
|
|
296
|
+
}
|