fix(web): pre-fill PR head branch when launching task from GitHub PR list (#960)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user