agent-orchestrator/packages/plugins/scm-github/test/graphql-batch.test.ts

1924 lines
56 KiB
TypeScript

/**
* Unit tests for GraphQL batch PR enrichment.
*
* Note: The GraphQL batch query uses the top-level statusCheckRollup.state
* field for overall CI status and fetches a bounded contexts page for
* individual check details when the page is complete.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
// Import from graphql-batch.js
import {
generateBatchQuery,
MAX_BATCH_SIZE,
CI_CONTEXTS_FIRST,
parseCIState,
parseReviewDecision,
parsePRState,
extractPREnrichment,
clearETagCache,
getPRListETag,
getCommitStatusETag,
setPRListETag,
setCommitStatusETag,
setPRMetadata,
getPRMetadataCache,
clearPRMetadataCache,
shouldRefreshPREnrichment,
checkReviewCommentsETag,
setExecFileAsync,
} from "../src/graphql-batch.js";
// Mock execFile using the injection function
// Create a mock function that returns a promise matching the execFile signature
type ExecFileResult = { stdout: string; stderr: string };
const mockExecFileImpl = vi.fn<(
file: string,
args: string[],
options?: Record<string, unknown>,
) => Promise<ExecFileResult>>();
// Setup mock before each test
beforeEach(() => {
setExecFileAsync(mockExecFileImpl);
});
describe("GraphQL Batch Query Generation", () => {
it("should generate a query for a single PR", () => {
const prs = [
{
owner: "octocat",
repo: "hello-world",
number: 42,
url: "https://github.com/octocat/hello-world/pull/42",
title: "Add new feature",
branch: "feature/new",
baseBranch: "main",
isDraft: false,
},
];
const { query, variables } = generateBatchQuery(prs);
expect(query).toContain("query BatchPRs($pr0Owner: String!, $pr0Name: String!, $pr0Number: Int!)");
expect(query).toContain("pr0: repository(owner: $pr0Owner, name: $pr0Name)");
expect(query).toContain("pullRequest(number: $pr0Number)");
expect(variables).toEqual({
pr0Owner: "octocat",
pr0Name: "hello-world",
pr0Number: 42,
});
});
it("should generate a query for multiple PRs with different aliases", () => {
const prs = [
{
owner: "octocat",
repo: "hello-world",
number: 42,
url: "https://github.com/octocat/hello-world/pull/42",
title: "Add new feature",
branch: "feature/new",
baseBranch: "main",
isDraft: false,
},
{
owner: "torvalds",
repo: "linux",
number: 123,
url: "https://github.com/torvalds/linux/pull/123",
title: "Fix kernel bug",
branch: "fix/kernel",
baseBranch: "master",
isDraft: false,
},
{
owner: "facebook",
repo: "react",
number: 456,
url: "https://github.com/facebook/react/pull/456",
title: "Update hooks",
branch: "update/hooks",
baseBranch: "main",
isDraft: false,
},
];
const { query, variables } = generateBatchQuery(prs);
// Check all three aliases are present
expect(query).toContain("pr0: repository(owner: $pr0Owner");
expect(query).toContain("pr1: repository(owner: $pr1Owner");
expect(query).toContain("pr2: repository(owner: $pr2Owner");
// Check variable definitions
expect(query).toContain("$pr0Owner: String!");
expect(query).toContain("$pr1Owner: String!");
expect(query).toContain("$pr2Owner: String!");
// Check variables contain all PR data
expect(variables.pr0Owner).toBe("octocat");
expect(variables.pr0Name).toBe("hello-world");
expect(variables.pr0Number).toBe(42);
expect(variables.pr1Owner).toBe("torvalds");
expect(variables.pr1Name).toBe("linux");
expect(variables.pr1Number).toBe(123);
expect(variables.pr2Owner).toBe("facebook");
expect(variables.pr2Name).toBe("react");
expect(variables.pr2Number).toBe(456);
});
it("should handle empty PR array", () => {
const { query, variables } = generateBatchQuery([]);
expect(query).toBe("");
expect(variables).toEqual({});
});
it("should include all required fields in the query", () => {
const prs = [
{
owner: "test",
repo: "test",
number: 1,
url: "https://github.com/test/test/pull/1",
title: "Test",
branch: "test",
baseBranch: "main",
isDraft: false,
},
];
const { query } = generateBatchQuery(prs);
// Check for all the PR fields we need
expect(query).toContain("title");
expect(query).toContain("state");
expect(query).toContain("additions");
expect(query).toContain("deletions");
expect(query).toContain("isDraft");
expect(query).toContain("mergeable");
expect(query).toContain("mergeStateStatus");
expect(query).toContain("reviewDecision");
expect(query).toContain("commits");
expect(query).toContain("statusCheckRollup");
expect(query).toContain(`contexts(first: ${CI_CONTEXTS_FIRST})`);
});
it("should use sequential numeric aliases", () => {
const prs = Array.from({ length: 5 }, (_, i) => ({
owner: "owner",
repo: "repo",
number: i + 1,
url: `https://github.com/owner/repo/pull/${i + 1}`,
title: `PR ${i + 1}`,
branch: `branch${i}`,
baseBranch: "main",
isDraft: false,
}));
const { query } = generateBatchQuery(prs);
expect(query).toContain("pr0:");
expect(query).toContain("pr1:");
expect(query).toContain("pr2:");
expect(query).toContain("pr3:");
expect(query).toContain("pr4:");
});
it("should handle special characters in owner/repo names", () => {
const prs = [
{
owner: "org-name",
repo: "repo_name",
number: 1,
url: "https://github.com/org-name/repo_name/pull/1",
title: "Test",
branch: "test",
baseBranch: "main",
isDraft: false,
},
];
const { variables } = generateBatchQuery(prs);
expect(variables.pr0Owner).toBe("org-name");
expect(variables.pr0Name).toBe("repo_name");
});
it("should handle PR numbers of varying lengths", () => {
const prs = [
{
owner: "test",
repo: "test",
number: 1,
url: "https://github.com/test/test/pull/1",
title: "Test 1",
branch: "branch1",
baseBranch: "main",
isDraft: false,
},
{
owner: "test",
repo: "test",
number: 9999,
url: "https://github.com/test/test/pull/9999",
title: "Test 9999",
branch: "branch9999",
baseBranch: "main",
isDraft: false,
},
];
const { variables } = generateBatchQuery(prs);
expect(variables.pr0Number).toBe(1);
expect(variables.pr1Number).toBe(9999);
});
it("should generate properly formatted GraphQL query", () => {
const prs = [
{
owner: "test",
repo: "test",
number: 1,
url: "https://github.com/test/test/pull/1",
title: "Test",
branch: "test",
baseBranch: "main",
isDraft: false,
},
];
const { query } = generateBatchQuery(prs);
// Check query structure
expect(query).toMatch(/^query BatchPRs\(/);
expect(query).toContain(") {\n");
expect(query).toContain("}\n }");
});
});
describe("CI State Parsing", () => {
it("should parse SUCCESS state as passing", () => {
expect(parseCIState({ state: "SUCCESS" })).toBe("passing");
expect(parseCIState({ state: "success" })).toBe("passing");
});
it("should parse FAILURE state as failing", () => {
expect(parseCIState({ state: "FAILURE" })).toBe("failing");
expect(parseCIState({ state: "failure" })).toBe("failing");
});
it("should parse PENDING state as pending", () => {
expect(parseCIState({ state: "PENDING" })).toBe("pending");
expect(parseCIState({ state: "pending" })).toBe("pending");
expect(parseCIState({ state: "EXPECTED" })).toBe("pending");
});
it("should use aggregate state even when individual contexts are present", () => {
// The top-level state provides overall CI status. Individual context
// details are parsed separately for ciChecks when the page is complete.
expect(parseCIState({
state: "PENDING",
contexts: {
nodes: [
{ state: "SUCCESS", conclusion: "SUCCESS" },
{ state: "PENDING", conclusion: null },
],
},
})).toBe("pending");
expect(parseCIState({
state: "FAILURE",
contexts: {
nodes: [
{ state: "FAILURE", conclusion: "FAILURE" },
{ state: "SUCCESS", conclusion: "SUCCESS" },
],
},
})).toBe("failing");
});
it("should return none for unknown state", () => {
expect(parseCIState({ state: "UNKNOWN" })).toBe("none");
expect(parseCIState({})).toBe("none");
expect(parseCIState(null)).toBe("none");
expect(parseCIState(undefined)).toBe("none");
});
it("should parse various conclusion states correctly", () => {
expect(parseCIState({ state: "TIMED_OUT" })).toBe("failing");
expect(parseCIState({ state: "ACTION_REQUIRED" })).toBe("failing");
expect(parseCIState({ state: "QUEUED" })).toBe("pending");
expect(parseCIState({ state: "IN_PROGRESS" })).toBe("pending");
expect(parseCIState({ state: "SKIPPED" })).toBe("none");
expect(parseCIState({ state: "STALE" })).toBe("none");
});
});
describe("Review Decision Parsing", () => {
it("should parse APPROVED decision", () => {
expect(parseReviewDecision("APPROVED")).toBe("approved");
expect(parseReviewDecision("approved")).toBe("approved");
});
it("should parse CHANGES_REQUESTED decision", () => {
expect(parseReviewDecision("CHANGES_REQUESTED")).toBe("changes_requested");
expect(parseReviewDecision("changes_requested")).toBe("changes_requested");
});
it("should parse REVIEW_REQUIRED decision", () => {
expect(parseReviewDecision("REVIEW_REQUIRED")).toBe("pending");
expect(parseReviewDecision("review_required")).toBe("pending");
});
it("should parse unknown decision as none", () => {
expect(parseReviewDecision(null)).toBe("none");
expect(parseReviewDecision(undefined)).toBe("none");
expect(parseReviewDecision("")).toBe("none");
expect(parseReviewDecision("UNKNOWN")).toBe("none");
});
});
describe("PR State Parsing", () => {
it("should parse MERGED state", () => {
expect(parsePRState("MERGED")).toBe("merged");
expect(parsePRState("merged")).toBe("merged");
});
it("should parse CLOSED state", () => {
expect(parsePRState("CLOSED")).toBe("closed");
expect(parsePRState("closed")).toBe("closed");
});
it("should parse OPEN state", () => {
expect(parsePRState("OPEN")).toBe("open");
expect(parsePRState("open")).toBe("open");
});
it("should parse unknown state as open", () => {
expect(parsePRState(null)).toBe("open");
expect(parsePRState(undefined)).toBe("open");
expect(parsePRState("")).toBe("open");
});
});
describe("PR Enrichment Data Extraction", () => {
it("should extract complete PR enrichment data", () => {
const pullRequest = {
title: "Add new feature",
state: "OPEN",
additions: 100,
deletions: 50,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "APPROVED",
reviews: {
nodes: [
{ author: { login: "user1" }, state: "APPROVED", submittedAt: "2024-01-01T00:00:00Z" },
],
},
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{ state: "SUCCESS", conclusion: "SUCCESS", name: "ci", context: "default/ci" },
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
expect(extracted).not.toBeNull();
expect(extracted?.data.state).toBe("open");
expect(extracted?.data.ciStatus).toBe("passing");
expect(extracted?.data.reviewDecision).toBe("approved");
expect(extracted?.data.mergeable).toBe(true);
expect(extracted?.data.title).toBe("Add new feature");
expect(extracted?.data.additions).toBe(100);
expect(extracted?.data.deletions).toBe(50);
expect(extracted?.data.isDraft).toBe(false);
expect(extracted?.data.hasConflicts).toBe(false);
expect(extracted?.data.isBehind).toBe(false);
expect(extracted?.data.blockers).toEqual([]);
});
it("should extract PR with CI failures", () => {
const pullRequest = {
title: "Fix bug",
state: "OPEN",
additions: 10,
deletions: 5,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "APPROVED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{ state: "FAILURE", conclusion: "FAILURE", name: "tests", context: "ci/tests" },
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.ciStatus).toBe("failing");
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("CI is failing");
});
it("should extract PR with changes requested", () => {
const pullRequest = {
title: "Update code",
state: "OPEN",
additions: 20,
deletions: 10,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "CHANGES_REQUESTED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.reviewDecision).toBe("changes_requested");
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("Changes requested in review");
});
it("should extract PR with merge conflicts", () => {
const pullRequest = {
title: "Fix conflict",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "CONFLICTING",
mergeStateStatus: "DIRTY",
reviewDecision: "APPROVED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.hasConflicts).toBe(true);
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("Merge conflicts");
});
it("should extract PR that is behind base", () => {
const pullRequest = {
title: "Sync branch",
state: "OPEN",
additions: 15,
deletions: 8,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "BEHIND",
reviewDecision: "APPROVED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.isBehind).toBe(true);
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("Branch is behind base branch");
});
it("should extract draft PR", () => {
const pullRequest = {
title: "WIP: New feature",
state: "OPEN",
additions: 50,
deletions: 25,
isDraft: true,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.isDraft).toBe(true);
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("PR is still a draft");
});
it("should return null for invalid pull request data", () => {
expect(extractPREnrichment(null)).toBeNull();
expect(extractPREnrichment(undefined)).toBeNull();
expect(extractPREnrichment({})).toBeNull();
expect(extractPREnrichment({ invalid: "data" })).toBeNull();
});
it("should handle merged PR state", () => {
const pullRequest = {
title: "Completed feature",
state: "MERGED",
additions: 100,
deletions: 50,
isDraft: false,
mergeable: null, // GitHub returns null for merged PRs
mergeStateStatus: null,
reviewDecision: "APPROVED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.state).toBe("merged");
});
it("should handle closed PR state", () => {
const pullRequest = {
title: "Abandoned PR",
state: "CLOSED",
additions: 10,
deletions: 5,
isDraft: false,
mergeable: null,
mergeStateStatus: null,
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.state).toBe("closed");
});
it("should handle PR with no reviewers required (APPROVED but reviewDecision is NONE)", () => {
const pullRequest = {
title: "Auto-mergeable PR",
state: "OPEN",
additions: 30,
deletions: 15,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE", // No reviewers configured
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.reviewDecision).toBe("none");
expect(result?.mergeable).toBe(true); // "none" is treated as approved for merge readiness
});
it("should handle PR with pending reviews", () => {
const pullRequest = {
title: "Pending review",
state: "OPEN",
additions: 40,
deletions: 20,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "REVIEW_REQUIRED",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const result = extracted?.data;
expect(result?.reviewDecision).toBe("pending");
expect(result?.mergeable).toBe(false);
expect(result?.blockers).toContain("Review required");
});
});
describe("GraphQL batch sizing constants", () => {
it("should keep PR batches at 25 for optimal batch sizing", () => {
expect(MAX_BATCH_SIZE).toBe(25);
});
it("should request enough CI contexts for AO's 18-check CI", () => {
expect(CI_CONTEXTS_FIRST).toBe(20);
});
});
describe("ETag Cache", () => {
beforeEach(() => {
// Clear caches before each test
clearETagCache();
clearPRMetadataCache();
});
describe("ETag Cache Getters (Testing Exposed APIs)", () => {
it("should return undefined for PR list ETag not in cache", () => {
const owner = "testowner";
const repo = "testrepo";
expect(getPRListETag(owner, repo)).toBeUndefined();
});
it("should return undefined for commit status ETag not in cache", () => {
const owner = "testowner";
const repo = "testrepo";
const sha = "abc123def456";
expect(getCommitStatusETag(owner, repo, sha)).toBeUndefined();
});
});
describe("PR Metadata Cache", () => {
it("should store and retrieve PR metadata", () => {
const key = "owner/repo#123";
const metadata = { headSha: "abc123", ciStatus: "pending" };
setPRMetadata(key, metadata);
const cached = getPRMetadataCache().get(key);
expect(cached).toEqual(metadata);
});
it("should store metadata for multiple PRs", () => {
const key1 = "owner/repo#1";
const key2 = "owner/repo#2";
const key3 = "other/repo#1";
setPRMetadata(key1, { headSha: "abc", ciStatus: "pending" });
setPRMetadata(key2, { headSha: "def", ciStatus: "passing" });
setPRMetadata(key3, { headSha: "ghi", ciStatus: "failing" });
const cache = getPRMetadataCache();
expect(cache.size).toBe(3);
expect(cache.get(key1)?.ciStatus).toBe("pending");
expect(cache.get(key2)?.ciStatus).toBe("passing");
expect(cache.get(key3)?.ciStatus).toBe("failing");
});
it("should allow updating metadata for same PR", () => {
const key = "owner/repo#123";
setPRMetadata(key, { headSha: "abc", ciStatus: "pending" });
expect(getPRMetadataCache().get(key)?.ciStatus).toBe("pending");
setPRMetadata(key, { headSha: "abc", ciStatus: "passing" });
expect(getPRMetadataCache().get(key)?.ciStatus).toBe("passing");
});
it("should clear PR metadata cache", () => {
setPRMetadata("owner/repo#1", { headSha: "abc", ciStatus: "pending" });
setPRMetadata("owner/repo#2", { headSha: "def", ciStatus: "passing" });
expect(getPRMetadataCache().size).toBe(2);
clearPRMetadataCache();
expect(getPRMetadataCache().size).toBe(0);
});
it("should handle null headSha", () => {
const key = "owner/repo#123";
const metadata = { headSha: null, ciStatus: "passing" };
setPRMetadata(key, metadata);
const cached = getPRMetadataCache().get(key);
expect(cached).toEqual(metadata);
expect(cached?.headSha).toBeNull();
});
});
});
describe("shouldRefreshPREnrichment - ETag Guard Strategy", () => {
beforeEach(() => {
clearETagCache();
clearPRMetadataCache();
// Don't clear all mocks - reset only our mock call counts
mockExecFileImpl.mockClear();
});
describe("Empty PRs", () => {
it("should not refresh when no PRs provided", async () => {
const result = await shouldRefreshPREnrichment([]);
expect(result.shouldRefresh).toBe(false);
expect(result.details).toContain("No repos to check");
// Should not make any API calls
expect(mockExecFileImpl).not.toHaveBeenCalled();
});
});
describe("Guard 1: PR List ETag - First-time PR (no cache)", () => {
it("should refresh when PR list ETag check returns 200 (first time)", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock gh CLI response for PR list check (200 OK, not 304)
mockExecFileImpl.mockResolvedValueOnce({
stdout: 'HTTP/2 200\neTag: "abc123"',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(true);
expect(result.details).toContain("PR list changed for owner/repo (Guard 1)");
expect(mockExecFileImpl).toHaveBeenCalledTimes(1);
});
it("should not refresh when PR list ETag returns 304 Not Modified", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock gh CLI response for PR list check (304 Not Modified)
mockExecFileImpl.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(false);
// When no changes detected, details may be empty (only changes add to details)
expect(result.details).toHaveLength(0);
});
it("should refresh on error and log warning", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock gh CLI error
const mockObserver = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
log: vi.fn(),
};
mockExecFileImpl.mockRejectedValueOnce(new Error("gh CLI failed"));
const result = await shouldRefreshPREnrichment(prs, [], mockObserver);
expect(result.shouldRefresh).toBe(true); // Fail-safe: assume changed on error
expect(mockObserver.log).toHaveBeenCalledWith("warn", expect.stringContaining("[ETag Guard 1]"));
});
it("should treat error message with HTTP 304 status line as cache hit", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Error where stdout/stderr extraction fails but message contains HTTP 304 status
const err = new Error("HTTP/1.1 304 Not Modified");
mockExecFileImpl.mockRejectedValueOnce(err);
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(false); // Treated as cache hit
});
it("should NOT treat error message with '304' in a URL path as cache hit", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 304,
url: "https://github.com/owner/repo/pull/304",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
const mockObserver = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
log: vi.fn(),
};
// Error message contains "304" in URL path but not as HTTP status
const err = new Error("failed to fetch pulls/304/comments");
mockExecFileImpl.mockRejectedValueOnce(err);
const result = await shouldRefreshPREnrichment(prs, [], mockObserver);
expect(result.shouldRefresh).toBe(true); // Not a cache hit — real error
expect(mockObserver.log).toHaveBeenCalledWith("warn", expect.stringContaining("[ETag Guard 1]"));
});
});
describe("Guard 2: Commit Status ETag - Pending CI PRs", () => {
it("should refresh when commit status ETag check returns 200", async () => {
// Set up cached PR with pending CI
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "pending" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock: Guard 1 returns 304 (no change), Guard 2 returns 200 (CI changed)
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 304', // Guard 1: no change
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 200\neTag: "xyz789"', // Guard 2: CI changed
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(true);
expect(result.details).toContain("CI status changed for owner/repo#123 (Guard 2)");
expect(mockExecFileImpl).toHaveBeenCalledTimes(2);
});
it("should not refresh when commit status ETag returns 304", async () => {
// Set up cached PR with pending CI
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "pending" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock both guards return 304 (no change)
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(false);
expect(mockExecFileImpl).toHaveBeenCalledTimes(2);
});
it("should check Guard 2 for ALL PRs with cached metadata", async () => {
// Set up cached PR with passing CI (not pending) - still should be checked
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "passing" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock both guards return 304 (no change)
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(false);
expect(mockExecFileImpl).toHaveBeenCalledTimes(2); // Guard 1 + Guard 2 called
});
it("should refresh when PR has no cached head SHA", async () => {
// Set up cached PR with null head SHA (incomplete cache)
setPRMetadata("owner/repo#123", { headSha: null, ciStatus: "pending" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Mock Guard 1 (PR list check)
mockExecFileImpl.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(true);
expect(result.details).toContain("First time seeing PR #123 (Guard 2: no cached head SHA)");
// Guard 1 called for PR list, Guard 2 skipped (no head SHA to check)
expect(mockExecFileImpl).toHaveBeenCalledTimes(1);
});
it("should refresh on Guard 2 error and log warning", async () => {
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "pending" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
const mockObserver = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
log: vi.fn(),
};
// Guard 1: 304 (no change), Guard 2: error
mockExecFileImpl
.mockResolvedValueOnce({ stdout: "HTTP/2 304", stderr: "" })
.mockRejectedValueOnce(new Error("gh CLI failed on commit status"));
const result = await shouldRefreshPREnrichment(prs, [], mockObserver);
expect(result.shouldRefresh).toBe(true);
expect(mockObserver.log).toHaveBeenCalledWith("warn", expect.stringContaining("[ETag Guard 2]"));
});
it("should treat Guard 2 error with HTTP 304 status line as cache hit", async () => {
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "pending" });
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// Guard 1: 304, Guard 2: error with HTTP 304 in message
mockExecFileImpl
.mockResolvedValueOnce({ stdout: "HTTP/2 304", stderr: "" })
.mockRejectedValueOnce(new Error("HTTP/1.1 304 Not Modified"));
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(false);
});
});
describe("Multiple Repositories", () => {
it("should check PR list ETag for each repository", async () => {
const prs = [
{
owner: "owner1",
repo: "repo1",
number: 1,
url: "https://github.com/owner1/repo1/pull/1",
title: "Test PR 1",
branch: "feature1",
baseBranch: "main",
isDraft: false,
},
{
owner: "owner2",
repo: "repo2",
number: 2,
url: "https://github.com/owner2/repo2/pull/2",
title: "Test PR 2",
branch: "feature2",
baseBranch: "main",
isDraft: false,
},
];
// With new behavior: ALL PRs with cached metadata get Guard 2 checked
// Add metadata for both PRs to validate Guard 2 is called
setPRMetadata("owner1/repo1#1", { headSha: "sha1", ciStatus: "passing" });
setPRMetadata("owner2/repo2#2", { headSha: "sha2", ciStatus: "pending" });
// Both repos changed - Guard 1 calls
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 200',
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 200',
stderr: "",
});
const result = await shouldRefreshPREnrichment(prs);
expect(result.shouldRefresh).toBe(true);
// Guard 1 adds 2 details (one per repo), Guard 2 skipped (304 = no change, no detail)
expect(result.details).toHaveLength(2);
expect(result.details).toContain("PR list changed for owner1/repo1 (Guard 1)");
expect(result.details).toContain("PR list changed for owner2/repo2 (Guard 1)");
// 2 Guard 1 calls only (Guard 2 calls return 304, no details added)
expect(mockExecFileImpl).toHaveBeenCalledTimes(2);
});
});
describe("If-None-Match Header", () => {
it("should send If-None-Match header with cached ETag", async () => {
const prs = [
{
owner: "owner",
repo: "repo",
number: 123,
url: "https://github.com/owner/repo/pull/123",
title: "Test PR",
branch: "feature",
baseBranch: "main",
isDraft: false,
},
];
// First call - cache miss, returns new ETag
mockExecFileImpl.mockResolvedValueOnce({
stdout: 'HTTP/2 200\netag: "cached-etag"',
stderr: "",
});
const result1 = await shouldRefreshPREnrichment(prs);
expect(result1.shouldRefresh).toBe(true);
// Cache metadata and ETags after first poll so Guard 2 has data for second poll
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "passing" });
setPRListETag("owner", "repo", "cached-etag");
setCommitStatusETag("owner", "repo", "abc123", "commit-status-etag");
// Second call - should use cached ETag in If-None-Match headers (Guard 1 + Guard 2)
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result2 = await shouldRefreshPREnrichment(prs);
expect(result2.shouldRefresh).toBe(false);
// Cache metadata after first poll so Guard 2 has data for second poll
setPRMetadata("owner/repo#123", { headSha: "abc123", ciStatus: "passing" });
// Second poll - should use cached ETag in If-None-Match headers (Guard 1 + Guard 2)
mockExecFileImpl
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
})
.mockResolvedValueOnce({
stdout: 'HTTP/2 304',
stderr: "",
});
const result3 = await shouldRefreshPREnrichment(prs);
expect(result3.shouldRefresh).toBe(false);
// Verify the second poll included If-None-Match headers
// Get all call arguments and find those with -H flag
const allCalls = mockExecFileImpl.mock.calls;
// Second poll has 2 calls: Guard 1 (index 1) and Guard 2 (index 2)
const secondPollCalls = allCalls.slice(1, 3);
// Mock call format: [file, args, options], so check call[1] for args
const callsWithHeader = secondPollCalls.filter((call) =>
Array.isArray(call) && call[1] && call[1].includes("-H")
);
expect(callsWithHeader).toHaveLength(2); // Both Guard 1 and Guard 2
});
});
describe("Guard 3: Review Comments ETag", () => {
it("should return true (changed) on 200 response", async () => {
mockExecFileImpl.mockResolvedValueOnce({
stdout: 'HTTP/2 200\netag: "review-etag"',
stderr: "",
});
const result = await checkReviewCommentsETag("owner", "repo", 42);
expect(result).toBe(true);
});
it("should return false (unchanged) on 304 response", async () => {
mockExecFileImpl.mockResolvedValueOnce({
stdout: "HTTP/2 304",
stderr: "",
});
const result = await checkReviewCommentsETag("owner", "repo", 42);
expect(result).toBe(false);
});
it("should return true on error and log warning via observer", async () => {
const mockObserver = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
log: vi.fn(),
};
mockExecFileImpl.mockRejectedValueOnce(new Error("gh CLI failed"));
const result = await checkReviewCommentsETag("owner", "repo", 42, mockObserver);
expect(result).toBe(true); // Fail-safe: assume changed
expect(mockObserver.log).toHaveBeenCalledWith("warn", expect.stringContaining("[ETag Guard 3]"));
});
it("should treat error with HTTP 304 status line as cache hit", async () => {
mockExecFileImpl.mockRejectedValueOnce(new Error("HTTP/1.1 304 Not Modified"));
const result = await checkReviewCommentsETag("owner", "repo", 42);
expect(result).toBe(false);
});
it("should NOT treat error with '304' in URL path as cache hit", async () => {
const mockObserver = {
recordSuccess: vi.fn(),
recordFailure: vi.fn(),
log: vi.fn(),
};
mockExecFileImpl.mockRejectedValueOnce(new Error("failed to fetch pulls/304/comments"));
const result = await checkReviewCommentsETag("owner", "repo", 304, mockObserver);
expect(result).toBe(true); // Not a cache hit
expect(mockObserver.log).toHaveBeenCalledWith("warn", expect.stringContaining("[ETag Guard 3]"));
});
});
});
describe("extractPREnrichment ciChecks", () => {
it("parses CheckRun contexts into ciChecks", () => {
const pullRequest = {
title: "Fix CI",
state: "OPEN",
additions: 10,
deletions: 5,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{
name: "lint",
status: "COMPLETED",
conclusion: "FAILURE",
detailsUrl: "https://github.com/org/repo/actions/runs/123",
},
{
name: "test",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/org/repo/actions/runs/124",
},
{
name: "typecheck",
status: "IN_PROGRESS",
conclusion: null,
detailsUrl: "https://github.com/org/repo/actions/runs/125",
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const ciChecks = extracted?.data.ciChecks;
expect(ciChecks).toBeDefined();
expect(ciChecks).toHaveLength(3);
const lint = ciChecks?.find((c) => c.name === "lint");
expect(lint?.status).toBe("failed");
expect(lint?.conclusion).toBe("FAILURE");
expect(lint?.url).toBe("https://github.com/org/repo/actions/runs/123");
const test = ciChecks?.find((c) => c.name === "test");
expect(test?.status).toBe("passed");
expect(test?.conclusion).toBe("SUCCESS");
const typecheck = ciChecks?.find((c) => c.name === "typecheck");
expect(typecheck?.status).toBe("running");
});
it("maps QUEUED and WAITING to pending, not running (matches REST mapRawCheckStateToStatus)", () => {
const pullRequest = {
title: "Queued checks",
state: "OPEN",
additions: 1,
deletions: 0,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "PENDING",
contexts: {
nodes: [
{ name: "queued-check", status: "QUEUED", conclusion: null, detailsUrl: null },
{ name: "waiting-check", status: "WAITING", conclusion: null, detailsUrl: null },
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const ciChecks = extracted?.data.ciChecks;
expect(ciChecks?.find((c) => c.name === "queued-check")?.status).toBe("pending");
expect(ciChecks?.find((c) => c.name === "waiting-check")?.status).toBe("pending");
});
it("parses StatusContext nodes into ciChecks", () => {
const pullRequest = {
title: "Legacy CI",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{
context: "ci/build",
state: "FAILURE",
targetUrl: "https://ci.example.com/build/1",
},
{
context: "ci/test",
state: "SUCCESS",
targetUrl: "https://ci.example.com/test/1",
},
{
context: "ci/deploy",
state: "PENDING",
targetUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const ciChecks = extracted?.data.ciChecks;
expect(ciChecks).toBeDefined();
expect(ciChecks).toHaveLength(3);
const build = ciChecks?.find((c) => c.name === "ci/build");
expect(build?.status).toBe("failed");
expect(build?.conclusion).toBe("FAILURE"); // matches REST getCIChecksFromStatusRollup format
expect(build?.url).toBe("https://ci.example.com/build/1");
const test = ciChecks?.find((c) => c.name === "ci/test");
expect(test?.status).toBe("passed");
expect(test?.conclusion).toBe("SUCCESS");
const deploy = ciChecks?.find((c) => c.name === "ci/deploy");
expect(deploy?.status).toBe("pending");
expect(deploy?.conclusion).toBe("PENDING");
expect(deploy?.url).toBeUndefined();
});
it("returns empty array when contexts nodes is empty", () => {
const pullRequest = {
title: "Clean PR",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: { nodes: [] },
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
expect(extracted?.data.ciChecks).toEqual([]);
});
it("returns undefined ciChecks when statusCheckRollup has no contexts field", () => {
const pullRequest = {
title: "No contexts",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
// No contexts field — older API response
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
expect(extracted?.data.ciChecks).toBeUndefined();
});
it("maps COMPLETED+SKIPPED conclusion to skipped status", () => {
const pullRequest = {
title: "Skipped check",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{
name: "optional-check",
status: "COMPLETED",
conclusion: "SKIPPED",
detailsUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const check = extracted?.data.ciChecks?.[0];
expect(check?.status).toBe("skipped");
expect(check?.conclusion).toBe("SKIPPED"); // uppercased to match REST format
});
it("maps COMPLETED+NEUTRAL conclusion to skipped (matches REST mapRawCheckStateToStatus)", () => {
const pullRequest = {
title: "Neutral check",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{
name: "optional-check",
status: "COMPLETED",
conclusion: "NEUTRAL",
detailsUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const check = extracted?.data.ciChecks?.[0];
// NEUTRAL maps to "skipped" in REST mapRawCheckStateToStatus — must match
expect(check?.status).toBe("skipped");
expect(check?.conclusion).toBe("NEUTRAL");
});
it("uppercases CheckRun conclusion to match REST getCIChecks format", () => {
const pullRequest = {
title: "Mixed case conclusion",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{
name: "lint",
status: "COMPLETED",
conclusion: "failure", // lowercase from API
detailsUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const check = extracted?.data.ciChecks?.[0];
expect(check?.status).toBe("failed");
expect(check?.conclusion).toBe("FAILURE"); // must be uppercased
});
it("maps STALE/NOT_REQUIRED/NONE conclusions to skipped (matches REST mapRawCheckStateToStatus)", () => {
const makeContextsWithConclusion = (conclusion: string) => ({
title: "Check",
state: "OPEN",
additions: 1,
deletions: 0,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [{ name: "check", status: "COMPLETED", conclusion, detailsUrl: null }],
},
},
},
},
],
},
});
for (const conclusion of ["STALE", "NOT_REQUIRED", "NONE"]) {
const extracted = extractPREnrichment(makeContextsWithConclusion(conclusion));
const check = extracted?.data.ciChecks?.[0];
expect(check?.status, `${conclusion} should map to skipped`).toBe("skipped");
}
});
it("returns undefined ciChecks when contexts list is truncated (hasNextPage=true)", () => {
const pullRequest = {
title: "Many CI checks",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{ name: "check-1", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: null },
// ... more checks truncated beyond CI_CONTEXTS_FIRST
],
pageInfo: { hasNextPage: true }, // list was truncated!
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
// ciChecks should be undefined when truncated — forces getCIChecks() REST fallback
expect(extracted?.data.ciChecks).toBeUndefined();
});
it("returns ciChecks when contexts list is complete (hasNextPage=false)", () => {
const pullRequest = {
title: "Few CI checks",
state: "OPEN",
additions: 5,
deletions: 2,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{ name: "lint", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: "https://ci.example.com/lint" },
],
pageInfo: { hasNextPage: false }, // complete list
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
expect(extracted?.data.ciChecks).toBeDefined();
expect(extracted?.data.ciChecks).toHaveLength(1);
expect(extracted?.data.ciChecks?.[0]?.name).toBe("lint");
});
it("maps COMPLETED with null conclusion to skipped (matches REST mapRawCheckStateToStatus empty-string branch)", () => {
// REST path: mapRawCheckStateToStatus(undefined) → state="" → "skipped"
// GraphQL path must match: COMPLETED + null conclusion → "skipped", not "passed"
const pullRequest = {
title: "Null conclusion check",
state: "OPEN",
additions: 1,
deletions: 0,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{
name: "some-check",
status: "COMPLETED",
conclusion: null, // null conclusion — shouldn't map to "passed"
detailsUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const check = extracted?.data.ciChecks?.[0];
expect(check?.status).toBe("skipped");
});
it("maps COMPLETED+STARTUP_FAILURE to skipped (matches REST mapRawCheckStateToStatus default fallback)", () => {
const pullRequest = {
title: "Startup failure check",
state: "OPEN",
additions: 1,
deletions: 0,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "FAILURE",
contexts: {
nodes: [
{
name: "infra-check",
status: "COMPLETED",
conclusion: "STARTUP_FAILURE",
detailsUrl: null,
},
],
},
},
},
},
],
},
};
const extracted = extractPREnrichment(pullRequest);
const check = extracted?.data.ciChecks?.[0];
// STARTUP_FAILURE is not in the explicit failure list → falls through to "skipped"
// matching mapRawCheckStateToStatus()'s default return "skipped"
expect(check?.status).toBe("skipped");
expect(check?.conclusion).toBe("STARTUP_FAILURE");
});
it("handles null pageInfo safely without TypeError (typeof null === 'object' quirk)", () => {
const pullRequest = {
title: "Null pageInfo",
state: "OPEN",
additions: 1,
deletions: 0,
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "NONE",
reviews: { nodes: [] },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
state: "SUCCESS",
contexts: {
nodes: [
{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null },
],
pageInfo: null, // null pageInfo — older API responses may omit this
},
},
},
},
],
},
};
// Must not throw TypeError: Cannot read properties of null
expect(() => extractPREnrichment(pullRequest)).not.toThrow();
const extracted = extractPREnrichment(pullRequest);
// null pageInfo is treated as "not truncated" → ciChecks should be defined
expect(extracted?.data.ciChecks).toBeDefined();
expect(extracted?.data.ciChecks).toHaveLength(1);
});
});