Commit 2cf079dbf030

Vincent Demeester <vincent@sbr.pm>
2026-02-10 16:27:10
feat: add PR selector to /review command
Replaced blank text input with interactive SelectList of open PRs when reviewing a pull request. Shows title, author, branch, review status with smart ordering (needs-review first). Manual entry option kept for cross-repo PRs. Works from menu and /review pr shortcut.
1 parent 83a161b
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/review.ts
@@ -1020,7 +1020,49 @@ export default function reviewExtension(pi: ExtensionAPI) {
 	}
 
 	/**
-	 * Show PR input and handle checkout
+	 * Fetch open PRs from GitHub for the selector
+	 */
+	async function fetchOpenPRs(): Promise<Array<{ number: number; title: string; author: string; branch: string; base: string; isDraft: boolean; reviewDecision: string }>> {
+		const { stdout, code } = await pi.exec("gh", [
+			"pr", "list",
+			"--state", "open",
+			"--json", "number,title,author,headRefName,baseRefName,isDraft,reviewDecision",
+			"--limit", "30",
+		]);
+
+		if (code !== 0) return [];
+
+		try {
+			const data = JSON.parse(stdout);
+			if (!Array.isArray(data)) return [];
+			return data.map((item: any) => ({
+				number: item.number ?? 0,
+				title: item.title ?? "",
+				author: item.author?.login ?? "",
+				branch: item.headRefName ?? "",
+				base: item.baseRefName ?? "",
+				isDraft: item.isDraft ?? false,
+				reviewDecision: item.reviewDecision ?? "",
+			}));
+		} catch {
+			return [];
+		}
+	}
+
+	/**
+	 * Get review decision label for display
+	 */
+	function reviewDecisionLabel(decision: string): string {
+		switch (decision) {
+			case "APPROVED": return " ✓approved";
+			case "CHANGES_REQUESTED": return " ✗changes requested";
+			case "REVIEW_REQUIRED": return " ⏳review needed";
+			default: return "";
+		}
+	}
+
+	/**
+	 * Show PR selector with list of open PRs + manual entry option
 	 */
 	async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
 		// First check for pending changes that would prevent branch switching
@@ -1029,18 +1071,95 @@ export default function reviewExtension(pi: ExtensionAPI) {
 			return null;
 		}
 
-		// Get PR reference from user
-		const prRef = await ctx.ui.editor(
-			"Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):",
-			"",
-		);
+		// Fetch open PRs
+		ctx.ui.notify("Fetching open PRs...", "info");
+		const prs = await fetchOpenPRs();
 
-		if (!prRef?.trim()) return null;
+		// Try to get current user for smart ordering
+		let currentUser = "";
+		const { stdout: userOut, code: userCode } = await pi.exec("gh", ["api", "user", "--jq", ".login"], { timeout: 5000 });
+		if (userCode === 0) currentUser = userOut.trim();
 
-		const prNumber = parsePrReference(prRef);
-		if (!prNumber) {
-			ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
-			return null;
+		// Sort: PRs needing your review first, then your own, then others
+		const sorted = prs.slice().sort((a, b) => {
+			const aScore = a.reviewDecision === "REVIEW_REQUIRED" && a.author !== currentUser ? 0
+				: a.author === currentUser ? 1
+				: 2;
+			const bScore = b.reviewDecision === "REVIEW_REQUIRED" && b.author !== currentUser ? 0
+				: b.author === currentUser ? 1
+				: 2;
+			return aScore - bScore;
+		});
+
+		// Build select items
+		const items: SelectItem[] = sorted.map((pr) => {
+			const draft = pr.isDraft ? " [draft]" : "";
+			const review = reviewDecisionLabel(pr.reviewDecision);
+			return {
+				value: String(pr.number),
+				label: `#${pr.number} ${pr.title}${draft}${review}`,
+				description: `@${pr.author}  ${pr.branch} → ${pr.base}`,
+			};
+		});
+
+		// Add manual entry option at the bottom
+		items.push({
+			value: "__manual__",
+			label: "Enter PR number manually…",
+			description: "(for cross-repo or closed PRs)",
+		});
+
+		// Show selector
+		const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+			const container = new Container();
+			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+			container.addChild(new Text(theme.fg("accent", theme.bold("Select a pull request to review"))));
+
+			const selectList = new SelectList(items, Math.min(items.length, 12), {
+				selectedPrefix: (text) => theme.fg("accent", text),
+				selectedText: (text) => theme.fg("accent", text),
+				description: (text) => theme.fg("muted", text),
+				scrollInfo: (text) => theme.fg("dim", text),
+				noMatch: (text) => theme.fg("warning", text),
+			});
+
+			selectList.searchable = true;
+			selectList.onSelect = (item) => done(item.value);
+			selectList.onCancel = () => done(null);
+
+			container.addChild(selectList);
+			container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
+			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+
+			return {
+				render(width: number) { return container.render(width); },
+				invalidate() { container.invalidate(); },
+				handleInput(data: string) {
+					selectList.handleInput(data);
+					tui.requestRender();
+				},
+			};
+		});
+
+		if (!selected) return null;
+
+		// Manual entry fallback
+		let prNumber: number;
+		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):",
+				"",
+			);
+			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");
+				return null;
+			}
+			prNumber = parsed;
+		} else {
+			prNumber = parseInt(selected, 10);
 		}
 
 		// Get PR info from GitHub
@@ -1227,7 +1346,7 @@ export default function reviewExtension(pi: ExtensionAPI) {
 
 			case "pr": {
 				const ref = parts[1];
-				if (!ref) return null;
+				if (!ref) return { type: "pr", ref: "__select__" };
 				return { type: "pr", ref };
 			}
 
@@ -1309,8 +1428,13 @@ export default function reviewExtension(pi: ExtensionAPI) {
 
 			if (parsed) {
 				if (parsed.type === "pr") {
-					// Handle PR checkout (async operation)
-					target = await handlePrCheckout(ctx, parsed.ref);
+					if (parsed.ref === "__select__") {
+						// `/review pr` with no number — show PR selector
+						target = await showPrInput(ctx);
+					} else {
+						// `/review pr 123` or `/review 123` — direct checkout
+						target = await handlePrCheckout(ctx, parsed.ref);
+					}
 					if (!target) {
 						ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
 					}