Files
claude-code-action/test/trigger-validation.test.ts
km-anthropic daac7e353f refactor: implement discriminated unions for GitHub contexts (#360)
* feat: add agent mode for automation scenarios

- Add agent mode that always triggers without checking for mentions
- Implement Mode interface with support for mode-specific tool configuration
- Add getAllowedTools() and getDisallowedTools() methods to Mode interface
- Simplify tests by combining related test cases
- Update documentation and examples to include agent mode
- Fix TypeScript imports to prevent circular dependencies

Agent mode is designed for automation and workflow_dispatch scenarios
where Claude should always run without requiring trigger phrases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Minor update to readme (from @main to @beta)

* Since workflow_dispatch isn't in the base action, update the examples accordingly

* minor formatting issue

* Update to say beta instead of main

* Fix missed tracking comment to be false

* add schedule & workflow dispatch paths. Also make prepare logic conditional

* tests

* Add test workflow for workflow_dispatch functionality

* Update workflow to use correct branch reference

* remove test workflow dispatch file

* minor lint update

* update workflow dispatch agent example

* minor lint update

* refactor: simplify prepare logic with mode-specific implementations

* ensure tag mode can't work with workflow dispatch and schedule tasks

* simplify: remove workflow_dispatch/schedule from create-prompt

- Remove workflow_dispatch and schedule event handling from create-prompt
  since agent mode doesn't use the standard prompt generation flow
- Enforce mode compatibility at selection time in the registry instead
  of runtime validation in tag mode
- Add explanatory comment in agent mode about why prompt file is needed
- Update tests to reflect simplified event handling

This reduces code duplication and makes the separation between tag mode
(entity-based events) and agent mode (automation events) clearer.

* simplify PR by making agent mode only work with workflow dispatch and schedule events

* remove unnecessary changes

* remove unnecessary changes from PR

- Revert update-comment-link.ts changes (agent mode doesn't use this)
- Revert create-initial.ts changes (agent mode doesn't create comments)
- Remove unused default-branch.ts file
- Revert install-mcp-server.ts changes (agent mode uses minimal MCP)

These files are only used by tag mode for entity-based events, not needed
for workflow_dispatch/schedule support via agent mode.

* fix: handle optional entityNumber for TypeScript

- Add runtime checks in files that require entityNumber
- These files are only used by tag mode which always has entityNumber
- Agent mode (workflow_dispatch/schedule) doesn't use these files

* linting update

* refactor: implement discriminated unions for GitHub contexts

Split ParsedGitHubContext into entity-specific and automation contexts:
- ParsedGitHubContext: For entity events (issues/PRs) with required entityNumber and isPR
- AutomationContext: For workflow_dispatch/schedule events without entity fields
- GitHubContext: Union type for all contexts

This eliminates ~20 null checks throughout the codebase and provides better type safety.
Entity-specific code paths are now guaranteed to have the required fields.

Co-Authored-By: Claude <noreply@anthropic.com>

* update comment

* More robust type checking

* refactor: improve discriminated union implementation based on review feedback

- Use eventName checks instead of 'in' operator for more robust type guards
- Remove unnecessary type assertions - TypeScript's control flow analysis works correctly
- Remove redundant runtime checks for entityNumber and isPR
- Simplify code by using context directly after type guard

Co-Authored-By: Claude <noreply@anthropic.com>

* some structural simplification

* refactor: further simplify discriminated union implementation

- Add event name constants to reduce duplication
- Derive EntityEventName and AutomationEventName types from constants
- Use isAutomationContext consistently in agent mode and registry
- Simplify parseGitHubContext by removing redundant type assertions
- Extract payload casts to variables for cleaner code

Co-Authored-By: Claude <noreply@anthropic.com>

* bun format

* specify the type

* minor linting update again

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 14:58:59 -07:00

495 lines
15 KiB
TypeScript

import {
checkContainsTrigger,
escapeRegExp,
} from "../src/github/validation/trigger";
import { describe, it, expect } from "bun:test";
import {
createMockContext,
mockIssueAssignedContext,
mockIssueLabeledContext,
mockIssueCommentContext,
mockIssueOpenedContext,
mockPullRequestReviewContext,
mockPullRequestReviewCommentContext,
} from "./mockContext";
import type {
IssueCommentEvent,
IssuesAssignedEvent,
IssuesEvent,
PullRequestEvent,
PullRequestReviewEvent,
} from "@octokit/webhooks-types";
import type { ParsedGitHubContext } from "../src/github/context";
describe("checkContainsTrigger", () => {
describe("direct prompt trigger", () => {
it("should return true when direct prompt is provided", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
inputs: {
mode: "tag",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "Fix the bug in the login form",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when direct prompt is empty", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
payload: {
action: "opened",
issue: {
number: 1,
title: "Test Issue",
body: "Test body without trigger",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as IssuesEvent,
inputs: {
mode: "tag",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("assignee trigger", () => {
it("should return true when issue is assigned to the trigger user", () => {
const context = mockIssueAssignedContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should add @ symbol from assignee trigger", () => {
const context = {
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "claude-bot",
},
};
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when issue is assigned to a different user", () => {
const context = {
...mockIssueAssignedContext,
payload: {
...mockIssueAssignedContext.payload,
assignee: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent)
.assignee,
login: "otherUser",
},
issue: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
assignee: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue
.assignee,
login: "otherUser",
},
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("label trigger", () => {
it("should return true when issue is labeled with the trigger label", () => {
const context = mockIssueLabeledContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when issue is labeled with a different label", () => {
const context = {
...mockIssueLabeledContext,
payload: {
...mockIssueLabeledContext.payload,
label: {
...(mockIssueLabeledContext.payload as any).label,
name: "bug",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should return false for non-labeled events", () => {
const context = {
...mockIssueLabeledContext,
eventAction: "opened",
payload: {
...mockIssueLabeledContext.payload,
action: "opened",
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("issue body and title trigger", () => {
it("should return true when issue body contains trigger phrase", () => {
const context = mockIssueOpenedContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true when issue title contains trigger phrase", () => {
const context = {
...mockIssueOpenedContext,
payload: {
...mockIssueOpenedContext.payload,
issue: {
...(mockIssueOpenedContext.payload as IssuesEvent).issue,
title: "/claude Fix the login bug",
body: "The login page is broken",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should handle trigger phrase with punctuation", () => {
const baseContext = {
...mockIssueOpenedContext,
inputs: {
...mockIssueOpenedContext.inputs,
triggerPhrase: "@claude",
},
};
// Test various punctuation marks
const testCases = [
{ issueBody: "@claude, can you help?", expected: true },
{ issueBody: "@claude. Please look at this", expected: true },
{ issueBody: "@claude! This is urgent", expected: true },
{ issueBody: "@claude? What do you think?", expected: true },
{ issueBody: "@claude: here's the issue", expected: true },
{ issueBody: "@claude; and another thing", expected: true },
{ issueBody: "Hey @claude, can you help?", expected: true },
{ issueBody: "claudette contains claude", expected: false },
{ issueBody: "email@claude.com", expected: false },
];
testCases.forEach(({ issueBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
issue: {
...(baseContext.payload as IssuesEvent).issue,
body: issueBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
it("should return false when trigger phrase is part of another word", () => {
const context = {
...mockIssueOpenedContext,
payload: {
...mockIssueOpenedContext.payload,
issue: {
...(mockIssueOpenedContext.payload as IssuesEvent).issue,
body: "claudette helped me with this",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle trigger phrase in title with punctuation", () => {
const baseContext = {
...mockIssueOpenedContext,
inputs: {
...mockIssueOpenedContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ issueTitle: "@claude, can you help?", expected: true },
{ issueTitle: "@claude: Fix this bug", expected: true },
{ issueTitle: "Bug: @claude please review", expected: true },
{ issueTitle: "email@claude.com issue", expected: false },
{ issueTitle: "claudette needs help", expected: false },
];
testCases.forEach(({ issueTitle, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
issue: {
...(baseContext.payload as IssuesEvent).issue,
title: issueTitle,
body: "No trigger in body",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
});
describe("pull request body and title trigger", () => {
it("should return true when PR body contains trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "Test PR",
body: "@claude can you review this?",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true when PR title contains trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "@claude Review this PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when PR body doesn't contain trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("comment trigger", () => {
it("should return true for issue_comment with trigger phrase", () => {
const context = mockIssueCommentContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review_comment with trigger phrase", () => {
const context = mockPullRequestReviewCommentContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review with submitted action and trigger phrase", () => {
const context = mockPullRequestReviewContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review with edited action and trigger phrase", () => {
const context = {
...mockPullRequestReviewContext,
eventAction: "edited",
payload: {
...mockPullRequestReviewContext.payload,
action: "edited",
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false for pull_request_review with different action", () => {
const context = {
...mockPullRequestReviewContext,
eventAction: "dismissed",
payload: {
...mockPullRequestReviewContext.payload,
action: "dismissed",
review: {
...(mockPullRequestReviewContext.payload as PullRequestReviewEvent)
.review,
body: "/claude please review this PR",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle pull_request_review with punctuation", () => {
const baseContext = {
...mockPullRequestReviewContext,
inputs: {
...mockPullRequestReviewContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ commentBody: "@claude, please review", expected: true },
{ commentBody: "@claude. fix this", expected: true },
{ commentBody: "@claude!", expected: true },
{ commentBody: "claude@example.com", expected: false },
{ commentBody: "claudette", expected: false },
];
testCases.forEach(({ commentBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
review: {
...(baseContext.payload as PullRequestReviewEvent).review,
body: commentBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
it("should handle comment trigger with punctuation", () => {
const baseContext = {
...mockIssueCommentContext,
inputs: {
...mockIssueCommentContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ commentBody: "@claude, please review", expected: true },
{ commentBody: "@claude. fix this", expected: true },
{ commentBody: "@claude!", expected: true },
{ commentBody: "claude@example.com", expected: false },
{ commentBody: "claudette", expected: false },
];
testCases.forEach(({ commentBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
comment: {
...(baseContext.payload as IssueCommentEvent).comment,
body: commentBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
});
});
describe("escapeRegExp", () => {
it("should escape special regex characters", () => {
expect(escapeRegExp(".*+?^${}()|[]\\")).toBe(
"\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
);
});
it("should not escape regular characters", () => {
expect(escapeRegExp("abc123")).toBe("abc123");
});
it("should handle mixed characters", () => {
expect(escapeRegExp("hello.world")).toBe("hello\\.world");
expect(escapeRegExp("test[123]")).toBe("test\\[123\\]");
});
});