Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ However, tools from these servers still need to be explicitly allowed via `claud

Check the GitHub Action log for Claude's run for the full execution trace.

### Why can't I trigger Claude with `@claude-mention` or `claude!`?
### Why can't I trigger Claude with `@claude-mention` or `claude@mention`?

The trigger uses word boundaries, so `@claude` must be a complete word. Variations like `@claude-bot`, `@claude!`, or `claude@mention` won't work unless you customize the `trigger_phrase`.
The trigger uses word boundaries, so `@claude` must not be embedded inside a word. Variations like `@claude-bot` or `claude@mention` won't work unless you customize the `trigger_phrase`. However, any non-word character around the trigger phrase is supported—`@claude!`, `(@claude)`, `"@claude"`, `>@claude`, `cc:@claude`, and `` `@claude` `` will all trigger correctly. The matching is also case-insensitive, so `@Claude` and `@CLAUDE` work too.

### How can I use custom executables in specialized environments?

Expand Down
35 changes: 18 additions & 17 deletions src/github/validation/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ import {
} from "../context";
import type { ParsedGitHubContext } from "../context";

/**
* Build a regex that matches the trigger phrase only when it appears as a
* standalone token — i.e. not embedded inside a word like "email@claude.com"
* or a hyphenated username like "@claude-bot".
*
* Uses negative lookbehind/lookahead for word characters and hyphens so that
* any other character (punctuation, brackets, quotes, etc.) is accepted as a
* boundary without needing an explicit allowlist.
*/
function buildTriggerRegex(triggerPhrase: string): RegExp {
return new RegExp(`(?<![\\w-])${escapeRegExp(triggerPhrase)}(?![\\w-])`, "i");
}

export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
const {
inputs: { assigneeTrigger, labelTrigger, triggerPhrase, prompt },
Expand All @@ -24,7 +37,7 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {

// Check for assignee trigger
if (isIssuesAssignedEvent(context)) {
// Remove @ symbol from assignee_trigger if present
// Remove @ symbol from assignee trigger if present
let triggerUser = assigneeTrigger.replace(/^@/, "");
const assigneeUsername = context.payload.assignee?.login || "";

Expand All @@ -48,10 +61,7 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
if (isIssuesEvent(context) && context.eventAction === "opened") {
const issueBody = context.payload.issue.body || "";
const issueTitle = context.payload.issue.title || "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
const regex = buildTriggerRegex(triggerPhrase);

// Check in body
if (regex.test(issueBody)) {
Expand All @@ -74,10 +84,7 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
if (isPullRequestEvent(context)) {
const prBody = context.payload.pull_request.body || "";
const prTitle = context.payload.pull_request.title || "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
const regex = buildTriggerRegex(triggerPhrase);

// Check in body
if (regex.test(prBody)) {
Expand All @@ -102,10 +109,7 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
(context.eventAction === "submitted" || context.eventAction === "edited")
) {
const reviewBody = context.payload.review.body || "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
const regex = buildTriggerRegex(triggerPhrase);
if (regex.test(reviewBody)) {
console.log(
`Pull request review contains exact trigger phrase '${triggerPhrase}'`,
Expand All @@ -122,10 +126,7 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
const commentBody = isIssueCommentEvent(context)
? context.payload.comment.body
: context.payload.comment.body;
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
const regex = buildTriggerRegex(triggerPhrase);
if (regex.test(commentBody)) {
console.log(`Comment contains exact trigger phrase '${triggerPhrase}'`);
return true;
Expand Down
47 changes: 47 additions & 0 deletions test/trigger-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,51 @@ describe("checkContainsTrigger", () => {
{ issueBody: "@claude: here's the issue", expected: true },
{ issueBody: "@claude; and another thing", expected: true },
{ issueBody: "Hey @claude, can you help?", expected: true },
{ issueBody: "(@claude) can you check?", expected: true },
{ issueBody: '"@claude can you check?"', expected: true },
{ issueBody: ">@claude can you check?", expected: true },
{ issueBody: "'@claude can you check?'", expected: true },
{ issueBody: "/@claude can you check?", expected: true },
{ issueBody: "cc:@claude check this", expected: true },
{ issueBody: "`@claude` can you check?", expected: true },
{ issueBody: "[@claude](https://example.com)", expected: true },
{ issueBody: "**@claude** please help", expected: true },
{ issueBody: "first line\n@claude help", expected: true },
{ issueBody: "claudette contains claude", expected: false },
{ issueBody: "email@claude.com", expected: false },
{ issueBody: "user@claude.com", expected: false },
{ issueBody: "@claude-bot helped me", expected: false },
{ issueBody: "@claude-mention won't work", 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 match trigger phrase case-insensitively", () => {
const baseContext = {
...mockIssueOpenedContext,
inputs: {
...mockIssueOpenedContext.inputs,
triggerPhrase: "@claude",
},
};

const testCases = [
{ issueBody: "@Claude help", expected: true },
{ issueBody: "@CLAUDE help", expected: true },
{ issueBody: "@cLaUdE help", expected: true },
];

testCases.forEach(({ issueBody, expected }) => {
Expand Down Expand Up @@ -230,6 +273,10 @@ describe("checkContainsTrigger", () => {
{ issueTitle: "@claude, can you help?", expected: true },
{ issueTitle: "@claude: Fix this bug", expected: true },
{ issueTitle: "Bug: @claude please review", expected: true },
{ issueTitle: "(@claude) Fix this bug", expected: true },
{ issueTitle: "@Claude: Fix this bug", expected: true },
{ issueTitle: "cc:@claude Fix this", expected: true },
{ issueTitle: "`@claude` Fix this bug", expected: true },
{ issueTitle: "email@claude.com issue", expected: false },
{ issueTitle: "claudette needs help", expected: false },
];
Expand Down