Skip to content

Commit 1a850f8

Browse files
authored
fix(api): preserves pending check status when UNSTABLE (#56)
* fix(api): preserves pending status when UNSTABLE GitHub's mergeStateStatus UNSTABLE means "mergeable with non-passing commit status", which includes both failing AND pending checks. The UNSTABLE override was unconditionally setting checkStatus to "failure", clobbering the correct "pending" state from statusCheckRollup. Guards the override so it only applies when rollup is not pending. * fix(test): increases hookTimeout for browser test project * refactor(api): extracts applyMergeStateOverride helper
1 parent 9c84fa1 commit 1a850f8

File tree

4 files changed

+149
-25
lines changed

4 files changed

+149
-25
lines changed

src/app/services/api.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,13 +1280,10 @@ export async function fetchPREnrichment(
12801280
.filter((l): l is string => l != null);
12811281
const reviewerLogins = [...new Set([...pendingLogins, ...actualLogins].map(l => l.toLowerCase()))];
12821282

1283-
let checkStatus = mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null);
1284-
const mss = node.mergeStateStatus;
1285-
if (mss === "DIRTY" || mss === "BEHIND") {
1286-
checkStatus = "conflict";
1287-
} else if (mss === "UNSTABLE") {
1288-
checkStatus = "failure";
1289-
}
1283+
const checkStatus = applyMergeStateOverride(
1284+
node.mergeStateStatus,
1285+
mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null),
1286+
);
12901287

12911288
let headRepository: PREnrichmentData["headRepository"] = null;
12921289
if (node.headRepository) {
@@ -1619,6 +1616,21 @@ function mapCheckStatus(state: string | null | undefined): CheckStatus["status"]
16191616
return null;
16201617
}
16211618

1619+
/**
1620+
* Applies mergeStateStatus overrides to a PR's checkStatus.
1621+
* DIRTY/BEHIND → conflict. UNSTABLE → failure unless checkStatus is already pending
1622+
* (a pending rollup means checks are still running, not conclusively failed).
1623+
* All other values (CLEAN, BLOCKED, HAS_HOOKS, UNKNOWN) pass through unchanged.
1624+
*/
1625+
function applyMergeStateOverride(
1626+
mergeStateStatus: string | null | undefined,
1627+
checkStatus: CheckStatus["status"],
1628+
): CheckStatus["status"] {
1629+
if (mergeStateStatus === "DIRTY" || mergeStateStatus === "BEHIND") return "conflict";
1630+
if (mergeStateStatus === "UNSTABLE" && checkStatus !== "pending") return "failure";
1631+
return checkStatus;
1632+
}
1633+
16221634
/**
16231635
* Maps a GraphQL reviewDecision string to the typed union or null.
16241636
*/
@@ -1664,17 +1676,12 @@ async function graphqlSearchPRs(
16641676
.filter((l): l is string => l != null);
16651677
const reviewerLogins = [...new Set([...pendingLogins, ...actualLogins].map(l => l.toLowerCase()))];
16661678

1667-
let checkStatus = mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null);
16681679
// mergeStateStatus overrides checkStatus when it indicates action is needed.
16691680
// BLOCKED means required checks/reviews haven't passed — leave checkStatus from rollup.
1670-
const mss = node.mergeStateStatus;
1671-
if (mss === "DIRTY" || mss === "BEHIND") {
1672-
checkStatus = "conflict";
1673-
} else if (mss === "UNSTABLE") {
1674-
checkStatus = "failure";
1675-
} else if (mss === "UNKNOWN" && checkStatus === null) {
1676-
checkStatus = null; // no-op, kept explicit for clarity
1677-
}
1681+
const checkStatus = applyMergeStateOverride(
1682+
node.mergeStateStatus,
1683+
mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null),
1684+
);
16781685

16791686
// Store fork info for fallback detection
16801687
if (node.headRepository) {
@@ -2101,13 +2108,10 @@ export async function fetchHotPRStatus(
21012108
for (const node of response.nodes) {
21022109
if (!node || node.databaseId == null) continue;
21032110

2104-
let checkStatus = mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null);
2105-
const mss = node.mergeStateStatus;
2106-
if (mss === "DIRTY" || mss === "BEHIND") {
2107-
checkStatus = "conflict";
2108-
} else if (mss === "UNSTABLE") {
2109-
checkStatus = "failure";
2110-
}
2111+
const checkStatus = applyMergeStateOverride(
2112+
node.mergeStateStatus,
2113+
mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null),
2114+
);
21112115

21122116
results.set(node.databaseId, {
21132117
state: node.state,

tests/services/api-optimization.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
fetchPullRequests,
66
fetchIssuesAndPullRequests,
77
fetchWorkflowRuns,
8+
fetchPREnrichment,
89
type RepoRef,
910
} from "../../src/app/services/api";
1011
import { clearCache } from "../../src/app/stores/cache";
@@ -676,3 +677,105 @@ describe("scaling behavior", () => {
676677
});
677678
}
678679
});
680+
681+
// ── fetchPREnrichment: UNSTABLE+pending override ───────────────────────────────
682+
683+
describe("fetchPREnrichment mergeStateStatus UNSTABLE override", () => {
684+
function makeEnrichmentOctokit(mergeStateStatus: string, rollupState: string | null) {
685+
return {
686+
request: vi.fn(async () => ({ data: [], headers: {} })),
687+
graphql: vi.fn(async () => ({
688+
nodes: [{
689+
databaseId: 100,
690+
headRefOid: "abc123",
691+
headRepository: { owner: { login: "octocat" }, nameWithOwner: "octocat/Hello-World" },
692+
mergeStateStatus,
693+
assignees: { nodes: [] },
694+
reviewRequests: { nodes: [] },
695+
latestReviews: { totalCount: 0, nodes: [] },
696+
additions: 5,
697+
deletions: 2,
698+
changedFiles: 1,
699+
comments: { totalCount: 0 },
700+
reviewThreads: { totalCount: 0 },
701+
commits: {
702+
nodes: rollupState
703+
? [{ commit: { statusCheckRollup: { state: rollupState } } }]
704+
: [],
705+
},
706+
}],
707+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
708+
})),
709+
paginate: { iterator: vi.fn() },
710+
};
711+
}
712+
713+
it("preserves pending checkStatus when UNSTABLE and rollup is PENDING", async () => {
714+
const octokit = makeEnrichmentOctokit("UNSTABLE", "PENDING");
715+
const { enrichments } = await fetchPREnrichment(octokit as never, ["PR_node1"]);
716+
expect(enrichments.get(100)!.checkStatus).toBe("pending");
717+
});
718+
719+
it("forces failure checkStatus when UNSTABLE and rollup is SUCCESS", async () => {
720+
const octokit = makeEnrichmentOctokit("UNSTABLE", "SUCCESS");
721+
const { enrichments } = await fetchPREnrichment(octokit as never, ["PR_node1"]);
722+
expect(enrichments.get(100)!.checkStatus).toBe("failure");
723+
});
724+
725+
it("forces failure checkStatus when UNSTABLE and rollup is absent", async () => {
726+
const octokit = makeEnrichmentOctokit("UNSTABLE", null);
727+
const { enrichments } = await fetchPREnrichment(octokit as never, ["PR_node1"]);
728+
expect(enrichments.get(100)!.checkStatus).toBe("failure");
729+
});
730+
});
731+
732+
// ── fetchPullRequests (graphqlSearchPRs): UNSTABLE+pending override ───────────
733+
734+
describe("fetchPullRequests processPRNode UNSTABLE override", () => {
735+
const repos: RepoRef[] = [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }];
736+
737+
function makePRNodeWithMSS(mergeStateStatus: string, rollupState: string | null) {
738+
return {
739+
...graphqlPRNode,
740+
mergeStateStatus,
741+
commits: {
742+
nodes: rollupState
743+
? [{ commit: { statusCheckRollup: { state: rollupState } } }]
744+
: [],
745+
},
746+
};
747+
}
748+
749+
it("preserves pending checkStatus when UNSTABLE and rollup is PENDING", async () => {
750+
const octokit = makeOctokit(async () => ({
751+
search: makeSearchResponse([makePRNodeWithMSS("UNSTABLE", "PENDING")]),
752+
rateLimit,
753+
}));
754+
755+
const result = await fetchPullRequests(octokit as unknown as OctokitLike, repos, "testuser");
756+
expect(result.pullRequests).toHaveLength(1);
757+
expect(result.pullRequests[0].checkStatus).toBe("pending");
758+
});
759+
760+
it("forces failure checkStatus when UNSTABLE and rollup is SUCCESS", async () => {
761+
const octokit = makeOctokit(async () => ({
762+
search: makeSearchResponse([makePRNodeWithMSS("UNSTABLE", "SUCCESS")]),
763+
rateLimit,
764+
}));
765+
766+
const result = await fetchPullRequests(octokit as unknown as OctokitLike, repos, "testuser");
767+
expect(result.pullRequests).toHaveLength(1);
768+
expect(result.pullRequests[0].checkStatus).toBe("failure");
769+
});
770+
771+
it("forces failure checkStatus when UNSTABLE and rollup is absent", async () => {
772+
const octokit = makeOctokit(async () => ({
773+
search: makeSearchResponse([makePRNodeWithMSS("UNSTABLE", null)]),
774+
rateLimit,
775+
}));
776+
777+
const result = await fetchPullRequests(octokit as unknown as OctokitLike, repos, "testuser");
778+
expect(result.pullRequests).toHaveLength(1);
779+
expect(result.pullRequests[0].checkStatus).toBe("failure");
780+
});
781+
});

tests/services/hot-poll.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,21 +134,37 @@ describe("fetchHotPRStatus", () => {
134134
expect(results.get(43)!.checkStatus).toBe("conflict");
135135
});
136136

137-
it("applies mergeStateStatus overrides: UNSTABLE -> failure", async () => {
137+
it("applies mergeStateStatus overrides: UNSTABLE overrides SUCCESS rollup to failure", async () => {
138138
const octokit = makeOctokit(undefined, () => Promise.resolve({
139139
nodes: [{
140140
databaseId: 44,
141141
state: "OPEN",
142142
mergeStateStatus: "UNSTABLE",
143143
reviewDecision: null,
144-
commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] },
144+
commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
145145
}],
146146
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" },
147147
}));
148148

149149
const { results } = await fetchHotPRStatus(octokit as never, ["PR_node3"]);
150150
expect(results.get(44)!.checkStatus).toBe("failure");
151151
});
152+
153+
it("preserves pending when mergeStateStatus is UNSTABLE but rollup is PENDING", async () => {
154+
const octokit = makeOctokit(undefined, () => Promise.resolve({
155+
nodes: [{
156+
databaseId: 45,
157+
state: "OPEN",
158+
mergeStateStatus: "UNSTABLE",
159+
reviewDecision: null,
160+
commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] },
161+
}],
162+
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" },
163+
}));
164+
165+
const { results } = await fetchHotPRStatus(octokit as never, ["PR_node4"]);
166+
expect(results.get(45)!.checkStatus).toBe("pending");
167+
});
152168
});
153169

154170
describe("fetchWorkflowRunById", () => {

vitest.workspace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default defineConfig({
1313
name: "browser",
1414
environment: "happy-dom",
1515
globals: true,
16+
hookTimeout: 30_000,
1617
setupFiles: ["tests/setup.ts"],
1718
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
1819
exclude: ["tests/worker/**"],

0 commit comments

Comments
 (0)