|
1 | | -import { existsSync, readFileSync, statSync } from "node:fs"; |
2 | | -import { dirname, resolve } from "node:path"; |
| 1 | +import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; |
| 2 | +import { dirname, join, resolve } from "node:path"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { spawnSync } from "node:child_process"; |
3 | 5 | import { fileURLToPath } from "node:url"; |
4 | 6 | import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; |
5 | 7 | import { |
6 | 8 | getGitContext, |
| 9 | + reviewRuntime, |
7 | 10 | runGitDiff, |
8 | 11 | startAnnotateServer, |
9 | 12 | startPlanReviewServer, |
10 | 13 | startReviewServer, |
11 | 14 | type DiffType, |
12 | 15 | } from "./server.js"; |
13 | 16 | import { openBrowser } from "./server/network.js"; |
| 17 | +import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js"; |
| 18 | +import { |
| 19 | + getMRLabel, |
| 20 | + getMRNumberLabel, |
| 21 | + getDisplayRepo, |
| 22 | + getCliName, |
| 23 | + getCliInstallUrl, |
| 24 | +} from "./generated/pr-provider.js"; |
| 25 | +import { parseRemoteUrl } from "./generated/repo.js"; |
| 26 | +import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js"; |
14 | 27 |
|
15 | 28 | export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last"; |
16 | 29 | export interface PlanReviewDecision { |
@@ -151,27 +164,198 @@ export async function openPlanReviewBrowser( |
151 | 164 |
|
152 | 165 | export async function openCodeReview( |
153 | 166 | ctx: ExtensionContext, |
154 | | - options: { cwd?: string; defaultBranch?: string; diffType?: DiffType } = {}, |
| 167 | + options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {}, |
155 | 168 | ): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> { |
156 | 169 | if (!ctx.hasUI || !reviewHtmlContent) { |
157 | 170 | throw new Error("Plannotator code review browser is unavailable in this session."); |
158 | 171 | } |
159 | 172 |
|
160 | | - const cwd = options.cwd ?? ctx.cwd; |
161 | | - const gitCtx = await getGitContext(cwd); |
162 | | - const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch; |
163 | | - const diffType: DiffType = options.diffType ?? "uncommitted"; |
164 | | - const { patch: rawPatch, label: gitRef, error } = await runGitDiff(diffType, defaultBranch, cwd); |
| 173 | + const urlArg = options.prUrl; |
| 174 | + const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://"); |
| 175 | + |
| 176 | + let rawPatch: string; |
| 177 | + let gitRef: string; |
| 178 | + let diffError: string | undefined; |
| 179 | + let gitCtx: Awaited<ReturnType<typeof getGitContext>> | undefined; |
| 180 | + let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined; |
| 181 | + let diffType: DiffType | undefined; |
| 182 | + let agentCwd: string | undefined; |
| 183 | + let worktreeCleanup: (() => void | Promise<void>) | undefined; |
| 184 | + let exitHandler: (() => void) | undefined; |
| 185 | + |
| 186 | + if (isPRMode && urlArg) { |
| 187 | + // --- PR Review Mode --- |
| 188 | + const prRef = parsePRUrl(urlArg); |
| 189 | + if (!prRef) { |
| 190 | + throw new Error( |
| 191 | + `Invalid PR/MR URL: ${urlArg}\n` + |
| 192 | + "Supported formats:\n" + |
| 193 | + " GitHub: https://github.com/owner/repo/pull/123\n" + |
| 194 | + " GitLab: https://gitlab.com/group/project/-/merge_requests/42", |
| 195 | + ); |
| 196 | + } |
| 197 | + |
| 198 | + const cliName = getCliName(prRef); |
| 199 | + const cliUrl = getCliInstallUrl(prRef); |
| 200 | + |
| 201 | + try { |
| 202 | + await checkPRAuth(prRef); |
| 203 | + } catch (err) { |
| 204 | + const msg = err instanceof Error ? err.message : String(err); |
| 205 | + if (msg.includes("not found") || msg.includes("ENOENT")) { |
| 206 | + throw new Error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`); |
| 207 | + } |
| 208 | + throw err; |
| 209 | + } |
| 210 | + |
| 211 | + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); |
| 212 | + const pr = await fetchPR(prRef); |
| 213 | + rawPatch = pr.rawPatch; |
| 214 | + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; |
| 215 | + prMetadata = pr.metadata; |
| 216 | + |
| 217 | + // Create local worktree for agent file access (--local is the default for PR reviews) |
| 218 | + let localPath: string | undefined; |
| 219 | + try { |
| 220 | + const repoDir = options.cwd ?? ctx.cwd; |
| 221 | + const identifier = prMetadata.platform === "github" |
| 222 | + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` |
| 223 | + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; |
| 224 | + const suffix = Math.random().toString(36).slice(2, 8); |
| 225 | + localPath = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); |
| 226 | + const fetchRefStr = prMetadata.platform === "github" |
| 227 | + ? `refs/pull/${prMetadata.number}/head` |
| 228 | + : `refs/merge-requests/${prMetadata.iid}/head`; |
| 229 | + |
| 230 | + // Validate inputs from platform API to prevent git flag/path injection |
| 231 | + if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); |
| 232 | + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); |
| 233 | + |
| 234 | + // Detect same-repo vs cross-repo (must match both owner/repo AND host) |
| 235 | + let isSameRepo = false; |
| 236 | + try { |
| 237 | + const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir }); |
| 238 | + if (remoteResult.exitCode === 0) { |
| 239 | + const remoteUrl = remoteResult.stdout.trim(); |
| 240 | + const currentRepo = parseRemoteUrl(remoteUrl); |
| 241 | + const prRepo = prMetadata.platform === "github" |
| 242 | + ? `${prMetadata.owner}/${prMetadata.repo}` |
| 243 | + : prMetadata.projectPath; |
| 244 | + const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); |
| 245 | + const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; |
| 246 | + const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); |
| 247 | + const remoteHost = (sshHost || httpsHost || "").toLowerCase(); |
| 248 | + const prHost = prMetadata.host.toLowerCase(); |
| 249 | + isSameRepo = repoMatches && remoteHost === prHost; |
| 250 | + } |
| 251 | + } catch { /* not in a git repo — cross-repo path */ } |
| 252 | + |
| 253 | + if (isSameRepo) { |
| 254 | + // ── Same-repo: fast worktree path ── |
| 255 | + console.error("Fetching PR branch and creating local worktree..."); |
| 256 | + await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir }); |
| 257 | + await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir }); |
| 258 | + await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir }); |
| 259 | + |
| 260 | + await createWorktree(reviewRuntime, { |
| 261 | + ref: "FETCH_HEAD", |
| 262 | + path: localPath, |
| 263 | + detach: true, |
| 264 | + cwd: repoDir, |
| 265 | + }); |
| 266 | + |
| 267 | + const worktreePath = localPath; |
| 268 | + const wtRepoDir = repoDir; |
| 269 | + exitHandler = () => { |
| 270 | + try { spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: wtRepoDir }); } catch {} |
| 271 | + }; |
| 272 | + worktreeCleanup = () => { |
| 273 | + if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } |
| 274 | + return removeWorktree(reviewRuntime, worktreePath, { force: true, cwd: wtRepoDir }); |
| 275 | + }; |
| 276 | + process.once("exit", exitHandler); |
| 277 | + } else { |
| 278 | + // ── Cross-repo: shallow clone + fetch PR head ── |
| 279 | + const prRepo = prMetadata.platform === "github" |
| 280 | + ? `${prMetadata.owner}/${prMetadata.repo}` |
| 281 | + : prMetadata.projectPath; |
| 282 | + if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); |
| 283 | + const cli = prMetadata.platform === "github" ? "gh" : "glab"; |
| 284 | + const host = prMetadata.host; |
| 285 | + // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead |
| 286 | + const isDefaultHost = host === "github.com" || host === "gitlab.com"; |
| 287 | + const cloneEnv = isDefaultHost ? undefined : { |
| 288 | + ...process.env, |
| 289 | + ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), |
| 290 | + }; |
| 291 | + |
| 292 | + console.error(`Cloning ${prRepo} (shallow)...`); |
| 293 | + const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv }); |
| 294 | + if ((cloneResult.status ?? 1) !== 0) { |
| 295 | + throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`); |
| 296 | + } |
| 297 | + |
| 298 | + console.error("Fetching PR branch..."); |
| 299 | + const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath }); |
| 300 | + if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`); |
| 301 | + |
| 302 | + const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath }); |
| 303 | + if (checkoutResult.exitCode !== 0) { |
| 304 | + throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`); |
| 305 | + } |
| 306 | + |
| 307 | + // Best-effort: create base refs so agent diffs work |
| 308 | + const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath }); |
| 309 | + if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); |
| 310 | + await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath }); |
| 311 | + await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath }); |
| 312 | + |
| 313 | + const clonePath = localPath; |
| 314 | + exitHandler = () => { |
| 315 | + try { rmSync(clonePath, { recursive: true, force: true }); } catch {} |
| 316 | + }; |
| 317 | + worktreeCleanup = () => { |
| 318 | + if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } |
| 319 | + try { rmSync(clonePath, { recursive: true, force: true }); } catch {} |
| 320 | + }; |
| 321 | + process.once("exit", exitHandler); |
| 322 | + } |
| 323 | + |
| 324 | + agentCwd = localPath; |
| 325 | + console.error(`Local checkout ready at ${localPath}`); |
| 326 | + } catch (err) { |
| 327 | + console.error("Warning: local worktree creation failed, falling back to remote diff"); |
| 328 | + console.error(err instanceof Error ? err.message : String(err)); |
| 329 | + if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } |
| 330 | + if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {} |
| 331 | + agentCwd = undefined; |
| 332 | + worktreeCleanup = undefined; |
| 333 | + } |
| 334 | + } else { |
| 335 | + // --- Local Review Mode --- |
| 336 | + const cwd = options.cwd ?? ctx.cwd; |
| 337 | + gitCtx = await getGitContext(cwd); |
| 338 | + const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch; |
| 339 | + diffType = options.diffType ?? "uncommitted"; |
| 340 | + const result = await runGitDiff(diffType, defaultBranch, cwd); |
| 341 | + rawPatch = result.patch; |
| 342 | + gitRef = result.label; |
| 343 | + diffError = result.error; |
| 344 | + } |
| 345 | + |
165 | 346 | const server = await startReviewServer({ |
166 | 347 | rawPatch, |
167 | 348 | gitRef, |
168 | | - error, |
| 349 | + error: diffError, |
169 | 350 | origin: "pi", |
170 | 351 | diffType, |
171 | 352 | gitContext: gitCtx, |
| 353 | + prMetadata, |
| 354 | + agentCwd, |
172 | 355 | htmlContent: reviewHtmlContent, |
173 | 356 | sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", |
174 | 357 | shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, |
| 358 | + onCleanup: worktreeCleanup, |
175 | 359 | }); |
176 | 360 |
|
177 | 361 | return openBrowserAndWait(server, ctx, server.waitForDecision); |
|
0 commit comments