1175 lines
34 KiB
TypeScript
1175 lines
34 KiB
TypeScript
/**
|
|
* Unit tests for GraphQL batch PR enrichment.
|
|
*
|
|
* Note: The GraphQL batch query was optimized to use only the top-level
|
|
* statusCheckRollup.state field instead of fetching individual contexts.
|
|
* This reduces GraphQL API cost from ~50 points to ~10 points per PR while
|
|
* providing the same semantic information for CI status determination.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
// Import from graphql-batch.js
|
|
import {
|
|
generateBatchQuery,
|
|
MAX_BATCH_SIZE,
|
|
parseCIState,
|
|
parseReviewDecision,
|
|
parsePRState,
|
|
extractPREnrichment,
|
|
clearETagCache,
|
|
getPRListETag,
|
|
getCommitStatusETag,
|
|
setPRListETag,
|
|
setCommitStatusETag,
|
|
setPRMetadata,
|
|
getPRMetadataCache,
|
|
clearPRMetadataCache,
|
|
shouldRefreshPREnrichment,
|
|
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("reviews");
|
|
expect(query).toContain("commits");
|
|
expect(query).toContain("statusCheckRollup");
|
|
});
|
|
|
|
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 parse individual contexts for detailed state", () => {
|
|
// After optimization, we no longer fetch individual contexts.
|
|
// The top-level state provides the same semantic information.
|
|
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("MAX_BATCH_SIZE constant", () => {
|
|
it("should be defined as 25", () => {
|
|
expect(MAX_BATCH_SIZE).toBe(25);
|
|
});
|
|
});
|
|
|
|
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 PRs 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 consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
mockExecFileImpl.mockRejectedValueOnce(new Error("gh CLI failed"));
|
|
|
|
const result = await shouldRefreshPREnrichment(prs);
|
|
|
|
expect(result.shouldRefresh).toBe(true); // Fail-safe: assume changed on error
|
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
consoleWarnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|
|
});
|