Skip to content

Commit 4f8e0a0

Browse files
authored
feat(pr): refresh pending check status and poll on creation (#11)
- Implement `usePendingPrRefresh` hook to periodically poll PR and details when status is pending - Integrate the refresh hook into `PrSection` inside Git and PR review sidebars - Poll for status check rollup in `GitHubService.createPr` before returning - Add unit tests for the hook and update GitHub service tests
1 parent 3315ef3 commit 4f8e0a0

8 files changed

Lines changed: 341 additions & 17 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { PrData, PrDetails, ProjectLocation } from "@/shared/contracts";
4+
import { useGitStore } from "@/renderer/state/gitStore";
5+
import { PR_PENDING_REFRESH_INTERVAL_MS, usePendingPrRefresh } from "./usePendingPrRefresh";
6+
7+
const ghGetPrForBranchMock =
8+
vi.fn<
9+
(payload: { projectLocation: ProjectLocation; branch: string }) => Promise<PrData | null>
10+
>();
11+
const ghGetPrDetailsMock =
12+
vi.fn<
13+
(payload: {
14+
projectLocation: ProjectLocation;
15+
prNumber: number;
16+
}) => Promise<{ details: PrDetails }>
17+
>();
18+
19+
const location: ProjectLocation = { kind: "posix", path: "/repo" };
20+
21+
const basePr: PrData = {
22+
number: 42,
23+
state: "open",
24+
title: "Improve PR checks",
25+
url: "https://github.com/owner/repo/pull/42",
26+
baseBranch: "main",
27+
isDraft: false,
28+
checksStatus: "PENDING",
29+
updatedAt: "2026-04-04T00:00:00.000Z",
30+
};
31+
32+
const baseDetails: PrDetails = {
33+
number: 42,
34+
title: "Improve PR checks",
35+
body: "",
36+
baseBranch: "main",
37+
headBranch: "feature/pr-checks",
38+
additions: 1,
39+
deletions: 0,
40+
changedFiles: 1,
41+
mergedAt: null,
42+
mergedBy: null,
43+
closedAt: null,
44+
commits: [],
45+
comments: [],
46+
reviews: [],
47+
checks: [{ name: "CI", state: "PENDING", conclusion: "" }],
48+
};
49+
50+
describe("usePendingPrRefresh", () => {
51+
beforeEach(() => {
52+
vi.useFakeTimers();
53+
ghGetPrForBranchMock.mockReset();
54+
ghGetPrDetailsMock.mockReset();
55+
Object.defineProperty(window, "lightcode", {
56+
configurable: true,
57+
value: {
58+
platform: "darwin",
59+
ghGetPrForBranch: ghGetPrForBranchMock,
60+
ghGetPrDetails: ghGetPrDetailsMock,
61+
},
62+
});
63+
useGitStore.setState({
64+
statuses: {},
65+
worktreeStatuses: {},
66+
worktrees: {},
67+
branches: {},
68+
ghAvailable: {},
69+
prData: {},
70+
worktreeSourceInfo: {},
71+
prDetails: {},
72+
prFiles: {},
73+
prDiffs: {},
74+
});
75+
});
76+
77+
afterEach(() => {
78+
vi.useRealTimers();
79+
});
80+
81+
it("refetches pending PR status immediately, then every 30 seconds until it leaves pending", async () => {
82+
useGitStore.getState().setPrData("pr-key", basePr);
83+
useGitStore.getState().setPrDetails("p1#42", baseDetails);
84+
ghGetPrForBranchMock
85+
.mockResolvedValueOnce({
86+
...basePr,
87+
checksStatus: "PENDING",
88+
updatedAt: "2026-04-04T00:00:30.000Z",
89+
})
90+
.mockResolvedValueOnce({
91+
...basePr,
92+
checksStatus: "SUCCESS",
93+
updatedAt: "2026-04-04T00:01:00.000Z",
94+
});
95+
ghGetPrDetailsMock.mockResolvedValueOnce({ details: baseDetails }).mockResolvedValueOnce({
96+
details: {
97+
...baseDetails,
98+
checks: [{ name: "CI", state: "COMPLETED", conclusion: "SUCCESS" }],
99+
},
100+
});
101+
102+
renderHook(() =>
103+
usePendingPrRefresh({
104+
prKey: "pr-key",
105+
projectLocation: location,
106+
branch: "feature/pr-checks",
107+
cacheKey: "p1#42",
108+
}),
109+
);
110+
111+
await act(async () => {});
112+
113+
expect(ghGetPrForBranchMock).toHaveBeenCalledTimes(1);
114+
expect(ghGetPrForBranchMock).toHaveBeenLastCalledWith({
115+
projectLocation: location,
116+
branch: "feature/pr-checks",
117+
});
118+
expect(ghGetPrDetailsMock).toHaveBeenCalledTimes(1);
119+
expect(useGitStore.getState().prData["pr-key"]?.checksStatus).toBe("PENDING");
120+
121+
ghGetPrForBranchMock.mockClear();
122+
ghGetPrDetailsMock.mockClear();
123+
124+
await act(async () => {
125+
await vi.advanceTimersByTimeAsync(PR_PENDING_REFRESH_INTERVAL_MS);
126+
});
127+
128+
expect(ghGetPrForBranchMock).toHaveBeenCalledWith({
129+
projectLocation: location,
130+
branch: "feature/pr-checks",
131+
});
132+
expect(ghGetPrDetailsMock).toHaveBeenCalledWith({ projectLocation: location, prNumber: 42 });
133+
expect(useGitStore.getState().prData["pr-key"]?.checksStatus).toBe("SUCCESS");
134+
expect(useGitStore.getState().prDetails["p1#42"]?.checks[0]?.conclusion).toBe("SUCCESS");
135+
136+
ghGetPrForBranchMock.mockClear();
137+
ghGetPrDetailsMock.mockClear();
138+
await act(async () => {
139+
await vi.advanceTimersByTimeAsync(PR_PENDING_REFRESH_INTERVAL_MS);
140+
});
141+
142+
expect(ghGetPrForBranchMock).not.toHaveBeenCalled();
143+
expect(ghGetPrDetailsMock).not.toHaveBeenCalled();
144+
});
145+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useEffect } from "react";
2+
import type { ProjectLocation } from "@/shared/contracts";
3+
import { readBridge } from "@/renderer/bridge";
4+
import { useGitStore } from "@/renderer/state/gitStore";
5+
import { usePrChecksStatus, usePrNumber, usePrState } from "@/renderer/state/gitSelectors";
6+
import { getPrStatusTone } from "@/renderer/utils/prStatus";
7+
8+
export const PR_PENDING_REFRESH_INTERVAL_MS = 30_000;
9+
10+
export function usePendingPrRefresh(params: {
11+
prKey: string | undefined;
12+
projectLocation: ProjectLocation;
13+
branch: string | undefined;
14+
cacheKey?: string | undefined;
15+
}) {
16+
const { prKey, projectLocation, branch, cacheKey } = params;
17+
const state = usePrState(prKey);
18+
const number = usePrNumber(prKey);
19+
const checksStatus = usePrChecksStatus(prKey);
20+
21+
useEffect(() => {
22+
if (!prKey || getPrStatusTone(state, checksStatus) !== "warning") return;
23+
24+
const targetPrKey = prKey;
25+
const targetBranch = branch;
26+
const detailsCacheKey = cacheKey;
27+
const detailsPrNumber = number;
28+
let cancelled = false;
29+
let inFlight = false;
30+
31+
async function refreshPendingPr() {
32+
if (inFlight) return;
33+
inFlight = true;
34+
try {
35+
const bridge = readBridge();
36+
const prPromise = targetBranch
37+
? bridge
38+
.ghGetPrForBranch({ projectLocation, branch: targetBranch })
39+
.catch(() => undefined)
40+
: Promise.resolve(undefined);
41+
const detailsPromise =
42+
detailsCacheKey && detailsPrNumber
43+
? bridge
44+
.ghGetPrDetails({ projectLocation, prNumber: detailsPrNumber })
45+
.catch(() => undefined)
46+
: Promise.resolve(undefined);
47+
const [pr, details] = await Promise.all([prPromise, detailsPromise]);
48+
if (cancelled) return;
49+
if (pr !== undefined) useGitStore.getState().setPrData(targetPrKey, pr);
50+
if (detailsCacheKey && details) {
51+
useGitStore.getState().setPrDetails(detailsCacheKey, details.details);
52+
}
53+
} finally {
54+
inFlight = false;
55+
}
56+
}
57+
58+
void refreshPendingPr();
59+
const intervalId = window.setInterval(
60+
() => void refreshPendingPr(),
61+
PR_PENDING_REFRESH_INTERVAL_MS,
62+
);
63+
return () => {
64+
cancelled = true;
65+
window.clearInterval(intervalId);
66+
};
67+
}, [branch, cacheKey, checksStatus, number, prKey, projectLocation, state]);
68+
}

src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ export function GitReviewSidebar(props: {
350350
<PrSection
351351
prKey={effectivePrKey}
352352
projectId={project.id}
353+
projectLocation={project.location}
354+
branch={effectiveBranch}
353355
worktreePath={worktreePath}
354356
prLoading={prLoading}
355357
handleMergePr={handleMergePr}

src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/PrSection.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import {
3030
usePrTitle,
3131
usePrUrl,
3232
} from "@/renderer/state/gitSelectors";
33+
import { usePendingPrRefresh } from "@/renderer/hooks/usePendingPrRefresh";
3334
import { usePanelStore } from "@/renderer/state/panelStore";
3435
import { getPrStatusTone, PR_TONE_BG_CLASS } from "@/renderer/utils/prStatus";
3536
import { GitReviewSection } from "./GitReviewSection";
37+
import type { ProjectLocation } from "@/shared/contracts";
3638

3739
const BLOCK_REASON: Record<string, string> = {
3840
BLOCKED: "Required reviews, conversations, or status checks not met.",
@@ -45,7 +47,10 @@ const BLOCK_REASON: Record<string, string> = {
4547
export function PrSection(props: {
4648
prKey: string;
4749
projectId: string;
50+
projectLocation: ProjectLocation;
51+
branch?: string | undefined;
4852
worktreePath?: string | undefined;
53+
cacheKey?: string | undefined;
4954
prLoading: boolean;
5055
handleMergePr: (method: "merge" | "squash" | "rebase", admin?: boolean) => Promise<void>;
5156
handleClosePr: () => Promise<void>;
@@ -55,7 +60,10 @@ export function PrSection(props: {
5560
const {
5661
prKey,
5762
projectId,
63+
projectLocation,
64+
branch,
5865
worktreePath,
66+
cacheKey,
5967
prLoading,
6068
handleMergePr,
6169
handleClosePr,
@@ -71,6 +79,8 @@ export function PrSection(props: {
7179
const mergeable = usePrMergeable(prKey);
7280
const [bypass, setBypass] = useState(false);
7381

82+
usePendingPrRefresh({ prKey, projectLocation, branch, ...(cacheKey ? { cacheKey } : {}) });
83+
7484
const indicatorColor = PR_TONE_BG_CLASS[getPrStatusTone(state, checksStatus)];
7585

7686
const reasonKey = mergeable === "CONFLICTING" ? "DIRTY" : mergeStateStatus;

src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export function PrReviewOverlay(props: {
195195
projectId={project.id}
196196
projectLocation={effectiveLocation}
197197
prKey={prKey}
198+
cacheKey={cacheKey}
199+
branch={details?.headBranch}
198200
worktreePath={worktreePath}
199201
onSelectFile={(path) => {
200202
setActiveTab("changes");

src/renderer/views/PrReviewOverlay/parts/PrReviewSidebar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export function PrReviewSidebar(props: {
2929
projectId: string;
3030
projectLocation: ProjectLocation;
3131
prKey: string;
32+
cacheKey: string;
33+
branch?: string | undefined;
3234
worktreePath?: string | undefined;
3335
onSelectFile: (path: string) => void;
3436
onClose: () => void;
@@ -41,6 +43,8 @@ export function PrReviewSidebar(props: {
4143
projectId,
4244
projectLocation,
4345
prKey,
46+
cacheKey,
47+
branch,
4448
worktreePath,
4549
onSelectFile,
4650
onClose,
@@ -146,7 +150,10 @@ export function PrReviewSidebar(props: {
146150
<PrSection
147151
prKey={prKey}
148152
projectId={projectId}
153+
projectLocation={projectLocation}
154+
branch={branch}
149155
worktreePath={worktreePath}
156+
cacheKey={cacheKey}
150157
prLoading={prLoading}
151158
handleMergePr={handleMergePr}
152159
handleClosePr={handleClosePr}

0 commit comments

Comments
 (0)