fix(web): pre-fill PR head branch when launching task from GitHub PR list (#960)

This commit is contained in:
Carlos Florêncio
2026-05-19 22:04:28 +01:00
committed by GitHub
parent 2d219f1d1b
commit e32c6a252f
7 changed files with 232 additions and 15 deletions
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { pickPRForLaunch } from "./pr-list";
function makePR(overrides?: Partial<GitHubPR>): GitHubPR {
return {
number: 42,
title: "Test PR",
url: "https://github.com/owner/repo/pull/42",
html_url: "https://github.com/owner/repo/pull/42",
state: "open",
head_branch: "",
base_branch: "",
author_login: "author",
repo_owner: "owner",
repo_name: "repo",
draft: false,
mergeable: true,
additions: 0,
deletions: 0,
requested_reviewers: [],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
merged_at: null,
closed_at: null,
...overrides,
};
}
function makeStatus(pr: GitHubPR): GitHubPRStatus {
return {
pr,
review_state: "",
checks_state: "",
mergeable_state: "unknown",
review_count: 0,
pending_review_count: 0,
checks_total: 0,
checks_passing: 0,
};
}
describe("pickPRForLaunch", () => {
it("prefers the enriched PR from the batched status (which carries head/base branches)", () => {
const rawSearchPR = makePR({ head_branch: "", base_branch: "" });
const enrichedPR = makePR({ head_branch: "feature/x", base_branch: "main" });
const result = pickPRForLaunch(rawSearchPR, makeStatus(enrichedPR));
expect(result.head_branch).toBe("feature/x");
expect(result.base_branch).toBe("main");
});
it("falls back to the raw PR when no status is loaded yet", () => {
const rawPR = makePR({ head_branch: "feature/raw", base_branch: "main" });
expect(pickPRForLaunch(rawPR, undefined).head_branch).toBe("feature/raw");
expect(pickPRForLaunch(rawPR, null).head_branch).toBe("feature/raw");
});
});
@@ -31,6 +31,13 @@ type PRListProps = {
onStartTask: (payload: LaunchPayload) => void;
};
// Prefer the enriched PR returned by the batched status endpoint — the search
// API used to populate `items` does not include head/base branches, so the
// launcher needs the enriched copy to pre-fill the task dialog correctly.
export function pickPRForLaunch(pr: GitHubPR, status: GitHubPRStatus | null | undefined): GitHubPR {
return status?.pr ?? pr;
}
function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
if (pr.state === "merged")
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
@@ -53,7 +60,12 @@ function StartTaskMenu({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="h-7 gap-1 cursor-pointer">
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
data-testid="pr-start-task-trigger"
>
<IconPlus className="h-3.5 w-3.5" />
Task
<IconChevronDown className="h-3 w-3" />
@@ -67,6 +79,8 @@ function StartTaskMenu({
key={p.id}
className="cursor-pointer gap-2 py-1.5"
onSelect={() => launch(p)}
data-testid="pr-start-task-preset"
data-preset-id={p.id}
>
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
@@ -94,7 +108,11 @@ function PRRow({
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
return (
<div className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors">
<div
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
data-testid="pr-row"
data-pr-number={pr.number}
>
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
<div className="min-w-0 flex-1">
<Link
@@ -117,7 +135,11 @@ function PRRow({
</div>
</div>
<div className="shrink-0">
<StartTaskMenu pr={pr} presets={presets} onStartTask={onStartTask} />
<StartTaskMenu
pr={pickPRForLaunch(pr, status)}
presets={presets}
onStartTask={onStartTask}
/>
</div>
</div>
);
@@ -36,6 +36,10 @@ function matchRepo(repos: Repository[], owner: string, name: string): Repository
);
}
function emptyToUndefined(value: string | undefined): string | undefined {
return value ? value : undefined;
}
function extractPayload(payload: LaunchPayload) {
if (payload.kind === "pr") {
return {
@@ -43,7 +47,7 @@ function extractPayload(payload: LaunchPayload) {
title: payload.pr.title,
owner: payload.pr.repo_owner,
name: payload.pr.repo_name,
branch: payload.pr.head_branch,
branch: emptyToUndefined(payload.pr.head_branch),
};
}
return {
@@ -60,6 +64,10 @@ function buildDialogState(payload: LaunchPayload, repositories: Repository[]): D
const repo = matchRepo(repositories, data.owner, data.name);
const description = payload.preset.prompt({ url: data.url, title: data.title });
const title = `${payload.preset.label}: ${data.title}`;
// For a PR launch we want the dialog to display and check out the PR's head
// branch — matching the GitHub-URL-paste flow, where the branch selector
// auto-resolves to the PR head. Same branch for both: the chip shows it and
// the worktree checks it out.
const checkoutBranch = payload.kind === "pr" ? data.branch : undefined;
if (repo) {
return {