mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
* 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>
495 lines
15 KiB
TypeScript
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\\]");
|
|
});
|
|
});
|