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
145 changes: 145 additions & 0 deletions src/renderer/hooks/usePendingPrRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PrData, PrDetails, ProjectLocation } from "@/shared/contracts";
import { useGitStore } from "@/renderer/state/gitStore";
import { PR_PENDING_REFRESH_INTERVAL_MS, usePendingPrRefresh } from "./usePendingPrRefresh";

const ghGetPrForBranchMock =
vi.fn<
(payload: { projectLocation: ProjectLocation; branch: string }) => Promise<PrData | null>
>();
const ghGetPrDetailsMock =
vi.fn<
(payload: {
projectLocation: ProjectLocation;
prNumber: number;
}) => Promise<{ details: PrDetails }>
>();

const location: ProjectLocation = { kind: "posix", path: "/repo" };

const basePr: PrData = {
number: 42,
state: "open",
title: "Improve PR checks",
url: "https://github.com/owner/repo/pull/42",
baseBranch: "main",
isDraft: false,
checksStatus: "PENDING",
updatedAt: "2026-04-04T00:00:00.000Z",
};

const baseDetails: PrDetails = {
number: 42,
title: "Improve PR checks",
body: "",
baseBranch: "main",
headBranch: "feature/pr-checks",
additions: 1,
deletions: 0,
changedFiles: 1,
mergedAt: null,
mergedBy: null,
closedAt: null,
commits: [],
comments: [],
reviews: [],
checks: [{ name: "CI", state: "PENDING", conclusion: "" }],
};

describe("usePendingPrRefresh", () => {
beforeEach(() => {
vi.useFakeTimers();
ghGetPrForBranchMock.mockReset();
ghGetPrDetailsMock.mockReset();
Object.defineProperty(window, "lightcode", {
configurable: true,
value: {
platform: "darwin",
ghGetPrForBranch: ghGetPrForBranchMock,
ghGetPrDetails: ghGetPrDetailsMock,
},
});
useGitStore.setState({
statuses: {},
worktreeStatuses: {},
worktrees: {},
branches: {},
ghAvailable: {},
prData: {},
worktreeSourceInfo: {},
prDetails: {},
prFiles: {},
prDiffs: {},
});
});

afterEach(() => {
vi.useRealTimers();
});

it("refetches pending PR status immediately, then every 30 seconds until it leaves pending", async () => {
useGitStore.getState().setPrData("pr-key", basePr);
useGitStore.getState().setPrDetails("p1#42", baseDetails);
ghGetPrForBranchMock
.mockResolvedValueOnce({
...basePr,
checksStatus: "PENDING",
updatedAt: "2026-04-04T00:00:30.000Z",
})
.mockResolvedValueOnce({
...basePr,
checksStatus: "SUCCESS",
updatedAt: "2026-04-04T00:01:00.000Z",
});
ghGetPrDetailsMock.mockResolvedValueOnce({ details: baseDetails }).mockResolvedValueOnce({
details: {
...baseDetails,
checks: [{ name: "CI", state: "COMPLETED", conclusion: "SUCCESS" }],
},
});

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

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

expect(ghGetPrForBranchMock).toHaveBeenCalledTimes(1);
expect(ghGetPrForBranchMock).toHaveBeenLastCalledWith({
projectLocation: location,
branch: "feature/pr-checks",
});
expect(ghGetPrDetailsMock).toHaveBeenCalledTimes(1);
expect(useGitStore.getState().prData["pr-key"]?.checksStatus).toBe("PENDING");

ghGetPrForBranchMock.mockClear();
ghGetPrDetailsMock.mockClear();

await act(async () => {
await vi.advanceTimersByTimeAsync(PR_PENDING_REFRESH_INTERVAL_MS);
});

expect(ghGetPrForBranchMock).toHaveBeenCalledWith({
projectLocation: location,
branch: "feature/pr-checks",
});
expect(ghGetPrDetailsMock).toHaveBeenCalledWith({ projectLocation: location, prNumber: 42 });
expect(useGitStore.getState().prData["pr-key"]?.checksStatus).toBe("SUCCESS");
expect(useGitStore.getState().prDetails["p1#42"]?.checks[0]?.conclusion).toBe("SUCCESS");

ghGetPrForBranchMock.mockClear();
ghGetPrDetailsMock.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(PR_PENDING_REFRESH_INTERVAL_MS);
});

expect(ghGetPrForBranchMock).not.toHaveBeenCalled();
expect(ghGetPrDetailsMock).not.toHaveBeenCalled();
});
});
68 changes: 68 additions & 0 deletions src/renderer/hooks/usePendingPrRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 { getPrStatusTone } from "@/renderer/utils/prStatus";

export const PR_PENDING_REFRESH_INTERVAL_MS = 30_000;

export function usePendingPrRefresh(params: {
prKey: string | undefined;
projectLocation: ProjectLocation;
branch: string | undefined;
cacheKey?: string | undefined;
}) {
const { prKey, projectLocation, branch, cacheKey } = params;
const state = usePrState(prKey);
const number = usePrNumber(prKey);
const checksStatus = usePrChecksStatus(prKey);

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

const targetPrKey = prKey;
const targetBranch = branch;
const detailsCacheKey = cacheKey;
const detailsPrNumber = number;
let cancelled = false;
let inFlight = false;

async function refreshPendingPr() {
if (inFlight) return;
inFlight = true;
try {
const bridge = readBridge();
const prPromise = targetBranch
? bridge
.ghGetPrForBranch({ projectLocation, branch: targetBranch })
.catch(() => undefined)
: Promise.resolve(undefined);
const detailsPromise =
detailsCacheKey && detailsPrNumber
? bridge
.ghGetPrDetails({ projectLocation, prNumber: detailsPrNumber })
.catch(() => undefined)
: Promise.resolve(undefined);
const [pr, details] = 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);
}
} finally {
inFlight = false;
}
}

void refreshPendingPr();
const intervalId = window.setInterval(
() => void refreshPendingPr(),
PR_PENDING_REFRESH_INTERVAL_MS,
);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [branch, cacheKey, checksStatus, number, prKey, projectLocation, state]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ export function GitReviewSidebar(props: {
<PrSection
prKey={effectivePrKey}
projectId={project.id}
projectLocation={project.location}
branch={effectiveBranch}
worktreePath={worktreePath}
prLoading={prLoading}
handleMergePr={handleMergePr}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import {
usePrTitle,
usePrUrl,
} from "@/renderer/state/gitSelectors";
import { usePendingPrRefresh } from "@/renderer/hooks/usePendingPrRefresh";
import { usePanelStore } from "@/renderer/state/panelStore";
import { getPrStatusTone, PR_TONE_BG_CLASS } from "@/renderer/utils/prStatus";
import { GitReviewSection } from "./GitReviewSection";
import type { ProjectLocation } from "@/shared/contracts";

const BLOCK_REASON: Record<string, string> = {
BLOCKED: "Required reviews, conversations, or status checks not met.",
Expand All @@ -45,7 +47,10 @@ const BLOCK_REASON: Record<string, string> = {
export function PrSection(props: {
prKey: string;
projectId: string;
projectLocation: ProjectLocation;
branch?: string | undefined;
worktreePath?: string | undefined;
cacheKey?: string | undefined;
prLoading: boolean;
handleMergePr: (method: "merge" | "squash" | "rebase", admin?: boolean) => Promise<void>;
handleClosePr: () => Promise<void>;
Expand All @@ -55,7 +60,10 @@ export function PrSection(props: {
const {
prKey,
projectId,
projectLocation,
branch,
worktreePath,
cacheKey,
prLoading,
handleMergePr,
handleClosePr,
Expand All @@ -71,6 +79,8 @@ export function PrSection(props: {
const mergeable = usePrMergeable(prKey);
const [bypass, setBypass] = useState(false);

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

const indicatorColor = PR_TONE_BG_CLASS[getPrStatusTone(state, checksStatus)];

const reasonKey = mergeable === "CONFLICTING" ? "DIRTY" : mergeStateStatus;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export function PrReviewOverlay(props: {
projectId={project.id}
projectLocation={effectiveLocation}
prKey={prKey}
cacheKey={cacheKey}
branch={details?.headBranch}
worktreePath={worktreePath}
onSelectFile={(path) => {
setActiveTab("changes");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function PrReviewSidebar(props: {
projectId: string;
projectLocation: ProjectLocation;
prKey: string;
cacheKey: string;
branch?: string | undefined;
worktreePath?: string | undefined;
onSelectFile: (path: string) => void;
onClose: () => void;
Expand All @@ -41,6 +43,8 @@ export function PrReviewSidebar(props: {
projectId,
projectLocation,
prKey,
cacheKey,
branch,
worktreePath,
onSelectFile,
onClose,
Expand Down Expand Up @@ -146,7 +150,10 @@ export function PrReviewSidebar(props: {
<PrSection
prKey={prKey}
projectId={projectId}
projectLocation={projectLocation}
branch={branch}
worktreePath={worktreePath}
cacheKey={cacheKey}
prLoading={prLoading}
handleMergePr={handleMergePr}
handleClosePr={handleClosePr}
Expand Down
Loading