Skip to content

Commit 1d8909e

Browse files
committed
refactor(api): extracts applyMergeStateOverride helper
1 parent c070d46 commit 1d8909e

File tree

3 files changed

+132
-25
lines changed

3 files changed

+132
-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" && checkStatus !== "pending") {
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" && checkStatus !== "pending") {
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" && checkStatus !== "pending") {
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,14 @@ describe("fetchHotPRStatus", () => {
134134
expect(results.get(43)!.checkStatus).toBe("conflict");
135135
});
136136

137-
it("applies mergeStateStatus overrides: UNSTABLE with failed rollup -> 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: "FAILURE" } } }] },
144+
commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
145145
}],
146146
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" },
147147
}));

0 commit comments

Comments
 (0)