3d860290da
* feat(github): add merge button when PR is ready to merge Surfaces a one-click merge action in the PR detail panel header and as a compact green pill in the PR topbar hover popover, gated by the existing isPRReadyToMerge predicate (open, CI success, mergeable_state=clean, and either approved or no review process). Backend adds MergePR to the Client interface, implements it across PAT/gh/Noop/Mock clients, exposes PUT /api/v1/github/prs/:owner/:repo/:number/merge, and maps GitHub 405/409 to HTTP 409 so the UI can show a clear error. * fix(github): address PR review feedback on merge endpoint - Map gh CLI 404/403/405/409 stderr to *GitHubAPIError via a new ghMergeStatusCode helper, so merge conflicts surface as HTTP 409 for gh CLI users (previously only PAT users got the right status). - Service.MergePR returns ErrNoClient when unconfigured; httpMergePR translates that to HTTP 503 with github_not_configured, matching httpListRepoBranches. - httpMergePR distinguishes empty body (io.EOF, fall through to default merge method) from malformed JSON (HTTP 400 invalid payload). - PRMergeButton tracks a local merged flag so the button stays hidden across the async refresh window after a successful merge. * test(github): cover merge helpers and extract PRMergeButton - TestGhMergeStatusCode locks in the gh CLI stderr → status-code parser for nil, unrelated errors, the HTTP / status: / bare-phrase forms of 404/403/405/409, and a 500 non-match guard. - Broaden ghMergeStatusCode to also match "405 Method Not Allowed" / "409 Conflict" phrase forms so the merge endpoint's 409 mapping does not depend on the gh CLI keeping its current "HTTP <code>" prefix. - TestHttpMergePR_MalformedJSON covers the new non-EOF bind-error branch (truncated body → 400). TestHttpMergePR_NoClient_Returns503 covers the ErrNoClient → 503 path on Service.MergePR. - Document the post/put error-type divergence on PATClient.post so a future caller does not silently lose per-status mapping. - Move PRMergeButton out of pr-shared.tsx into its own file, and reset the local "merged" flag when taskPR.id changes so an instance that re-renders for a different PR does not stay hidden. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { IconGitMerge } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { mergePR } from "@/lib/api/domains/github-api";
|
|
import type { TaskPR } from "@/lib/types/github";
|
|
import { isPRReadyToMerge } from "./pr-task-icon";
|
|
|
|
// Renders nothing unless the PR is fully green (CI success + mergeable +
|
|
// approval or no-review-needed). Shared by the PR detail panel header and
|
|
// the topbar hover popover so a "ready" PR can be merged from either
|
|
// surface. `compact` switches to the smaller pill variant used inside the
|
|
// dense popover.
|
|
export function PRMergeButton({
|
|
taskPR,
|
|
onMerged,
|
|
compact = false,
|
|
}: {
|
|
taskPR: TaskPR;
|
|
onMerged?: () => void;
|
|
compact?: boolean;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const [merging, setMerging] = useState(false);
|
|
// After a successful merge we stay hidden until the store catches up to
|
|
// state="merged" — otherwise the button briefly re-enables during the async
|
|
// refresh window and a double-click would hit the GitHub API again.
|
|
const [merged, setMerged] = useState(false);
|
|
|
|
// If the same component instance ever renders a different PR (e.g. the user
|
|
// switches the active task while the panel/popover stays mounted), the
|
|
// sticky `merged` flag from a previous merge would hide the button for an
|
|
// unrelated, still-mergeable PR. Reset it whenever the underlying PR id
|
|
// changes.
|
|
useEffect(() => {
|
|
setMerged(false);
|
|
}, [taskPR.id]);
|
|
|
|
if (merged || !isPRReadyToMerge(taskPR)) return null;
|
|
|
|
const handleMerge = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setMerging(true);
|
|
try {
|
|
await mergePR(taskPR.owner, taskPR.repo, taskPR.pr_number);
|
|
setMerged(true);
|
|
toast({ description: "PR merged", variant: "success" });
|
|
onMerged?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to merge",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "error",
|
|
});
|
|
} finally {
|
|
setMerging(false);
|
|
}
|
|
};
|
|
|
|
if (compact) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid="pr-merge-button"
|
|
onClick={handleMerge}
|
|
disabled={merging}
|
|
className="self-end inline-flex items-center gap-1 rounded-full bg-green-600 px-2 py-0.5 text-[11px] font-medium text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
|
|
>
|
|
<IconGitMerge className="h-3 w-3" />
|
|
{merging ? "Merging..." : "Merge"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
data-testid="pr-merge-button"
|
|
size="sm"
|
|
className="cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500"
|
|
onClick={handleMerge}
|
|
disabled={merging}
|
|
>
|
|
<IconGitMerge className="h-3.5 w-3.5" />
|
|
{merging ? "Merging..." : "Merge PR"}
|
|
</Button>
|
|
);
|
|
}
|