From ee2b19d8820907c22bd4d397bf63f8121a8b25b4 Mon Sep 17 00:00:00 2001 From: MateusSilva <88845521+mateuscmtropical@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:16:45 -0300 Subject: [PATCH] test: add unit tests for parseGitHubContext and context type guards (#1404) * test: add unit tests for parseGitHubContext and context type guards Covers all supported webhook event types (entity and automation), the pull_request_target normalization, isPR detection for comments on pull requests, env-derived input defaults and parsing, and the nine type guard functions. Raises src/github/context.ts line coverage from 26.5% to 100%. Co-Authored-By: Claude Fable 5 * test: assert all env-derived inputs to close mutation testing gaps Mutation testing (StrykerJS, 138 mutants) showed 22 surviving mutants in the env input parsing of parseGitHubContext: fields like trackProgress, includeFixLinks, allowedBots and the comment actor filters were never asserted. Asserting every input field in both the defaults and the explicit-values tests, plus covering the optional chaining on payload.repository, brings the mutation score for src/github/context.ts from 84.06% to 100%. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- test/github-context.test.ts | 520 ++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 test/github-context.test.ts diff --git a/test/github-context.test.ts b/test/github-context.test.ts new file mode 100644 index 00000000..40870f8f --- /dev/null +++ b/test/github-context.test.ts @@ -0,0 +1,520 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + IssuesEvent, + IssueCommentEvent, + PullRequestEvent, + PullRequestReviewEvent, + PullRequestReviewCommentEvent, + WorkflowRunEvent, +} from "@octokit/webhooks-types"; + +// parseGitHubContext() reads the singleton `github.context` from +// @actions/github, so the module is mocked with a mutable context object +// that each test configures. Nothing else in the codebase imports +// @actions/github, so the mock does not leak into other suites. +const fakeGithubContext = { + eventName: "", + payload: {} as Record, + repo: { owner: "test-owner", repo: "test-repo" }, + actor: "test-actor", +}; + +mock.module("@actions/github", () => ({ + context: fakeGithubContext, +})); + +import { + parseGitHubContext, + isIssuesEvent, + isIssueCommentEvent, + isPullRequestEvent, + isPullRequestReviewEvent, + isPullRequestReviewCommentEvent, + isIssuesAssignedEvent, + isEntityContext, + isAutomationContext, +} from "../src/github/context"; +import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants"; +import { createMockContext, createMockAutomationContext } from "./mockContext"; + +const ENV_KEYS = [ + "GITHUB_RUN_ID", + "PROMPT", + "TRIGGER_PHRASE", + "ASSIGNEE_TRIGGER", + "LABEL_TRIGGER", + "BASE_BRANCH", + "BRANCH_PREFIX", + "BRANCH_NAME_TEMPLATE", + "USE_STICKY_COMMENT", + "CLASSIFY_INLINE_COMMENTS", + "USE_COMMIT_SIGNING", + "SSH_SIGNING_KEY", + "BOT_ID", + "BOT_NAME", + "ALLOWED_BOTS", + "ALLOWED_NON_WRITE_USERS", + "TRACK_PROGRESS", + "INCLUDE_FIX_LINKS", + "INCLUDE_COMMENTS_BY_ACTOR", + "EXCLUDE_COMMENTS_BY_ACTOR", +] as const; + +const originalEnv: Record = {}; +for (const key of ENV_KEYS) { + originalEnv[key] = process.env[key]; +} + +beforeEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.GITHUB_RUN_ID = "9876543210"; + + fakeGithubContext.eventName = ""; + fakeGithubContext.payload = {}; + fakeGithubContext.repo = { owner: "test-owner", repo: "test-repo" }; + fakeGithubContext.actor = "test-actor"; +}); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +}); + +const repositoryPayload = { + name: "test-repo", + full_name: "test-owner/test-repo", + private: false, + default_branch: "main", + owner: { login: "test-owner" }, +}; + +function setEvent(eventName: string, payload: unknown) { + fakeGithubContext.eventName = eventName; + fakeGithubContext.payload = payload as Record; +} + +describe("parseGitHubContext", () => { + describe("entity events (one test per equivalence partition)", () => { + test("issues event extracts issue number and isPR false", () => { + setEvent("issues", { + action: "opened", + issue: { number: 42 }, + repository: repositoryPayload, + } as unknown as IssuesEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("issues"); + expect(context.eventAction).toBe("opened"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(42); + expect(context.isPR).toBe(false); + }); + + test("issue_comment on a plain issue has isPR false", () => { + setEvent("issue_comment", { + action: "created", + issue: { number: 55 }, + comment: { id: 1, body: "hello" }, + repository: repositoryPayload, + } as unknown as IssueCommentEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("issue_comment"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(55); + expect(context.isPR).toBe(false); + }); + + test("issue_comment on a pull request has isPR true", () => { + setEvent("issue_comment", { + action: "created", + issue: { + number: 789, + pull_request: { + url: "https://api.github.com/repos/test-owner/test-repo/pulls/789", + }, + }, + comment: { id: 2, body: "hello" }, + repository: repositoryPayload, + } as unknown as IssueCommentEvent); + + const context = parseGitHubContext(); + + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(789); + expect(context.isPR).toBe(true); + }); + + test("pull_request event extracts PR number and isPR true", () => { + setEvent("pull_request", { + action: "opened", + number: 456, + pull_request: { number: 456 }, + repository: repositoryPayload, + } as unknown as PullRequestEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("pull_request"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(456); + expect(context.isPR).toBe(true); + }); + + test("pull_request_target is normalized to pull_request", () => { + setEvent("pull_request_target", { + action: "opened", + number: 457, + pull_request: { number: 457 }, + repository: repositoryPayload, + } as unknown as PullRequestEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("pull_request"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(457); + expect(context.isPR).toBe(true); + }); + + test("pull_request_review event extracts PR number and isPR true", () => { + setEvent("pull_request_review", { + action: "submitted", + review: { id: 9, state: "approved" }, + pull_request: { number: 321 }, + repository: repositoryPayload, + } as unknown as PullRequestReviewEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("pull_request_review"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(321); + expect(context.isPR).toBe(true); + }); + + test("pull_request_review_comment event extracts PR number and isPR true", () => { + setEvent("pull_request_review_comment", { + action: "created", + comment: { id: 7, body: "inline" }, + pull_request: { number: 999 }, + repository: repositoryPayload, + } as unknown as PullRequestReviewCommentEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("pull_request_review_comment"); + if (!isEntityContext(context)) { + throw new Error("expected entity context"); + } + expect(context.entityNumber).toBe(999); + expect(context.isPR).toBe(true); + }); + }); + + describe("automation events (no entityNumber, no isPR)", () => { + test("workflow_dispatch produces an automation context", () => { + setEvent("workflow_dispatch", { + inputs: { task: "run" }, + repository: repositoryPayload, + sender: { login: "test-actor" }, + workflow: "ci.yml", + }); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("workflow_dispatch"); + expect(isAutomationContext(context)).toBe(true); + expect("entityNumber" in context).toBe(false); + expect("isPR" in context).toBe(false); + }); + + test("repository_dispatch produces an automation context", () => { + setEvent("repository_dispatch", { + action: "trigger-analysis", + client_payload: { issue_number: 42 }, + repository: repositoryPayload, + sender: { login: "automation-user" }, + }); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("repository_dispatch"); + expect(context.eventAction).toBe("trigger-analysis"); + expect(isAutomationContext(context)).toBe(true); + }); + + test("schedule produces an automation context", () => { + setEvent("schedule", { + schedule: "0 0 * * *", + repository: repositoryPayload, + }); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("schedule"); + expect(isAutomationContext(context)).toBe(true); + }); + + test("payload without repository keeps default_branch undefined", () => { + setEvent("schedule", { schedule: "0 0 * * *" }); + + const context = parseGitHubContext(); + + expect(context.repository.default_branch).toBeUndefined(); + }); + + test("workflow_run produces an automation context", () => { + setEvent("workflow_run", { + action: "completed", + workflow_run: { id: 123 }, + repository: repositoryPayload, + } as unknown as WorkflowRunEvent); + + const context = parseGitHubContext(); + + expect(context.eventName).toBe("workflow_run"); + expect(isAutomationContext(context)).toBe(true); + }); + }); + + describe("invalid partition", () => { + test("unsupported event type throws", () => { + setEvent("deployment_status", { repository: repositoryPayload }); + + expect(() => parseGitHubContext()).toThrow( + "Unsupported event type: deployment_status", + ); + }); + }); + + describe("common fields", () => { + test("repository and runId come from the action context", () => { + setEvent("issues", { + action: "opened", + issue: { number: 1 }, + repository: repositoryPayload, + } as unknown as IssuesEvent); + + const context = parseGitHubContext(); + + expect(context.runId).toBe("9876543210"); + expect(context.actor).toBe("test-actor"); + expect(context.repository).toEqual({ + owner: "test-owner", + repo: "test-repo", + full_name: "test-owner/test-repo", + default_branch: "main", + }); + }); + + test("inputs fall back to documented defaults when env vars are unset", () => { + setEvent("issues", { + action: "opened", + issue: { number: 1 }, + repository: repositoryPayload, + } as unknown as IssuesEvent); + + const { inputs } = parseGitHubContext(); + + expect(inputs.prompt).toBe(""); + expect(inputs.triggerPhrase).toBe("@claude"); + expect(inputs.assigneeTrigger).toBe(""); + expect(inputs.labelTrigger).toBe(""); + expect(inputs.branchPrefix).toBe("claude/"); + expect(inputs.branchNameTemplate).toBeUndefined(); + expect(inputs.useStickyComment).toBe(false); + expect(inputs.classifyInlineComments).toBe(true); + expect(inputs.useCommitSigning).toBe(false); + expect(inputs.sshSigningKey).toBe(""); + expect(inputs.botId).toBe(String(CLAUDE_APP_BOT_ID)); + expect(inputs.botName).toBe(CLAUDE_BOT_LOGIN); + expect(inputs.allowedBots).toBe(""); + expect(inputs.allowedNonWriteUsers).toBe(""); + expect(inputs.trackProgress).toBe(false); + expect(inputs.includeFixLinks).toBe(false); + expect(inputs.includeCommentsByActor).toBe(""); + expect(inputs.excludeCommentsByActor).toBe(""); + expect(inputs.baseBranch).toBeUndefined(); + }); + + test("inputs reflect the env vars set by action.yml", () => { + process.env.PROMPT = "do something"; + process.env.TRIGGER_PHRASE = "/claude"; + process.env.ASSIGNEE_TRIGGER = "@claude-bot"; + process.env.LABEL_TRIGGER = "claude-task"; + process.env.BASE_BRANCH = "develop"; + process.env.BRANCH_PREFIX = "bot/"; + process.env.BRANCH_NAME_TEMPLATE = "{{description}}"; + process.env.USE_STICKY_COMMENT = "true"; + process.env.CLASSIFY_INLINE_COMMENTS = "false"; + process.env.USE_COMMIT_SIGNING = "true"; + process.env.SSH_SIGNING_KEY = "ssh-key-material"; + process.env.BOT_ID = "111"; + process.env.BOT_NAME = "custom-bot"; + process.env.ALLOWED_BOTS = "dependabot[bot]"; + process.env.ALLOWED_NON_WRITE_USERS = "trusted-user"; + process.env.TRACK_PROGRESS = "true"; + process.env.INCLUDE_FIX_LINKS = "true"; + process.env.INCLUDE_COMMENTS_BY_ACTOR = "alice"; + process.env.EXCLUDE_COMMENTS_BY_ACTOR = "bob"; + + setEvent("issues", { + action: "opened", + issue: { number: 1 }, + repository: repositoryPayload, + } as unknown as IssuesEvent); + + const { inputs } = parseGitHubContext(); + + expect(inputs.prompt).toBe("do something"); + expect(inputs.triggerPhrase).toBe("/claude"); + expect(inputs.assigneeTrigger).toBe("@claude-bot"); + expect(inputs.labelTrigger).toBe("claude-task"); + expect(inputs.baseBranch).toBe("develop"); + expect(inputs.branchPrefix).toBe("bot/"); + expect(inputs.branchNameTemplate).toBe("{{description}}"); + expect(inputs.useStickyComment).toBe(true); + expect(inputs.classifyInlineComments).toBe(false); + expect(inputs.useCommitSigning).toBe(true); + expect(inputs.sshSigningKey).toBe("ssh-key-material"); + expect(inputs.botId).toBe("111"); + expect(inputs.botName).toBe("custom-bot"); + expect(inputs.allowedBots).toBe("dependabot[bot]"); + expect(inputs.allowedNonWriteUsers).toBe("trusted-user"); + expect(inputs.trackProgress).toBe(true); + expect(inputs.includeFixLinks).toBe(true); + expect(inputs.includeCommentsByActor).toBe("alice"); + expect(inputs.excludeCommentsByActor).toBe("bob"); + }); + + test("boolean inputs only accept the lowercase string true", () => { + process.env.USE_STICKY_COMMENT = "TRUE"; + process.env.USE_COMMIT_SIGNING = "1"; + + setEvent("issues", { + action: "opened", + issue: { number: 1 }, + repository: repositoryPayload, + } as unknown as IssuesEvent); + + const { inputs } = parseGitHubContext(); + + expect(inputs.useStickyComment).toBe(false); + expect(inputs.useCommitSigning).toBe(false); + }); + }); +}); + +describe("type guards", () => { + const issuesContext = createMockContext({ eventName: "issues" }); + const issueCommentContext = createMockContext({ + eventName: "issue_comment", + }); + const pullRequestContext = createMockContext({ eventName: "pull_request" }); + const reviewContext = createMockContext({ + eventName: "pull_request_review", + }); + const reviewCommentContext = createMockContext({ + eventName: "pull_request_review_comment", + }); + const workflowDispatchContext = createMockAutomationContext({ + eventName: "workflow_dispatch", + }); + + test("isIssuesEvent accepts only issues events", () => { + expect(isIssuesEvent(issuesContext)).toBe(true); + expect(isIssuesEvent(issueCommentContext)).toBe(false); + expect(isIssuesEvent(workflowDispatchContext)).toBe(false); + }); + + test("isIssueCommentEvent accepts only issue_comment events", () => { + expect(isIssueCommentEvent(issueCommentContext)).toBe(true); + expect(isIssueCommentEvent(issuesContext)).toBe(false); + }); + + test("isPullRequestEvent accepts only pull_request events", () => { + expect(isPullRequestEvent(pullRequestContext)).toBe(true); + expect(isPullRequestEvent(reviewContext)).toBe(false); + expect(isPullRequestEvent(issuesContext)).toBe(false); + }); + + test("isPullRequestReviewEvent accepts only pull_request_review events", () => { + expect(isPullRequestReviewEvent(reviewContext)).toBe(true); + expect(isPullRequestReviewEvent(reviewCommentContext)).toBe(false); + }); + + test("isPullRequestReviewCommentEvent accepts only review comment events", () => { + expect(isPullRequestReviewCommentEvent(reviewCommentContext)).toBe(true); + expect(isPullRequestReviewCommentEvent(reviewContext)).toBe(false); + }); + + test("isIssuesAssignedEvent requires issues event with assigned action", () => { + const assignedContext = createMockContext({ + eventName: "issues", + eventAction: "assigned", + }); + const openedContext = createMockContext({ + eventName: "issues", + eventAction: "opened", + }); + const assignedCommentContext = createMockContext({ + eventName: "issue_comment", + eventAction: "assigned", + }); + + expect(isIssuesAssignedEvent(assignedContext)).toBe(true); + expect(isIssuesAssignedEvent(openedContext)).toBe(false); + expect(isIssuesAssignedEvent(assignedCommentContext)).toBe(false); + }); + + test("isEntityContext accepts the five entity events", () => { + expect(isEntityContext(issuesContext)).toBe(true); + expect(isEntityContext(issueCommentContext)).toBe(true); + expect(isEntityContext(pullRequestContext)).toBe(true); + expect(isEntityContext(reviewContext)).toBe(true); + expect(isEntityContext(reviewCommentContext)).toBe(true); + expect(isEntityContext(workflowDispatchContext)).toBe(false); + }); + + test("isAutomationContext accepts the four automation events", () => { + expect(isAutomationContext(workflowDispatchContext)).toBe(true); + expect( + isAutomationContext( + createMockAutomationContext({ eventName: "repository_dispatch" }), + ), + ).toBe(true); + expect( + isAutomationContext( + createMockAutomationContext({ eventName: "schedule" }), + ), + ).toBe(true); + expect( + isAutomationContext( + createMockAutomationContext({ eventName: "workflow_run" }), + ), + ).toBe(true); + expect(isAutomationContext(issuesContext)).toBe(false); + }); +});