Commit bbd8aa24e4e7

Vincent Demeester <vincent@sbr.pm>
2026-02-17 22:25:16
feat(pi): support cross-repo PR reviews
Preserved repository context (owner/repo) from PR references throughout the review flow, passing -R flag to gh CLI commands. Enables /review with URLs, owner/repo#number syntax, and manual entry for PRs outside the current repository.
1 parent a91ea4b
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/review.ts
@@ -328,7 +328,7 @@ type ReviewTarget =
 	| { type: "baseBranch"; branch: string }
 	| { type: "commit"; sha: string; title?: string }
 	| { type: "custom"; instructions: string }
-	| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
+	| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string; repo?: string }
 	| { type: "folder"; paths: string[] };
 
 // Prompts (adapted from Codex)
@@ -541,30 +541,38 @@ async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
 }
 
 /**
- * Parse a PR reference (URL, number, or owner/repo#number) and return the PR number
+ * Parsed PR reference with optional repository context
  */
-function parsePrReference(ref: string): number | null {
+type PrReference = {
+	number: number;
+	repo?: string; // "owner/repo" format, undefined means current repo
+};
+
+/**
+ * Parse a PR reference (URL, number, or owner/repo#number) and return the PR number + optional repo
+ */
+function parsePrReference(ref: string): PrReference | null {
 	const trimmed = ref.trim();
 
 	// Try as a number first
 	const num = parseInt(trimmed, 10);
 	if (!isNaN(num) && num > 0) {
-		return num;
+		return { number: num };
 	}
 
 	// Try to extract from GitHub URL
 	// Formats: https://github.com/owner/repo/pull/123
 	//          github.com/owner/repo/pull/123
-	const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
+	const urlMatch = trimmed.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
 	if (urlMatch) {
-		return parseInt(urlMatch[1], 10);
+		return { number: parseInt(urlMatch[2], 10), repo: urlMatch[1] };
 	}
 
 	// Try to extract from owner/repo#number format
 	// Format: tektoncd/pipeline#1234
-	const repoMatch = trimmed.match(/^[^/]+\/[^#]+#(\d+)$/);
+	const repoMatch = trimmed.match(/^([^/]+\/[^#]+)#(\d+)$/);
 	if (repoMatch) {
-		return parseInt(repoMatch[1], 10);
+		return { number: parseInt(repoMatch[2], 10), repo: repoMatch[1] };
 	}
 
 	return null;
@@ -573,11 +581,12 @@ function parsePrReference(ref: string): number | null {
 /**
  * Get PR information from GitHub CLI
  */
-async function getPrInfo(pi: ExtensionAPI, prNumber: number): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
-	const { stdout, code } = await pi.exec("gh", [
-		"pr", "view", String(prNumber),
-		"--json", "baseRefName,title,headRefName",
-	]);
+async function getPrInfo(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
+	const args = ["pr", "view", String(prNumber), "--json", "baseRefName,title,headRefName"];
+	if (repo) {
+		args.push("-R", repo);
+	}
+	const { stdout, code } = await pi.exec("gh", args);
 
 	if (code !== 0) return null;
 
@@ -596,8 +605,12 @@ async function getPrInfo(pi: ExtensionAPI, prNumber: number): Promise<{ baseBran
 /**
  * Checkout a PR using GitHub CLI
  */
-async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
-	const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
+async function checkoutPr(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ success: boolean; error?: string }> {
+	const args = ["pr", "checkout", String(prNumber)];
+	if (repo) {
+		args.push("-R", repo);
+	}
+	const { stdout, stderr, code } = await pi.exec("gh", args);
 
 	if (code !== 0) {
 		return { success: false, error: stderr || stdout || "Failed to checkout PR" };
@@ -701,7 +714,8 @@ function getUserFacingHint(target: ReviewTarget): string {
 
 		case "pullRequest": {
 			const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
-			return `PR #${target.prNumber}: ${shortTitle}`;
+			const repoPrefix = target.repo ? `${target.repo}#` : "PR #";
+			return `${repoPrefix}${target.prNumber}: ${shortTitle}`;
 		}
 
 		case "folder": {
@@ -1210,29 +1224,33 @@ export default function reviewExtension(pi: ExtensionAPI) {
 
 		// Manual entry fallback
 		let prNumber: number;
+		let prRepo: string | undefined;
 		if (selected === "__manual__") {
 			const prRef = await ctx.ui.editor(
-				"Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):",
+				"Enter PR number, owner/repo#number, or URL (e.g. 123, tektoncd/pipeline#456, or https://github.com/owner/repo/pull/123):",
 				"",
 			);
 			if (!prRef?.trim()) return null;
 
 			const parsed = parsePrReference(prRef);
 			if (!parsed) {
-				ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
+				ctx.ui.notify("Invalid PR reference. Enter a number, owner/repo#number, or GitHub PR URL.", "error");
 				return null;
 			}
-			prNumber = parsed;
+			prNumber = parsed.number;
+			prRepo = parsed.repo;
 		} else {
 			prNumber = parseInt(selected, 10);
 		}
 
+		const repoLabel = prRepo ? ` (${prRepo})` : "";
+
 		// Get PR info from GitHub
-		ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
-		const prInfo = await getPrInfo(pi, prNumber);
+		ctx.ui.notify(`Fetching PR #${prNumber} info${repoLabel}...`, "info");
+		const prInfo = await getPrInfo(pi, prNumber, prRepo);
 
 		if (!prInfo) {
-			ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
+			ctx.ui.notify(`Could not find PR #${prNumber}${repoLabel}. Make sure gh is authenticated and the PR exists.`, "error");
 			return null;
 		}
 
@@ -1243,21 +1261,22 @@ export default function reviewExtension(pi: ExtensionAPI) {
 		}
 
 		// Checkout the PR
-		ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
-		const checkoutResult = await checkoutPr(pi, prNumber);
+		ctx.ui.notify(`Checking out PR #${prNumber}${repoLabel}...`, "info");
+		const checkoutResult = await checkoutPr(pi, prNumber, prRepo);
 
 		if (!checkoutResult.success) {
 			ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
 			return null;
 		}
 
-		ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
+		ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})${repoLabel}`, "info");
 
 		return {
 			type: "pullRequest",
 			prNumber,
 			baseBranch: prInfo.baseBranch,
 			title: prInfo.title,
+			repo: prRepo,
 		};
 	}
 
@@ -1366,15 +1385,16 @@ export default function reviewExtension(pi: ExtensionAPI) {
 	 * Parse command arguments for direct invocation
 	 * Returns the target or a special marker for PR that needs async handling
 	 */
-	function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string } | null {
+	function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string; repo?: string } | null {
 		if (!args?.trim()) return null;
 
 		const trimmed = args.trim();
 
 		// Check if it looks like a PR reference (URL, number, or owner/repo#number)
 		// This allows `/review 123`, `/review https://...`, or `/review tektoncd/pipeline#1234`
-		if (parsePrReference(trimmed) !== null) {
-			return { type: "pr", ref: trimmed };
+		const prRef = parsePrReference(trimmed);
+		if (prRef !== null) {
+			return { type: "pr", ref: trimmed, repo: prRef.repo };
 		}
 
 		const parts = trimmed.split(/\s+/);
@@ -1412,7 +1432,8 @@ export default function reviewExtension(pi: ExtensionAPI) {
 			case "pr": {
 				const ref = parts[1];
 				if (!ref) return { type: "pr", ref: "__select__" };
-				return { type: "pr", ref };
+				const parsed = parsePrReference(ref);
+				return { type: "pr", ref, repo: parsed?.repo };
 			}
 
 			default:
@@ -1423,44 +1444,49 @@ export default function reviewExtension(pi: ExtensionAPI) {
 	/**
 	 * Handle PR checkout and return a ReviewTarget (or null on failure)
 	 */
-	async function handlePrCheckout(ctx: ExtensionContext, ref: string): Promise<ReviewTarget | null> {
+	async function handlePrCheckout(ctx: ExtensionContext, ref: string, repo?: string): Promise<ReviewTarget | null> {
 		// First check for pending changes
 		if (await hasPendingChanges(pi)) {
 			ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
 			return null;
 		}
 
-		const prNumber = parsePrReference(ref);
-		if (!prNumber) {
+		const prRef = parsePrReference(ref);
+		if (!prRef) {
 			ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
 			return null;
 		}
 
+		// Use repo from the parsed reference if not explicitly provided
+		const effectiveRepo = repo ?? prRef.repo;
+		const repoLabel = effectiveRepo ? ` (${effectiveRepo})` : "";
+
 		// Get PR info
-		ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
-		const prInfo = await getPrInfo(pi, prNumber);
+		ctx.ui.notify(`Fetching PR #${prRef.number} info${repoLabel}...`, "info");
+		const prInfo = await getPrInfo(pi, prRef.number, effectiveRepo);
 
 		if (!prInfo) {
-			ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
+			ctx.ui.notify(`Could not find PR #${prRef.number}${repoLabel}. Make sure gh is authenticated and the PR exists.`, "error");
 			return null;
 		}
 
 		// Checkout the PR
-		ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
-		const checkoutResult = await checkoutPr(pi, prNumber);
+		ctx.ui.notify(`Checking out PR #${prRef.number}${repoLabel}...`, "info");
+		const checkoutResult = await checkoutPr(pi, prRef.number, effectiveRepo);
 
 		if (!checkoutResult.success) {
 			ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
 			return null;
 		}
 
-		ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
+		ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch})${repoLabel}`, "info");
 
 		return {
 			type: "pullRequest",
-			prNumber,
+			prNumber: prRef.number,
 			baseBranch: prInfo.baseBranch,
 			title: prInfo.title,
+			repo: effectiveRepo,
 		};
 	}
 
@@ -1498,7 +1524,7 @@ export default function reviewExtension(pi: ExtensionAPI) {
 						target = await showPrInput(ctx);
 					} else {
 						// `/review pr 123` or `/review 123` — direct checkout
-						target = await handlePrCheckout(ctx, parsed.ref);
+						target = await handlePrCheckout(ctx, parsed.ref, parsed.repo);
 					}
 					if (!target) {
 						ctx.ui.notify("PR review failed. Returning to review menu.", "warning");