Commit 9a3257d483e8

Vincent Demeester <vincent@sbr.pm>
2026-02-11 15:37:10
feat(pi/github): add PR template detection and worktree support
- Auto-detect PR templates in common locations (.github/, docs/) - Use --template flag when creating PRs (if no body provided) - Show template path in confirmation dialog - Detect git worktree context from recent bash operations - Automatically use worktree path for gh commands - Fix PR creation when working in worktrees - Add helpful error messages for common PR creation failures - Guide users on missing commits, uncommitted changes, etc.
1 parent cb1d81e
Changed files (2)
dots
pi
agent
extensions
github
dots/pi/agent/extensions/github/actions/pr.ts
@@ -25,6 +25,7 @@ import {
 	getReviewDecisionText,
 	formatRelativeDate,
 	execGh,
+	findPRTemplate,
 } from "../utils";
 
 const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -240,9 +241,15 @@ export async function handlePRCreate(
 		};
 	}
 
+	// Find PR template
+	const template = await findPRTemplate(pi, ctx);
+
 	// APPROVAL GATE
 	if (ctx.hasUI) {
-		const confirmMessage = buildPRCreateConfirmation(params);
+		let confirmMessage = buildPRCreateConfirmation(params);
+		if (template) {
+			confirmMessage += `\n\nTemplate: ${template}`;
+		}
 		const confirmed = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
 		if (!confirmed) {
 			ctx.ui.notify("PR creation cancelled", "info");
@@ -255,6 +262,11 @@ export async function handlePRCreate(
 
 	const args = ["pr", "create", "--title", params.title];
 
+	// Add template if found and body not explicitly provided
+	if (template && !params.body) {
+		args.push("--template", template);
+	}
+
 	if (params.body) args.push("--body", params.body);
 	if (params.base) args.push("--base", params.base);
 	if (params.draft) args.push("--draft");
@@ -270,8 +282,22 @@ export async function handlePRCreate(
 	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
+		let errorMsg = getErrorMessage(result.stderr, "Create PR");
+		
+		// Add helpful hints for common issues
+		if (result.stderr.includes("No commits between")) {
+			errorMsg += "\n\n💡 Tip: Make sure you have committed and pushed your changes to the branch.";
+			errorMsg += "\n   If working in a worktree, ensure you're on the correct branch.";
+		}
+		if (result.stderr.includes("uncommitted changes")) {
+			errorMsg += "\n\n💡 Tip: Commit or stash your changes before creating a PR.";
+		}
+		if (result.stderr.includes("head repository")) {
+			errorMsg += "\n\n💡 Tip: Make sure you've pushed your branch to your fork.";
+		}
+		
 		return {
-			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create PR") }],
+			content: [{ type: "text", text: errorMsg }],
 			details: { action: "pr-create", error: result.stderr } as GhDetails,
 			isError: true,
 		};
dots/pi/agent/extensions/github/utils.ts
@@ -10,6 +10,7 @@ import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReview
 // ============================================================================
 
 let cachedGitRoot: string | null = null;
+let cachedWorktreeRoot: string | null = null;
 
 /**
  * Find the git repository root directory.
@@ -30,16 +31,72 @@ async function findGitRoot(pi: ExtensionAPI, cwd: string): Promise<string | null
 	return null;
 }
 
+/**
+ * Detect if we're working in a git worktree context.
+ * Checks recent bash operations to find where actual work is happening.
+ * Returns the worktree path if detected, null otherwise.
+ */
+async function detectWorktreeContext(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext
+): Promise<string | null> {
+	if (cachedWorktreeRoot !== null) return cachedWorktreeRoot;
+	
+	// Strategy 1: Check if ctx.cwd itself is a worktree
+	const gitDirResult = await pi.exec("git", ["rev-parse", "--git-dir"], { cwd: ctx.cwd, timeout: 5000 });
+	if (gitDirResult.code === 0 && gitDirResult.stdout.includes("/worktrees/")) {
+		// We're already in a worktree
+		const toplevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.cwd, timeout: 5000 });
+		if (toplevel.code === 0 && toplevel.stdout.trim()) {
+			cachedWorktreeRoot = toplevel.stdout.trim();
+			return cachedWorktreeRoot;
+		}
+	}
+	
+	// Strategy 2: Check for recent git operations in session messages
+	// Look for bash tool results with git commands that mention worktree paths
+	try {
+		const entries = ctx.sessionManager.getBranch();
+		for (const entry of entries.reverse()) { // Start from most recent
+			if (entry.type !== "message") continue;
+			const msg = entry.message;
+			if (msg.role !== "toolResult" || msg.toolName !== "bash") continue;
+			
+			const details = msg.details as any;
+			if (!details?.cwd) continue;
+			
+			// Check if this command was run in a worktree directory
+			if (details.cwd.includes("/.local/share/worktrees/")) {
+				// Verify it's still a valid git directory
+				const checkResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { 
+					cwd: details.cwd, 
+					timeout: 5000 
+				});
+				if (checkResult.code === 0 && checkResult.stdout.trim()) {
+					cachedWorktreeRoot = checkResult.stdout.trim();
+					return cachedWorktreeRoot;
+				}
+			}
+		}
+	} catch {
+		// Ignore errors in session scanning
+	}
+	
+	return null;
+}
+
 /**
  * Reset cached git root (call on session change)
  */
 export function resetGitRoot() {
 	cachedGitRoot = null;
+	cachedWorktreeRoot = null;
 }
 
 /**
  * Execute gh command with correct working directory.
  * Automatically uses git repository root if available.
+ * Detects and prefers worktree context when available.
  */
 export async function execGh(
 	pi: ExtensionAPI,
@@ -47,11 +104,47 @@ export async function execGh(
 	args: string[],
 	options?: { signal?: AbortSignal; timeout?: number }
 ) {
+	// First check if we're working in a worktree context
+	const worktree = await detectWorktreeContext(pi, ctx);
+	if (worktree) {
+		return await pi.exec("gh", args, { ...options, cwd: worktree });
+	}
+	
+	// Fall back to regular git root detection
 	const root = await findGitRoot(pi, ctx.cwd);
 	const cwd = root ?? ctx.cwd;
 	return await pi.exec("gh", args, { ...options, cwd });
 }
 
+/**
+ * Find PR template in common locations.
+ * Returns the path to the template file if found, null otherwise.
+ */
+export async function findPRTemplate(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext
+): Promise<string | null> {
+	// Determine the correct working directory (worktree-aware)
+	const worktree = await detectWorktreeContext(pi, ctx);
+	const gitRoot = worktree ?? await findGitRoot(pi, ctx.cwd) ?? ctx.cwd;
+	
+	const templatePaths = [
+		".github/PULL_REQUEST_TEMPLATE.md",
+		".github/pull_request_template.md",
+		"docs/PULL_REQUEST_TEMPLATE.md",
+		"PULL_REQUEST_TEMPLATE.md",
+	];
+	
+	for (const templatePath of templatePaths) {
+		const result = await pi.exec("test", ["-f", templatePath], { cwd: gitRoot });
+		if (result.code === 0) {
+			return templatePath;
+		}
+	}
+	
+	return null;
+}
+
 // ============================================================================
 // Parsing: gh CLI JSON output → typed objects
 // ============================================================================