Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/renderer/hooks/usePendingPrRefresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,30 @@ describe("usePendingPrRefresh", () => {
expect(ghGetPrForBranchMock).not.toHaveBeenCalled();
expect(ghGetPrDetailsMock).not.toHaveBeenCalled();
});

it("refetches when cached PR details still have pending checks", async () => {
useGitStore.getState().setPrData("pr-key", { ...basePr, checksStatus: "SUCCESS" });
useGitStore.getState().setPrDetails("p1#42", baseDetails);
ghGetPrDetailsMock.mockResolvedValueOnce({
details: {
...baseDetails,
checks: [{ name: "CI", state: "COMPLETED", conclusion: "SUCCESS" }],
},
});

renderHook(() =>
usePendingPrRefresh({
prKey: "pr-key",
projectLocation: location,
branch: undefined,
cacheKey: "p1#42",
}),
);

await act(async () => {});

expect(ghGetPrForBranchMock).not.toHaveBeenCalled();
expect(ghGetPrDetailsMock).toHaveBeenCalledWith({ projectLocation: location, prNumber: 42 });
expect(useGitStore.getState().prDetails["p1#42"]?.checks[0]?.conclusion).toBe("SUCCESS");
});
});
20 changes: 12 additions & 8 deletions src/renderer/hooks/usePendingPrRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useEffect } from "react";
import type { ProjectLocation } from "@/shared/contracts";
import { readBridge } from "@/renderer/bridge";
import { useGitStore } from "@/renderer/state/gitStore";
import { usePrChecksStatus, usePrNumber, usePrState } from "@/renderer/state/gitSelectors";
import { usePrNumber, usePrState } from "@/renderer/state/gitSelectors";
import { getPrStatusTone } from "@/renderer/utils/prStatus";
import { usePrCombinedChecksStatus } from "./usePrCombinedChecksStatus";

export const PR_PENDING_REFRESH_INTERVAL_MS = 30_000;

Expand All @@ -16,15 +17,18 @@ export function usePendingPrRefresh(params: {
const { prKey, projectLocation, branch, cacheKey } = params;
const state = usePrState(prKey);
const number = usePrNumber(prKey);
const checksStatus = usePrChecksStatus(prKey);
const details = useGitStore((s) => (cacheKey ? s.prDetails[cacheKey] : undefined));
const effectiveChecksStatus = usePrCombinedChecksStatus(prKey, cacheKey);
const prNumber = number ?? details?.number;

useEffect(() => {
if (!prKey || getPrStatusTone(state, checksStatus) !== "warning") return;
if (!prKey || getPrStatusTone(state, effectiveChecksStatus) !== "warning") return;

const targetPrKey = prKey;
const targetBranch = branch;
const detailsCacheKey = cacheKey;
const detailsPrNumber = number;
const detailsPrNumber = prNumber;
if (!targetBranch && (!detailsCacheKey || !detailsPrNumber)) return;
let cancelled = false;
let inFlight = false;

Expand All @@ -44,11 +48,11 @@ export function usePendingPrRefresh(params: {
.ghGetPrDetails({ projectLocation, prNumber: detailsPrNumber })
.catch(() => undefined)
: Promise.resolve(undefined);
const [pr, details] = await Promise.all([prPromise, detailsPromise]);
const [pr, refreshedDetails] = await Promise.all([prPromise, detailsPromise]);
if (cancelled) return;
if (pr !== undefined) useGitStore.getState().setPrData(targetPrKey, pr);
if (detailsCacheKey && details) {
useGitStore.getState().setPrDetails(detailsCacheKey, details.details);
if (detailsCacheKey && refreshedDetails) {
useGitStore.getState().setPrDetails(detailsCacheKey, refreshedDetails.details);
}
} finally {
inFlight = false;
Expand All @@ -64,5 +68,5 @@ export function usePendingPrRefresh(params: {
cancelled = true;
window.clearInterval(intervalId);
};
}, [branch, cacheKey, checksStatus, number, prKey, projectLocation, state]);
}, [branch, cacheKey, effectiveChecksStatus, prKey, prNumber, projectLocation, state]);
}
10 changes: 10 additions & 0 deletions src/renderer/hooks/usePrCombinedChecksStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useGitStore } from "@/renderer/state/gitStore";
import { usePrChecksStatus } from "@/renderer/state/gitSelectors";
import { aggregatePrChecksStatus, combineChecksStatus } from "@/renderer/utils/prStatus";

export function usePrCombinedChecksStatus(prKey: string | undefined, cacheKey: string | undefined) {
const prChecksStatus = usePrChecksStatus(prKey);
const detailsChecks = useGitStore((s) => (cacheKey ? s.prDetails[cacheKey]?.checks : undefined));
const detailsChecksStatus = aggregatePrChecksStatus(detailsChecks);
return combineChecksStatus(detailsChecksStatus, prChecksStatus);
}
58 changes: 55 additions & 3 deletions src/renderer/utils/prStatus.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,67 @@
import type { PrState } from "@/shared/contracts";
import { PR_CHECK_FAILURE_CONCLUSIONS, type PrCheck, type PrState } from "@/shared/contracts";

export type PrStatusTone = "merged" | "draft" | "danger" | "warning" | "success";
export type PrChecksStatus = "FAILURE" | "PENDING" | "SUCCESS";
export type PrChecksTone = "danger" | "warning" | "success";

export function aggregatePrChecksStatus(
checks: readonly PrCheck[] | undefined,
): PrChecksStatus | undefined {
if (!checks || checks.length === 0) return undefined;
let hasPending = false;
for (const check of checks) {
const conclusion = check.conclusion.toUpperCase();
const state = check.state.toUpperCase();
if (PR_CHECK_FAILURE_CONCLUSIONS.has(conclusion) || state === "ERROR" || state === "FAILURE") {
return "FAILURE";
}
if (state && state !== "COMPLETED" && state !== "SUCCESS") {
hasPending = true;
}
}
return hasPending ? "PENDING" : "SUCCESS";
}

const CHECKS_STATUS_TONE: Record<PrChecksStatus, PrChecksTone> = {
FAILURE: "danger",
PENDING: "warning",
SUCCESS: "success",
};

function normalizeChecksStatus(checksStatus: string | undefined): PrChecksStatus | undefined {
const status = checksStatus?.toUpperCase();
if (status === "FAILURE" || status === "ERROR") return "FAILURE";
if (status === "PENDING") return "PENDING";
if (status === "SUCCESS") return "SUCCESS";
return undefined;
}

export function getChecksStatusTone(
checksStatus: PrChecksStatus | undefined,
): PrChecksTone | undefined {
return checksStatus ? CHECKS_STATUS_TONE[checksStatus] : undefined;
}

export function combineChecksStatus(
detailsStatus: string | undefined,
prStatus: string | undefined,
): PrChecksStatus | undefined {
const normalizedDetails = normalizeChecksStatus(detailsStatus);
const normalizedPr = normalizeChecksStatus(prStatus);
if (normalizedDetails === "FAILURE" || normalizedPr === "FAILURE") return "FAILURE";
if (normalizedDetails === "PENDING" || normalizedPr === "PENDING") return "PENDING";
if (normalizedDetails === "SUCCESS" || normalizedPr === "SUCCESS") return "SUCCESS";
return undefined;
}

export function getPrStatusTone(
state: PrState | undefined,
checksStatus: string | undefined,
): PrStatusTone {
if (state === "merged") return "merged";
if (state === "draft") return "draft";
if (checksStatus === "FAILURE" || checksStatus === "ERROR") return "danger";
if (checksStatus === "PENDING") return "warning";
const checksTone = getChecksStatusTone(normalizeChecksStatus(checksStatus));
if (checksTone) return checksTone;
return "success";
}

Expand Down
11 changes: 11 additions & 0 deletions src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { Project, ProjectLocation } from "@/shared/contracts";
import { friendlyError } from "@/shared/messages";
import { readBridge } from "@/renderer/bridge";
import { useGitStore } from "@/renderer/state/gitStore";
import { usePendingPrRefresh } from "@/renderer/hooks/usePendingPrRefresh";
import { usePrCombinedChecksStatus } from "@/renderer/hooks/usePrCombinedChecksStatus";
import { PageLayout } from "@/renderer/components/layout/PageLayout";
import { usePrTitle, usePrUrl, usePrViewerDidAuthor } from "@/renderer/state/gitSelectors";
import { PrReviewSidebar } from "./parts/PrReviewSidebar";
Expand Down Expand Up @@ -36,6 +38,7 @@ export function PrReviewOverlay(props: {
const files = useGitStore((s) => s.prFiles[cacheKey]);
const rawDiff = useGitStore((s) => s.prDiffs[cacheKey]);
const details = useGitStore((s) => s.prDetails[cacheKey]);
const combinedChecksStatus = usePrCombinedChecksStatus(prKey, cacheKey);
const prTitleFromData = usePrTitle(prKey);
const prTitle = prTitleFromData || details?.title || "";
const prUrl = usePrUrl(prKey);
Expand All @@ -46,6 +49,13 @@ export function PrReviewOverlay(props: {
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<PrTabKey>("conversation");

usePendingPrRefresh({
prKey,
projectLocation: effectiveLocation,
branch: details?.headBranch,
...(details ? { cacheKey } : {}),
});

// Track content width to decide if the meta row fits inline in the content
// header or should overflow into a row above the tabs. Above ~880px the
// chips, branches, and stats all fit comfortably; below, they're pushed down.
Expand Down Expand Up @@ -230,6 +240,7 @@ export function PrReviewOverlay(props: {
active={activeTab}
onChange={setActiveTab}
counts={counts}
checksStatus={combinedChecksStatus}
pillInHeaderBreakpoint="never"
conversationPanel={
<PrConversationTab
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/views/PrReviewOverlay/parts/PrTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { PrChecksStatus } from "@/renderer/utils/prStatus";
import { PrTabsPill, type PrTabCounts, type PrTabKey } from "./PrTabsPill";

export type { PrTabKey } from "./PrTabsPill";
Expand All @@ -7,6 +8,7 @@ export function PrTabs(props: {
active: PrTabKey;
onChange: (key: PrTabKey) => void;
counts: PrTabCounts;
checksStatus?: PrChecksStatus | undefined;
conversationPanel: ReactNode;
commitsPanel: ReactNode;
checksPanel: ReactNode;
Expand All @@ -18,6 +20,7 @@ export function PrTabs(props: {
active,
onChange,
counts,
checksStatus,
conversationPanel,
commitsPanel,
checksPanel,
Expand Down Expand Up @@ -50,7 +53,12 @@ export function PrTabs(props: {
return (
<div className="@container flex h-full min-h-0 w-full flex-col">
<div className={`shrink-0 justify-center px-5 py-1 ${standaloneVisibility}`}>
<PrTabsPill active={active} onChange={onChange} counts={counts} />
<PrTabsPill
active={active}
onChange={onChange}
counts={counts}
checksStatus={checksStatus}
/>
</div>
<div className="min-h-0 flex-1 overflow-hidden" role="tabpanel">
{panel}
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/views/PrReviewOverlay/parts/PrTabsPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { PrChecksStatus } from "@/renderer/utils/prStatus";
import { PrTabsPill, type PrTabCounts } from "./PrTabsPill";

const counts: PrTabCounts = {
conversation: 0,
commits: 0,
checks: 1,
changes: 0,
};

function renderChecksTab(checksStatus: PrChecksStatus) {
render(
<PrTabsPill
active="conversation"
onChange={() => undefined}
counts={counts}
checksStatus={checksStatus}
/>,
);
return screen.getByRole("tab", { name: /Checks/ }).querySelector("svg");
}

describe("PrTabsPill", () => {
it("colors the Checks tab icon green when checks pass", () => {
expect(renderChecksTab("SUCCESS")).toHaveClass("text-success");
});

it("colors the Checks tab icon yellow when checks are pending", () => {
expect(renderChecksTab("PENDING")).toHaveClass("text-warning");
});

it("colors the Checks tab icon red when checks fail", () => {
expect(renderChecksTab("FAILURE")).toHaveClass("text-danger");
});
});
20 changes: 18 additions & 2 deletions src/renderer/views/PrReviewOverlay/parts/PrTabsPill.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { FileDiff, GitCommit, MessageSquare, ShieldCheck } from "lucide-react";
import { LightballTabs, type LightballTab } from "@/renderer/components/common";
import {
getChecksStatusTone,
type PrChecksStatus,
type PrChecksTone,
} from "@/renderer/utils/prStatus";

export type PrTabKey = "conversation" | "commits" | "checks" | "changes";

Expand All @@ -21,6 +26,12 @@ const TAB_DEFS: ReadonlyArray<{
{ id: "changes", label: "Changes", icon: FileDiff },
];

const CHECKS_ICON_TONE_CLASS: Record<PrChecksTone, string> = {
success: "text-success",
warning: "text-warning",
danger: "text-danger",
};

function CountChip(props: { value: number; active: boolean }) {
return (
<span
Expand All @@ -37,17 +48,22 @@ export function PrTabsPill(props: {
active: PrTabKey;
onChange: (key: PrTabKey) => void;
counts: PrTabCounts;
checksStatus?: PrChecksStatus | undefined;
className?: string;
}) {
const { active, onChange, counts, className } = props;
const { active, onChange, counts, checksStatus, className } = props;
const checksTone = getChecksStatusTone(checksStatus);

const tabs: ReadonlyArray<LightballTab<PrTabKey>> = TAB_DEFS.map((def) => {
const count = counts[def.id];
const Icon = def.icon;
const iconToneClass =
def.id === "checks" && checksTone ? CHECKS_ICON_TONE_CLASS[checksTone] : "";
const iconClassName = `size-3${iconToneClass ? ` ${iconToneClass}` : ""}`;
return {
id: def.id,
label: def.label,
icon: <Icon className="size-3" />,
icon: <Icon className={iconClassName} />,
...(count > 0
? { trailing: (isActive: boolean) => <CountChip value={count} active={isActive} /> }
: {}),
Expand Down