Add override prompt variable (#301)

* Add override prompt variable

* create test

* Fix typechecks

* remove use of `any` for additional type-safety

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
This commit is contained in:
km-anthropic
2025-07-21 17:41:25 -07:00
committed by GitHub
parent 0d8a8fe1ac
commit 8f551b358e
10 changed files with 256 additions and 0 deletions

View File

@@ -170,6 +170,7 @@ jobs:
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
| `override_prompt` | Complete replacement of Claude's prompt with custom template (supports variable substitution) | No | - |
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
@@ -395,6 +396,36 @@ jobs:
Perfect for automatically reviewing PRs from new team members, external contributors, or specific developers who need extra guidance.
#### Custom Prompt Templates
Use `override_prompt` for complete control over Claude's behavior with variable substitution:
```yaml
- uses: anthropics/claude-code-action@beta
with:
override_prompt: |
Analyze PR #$PR_NUMBER in $REPOSITORY for security vulnerabilities.
Changed files:
$CHANGED_FILES
Focus on:
- SQL injection risks
- XSS vulnerabilities
- Authentication bypasses
- Exposed secrets or credentials
Provide severity ratings (Critical/High/Medium/Low) for any issues found.
```
The `override_prompt` feature supports these variables:
- `$REPOSITORY`, `$PR_NUMBER`, `$ISSUE_NUMBER`
- `$PR_TITLE`, `$ISSUE_TITLE`, `$PR_BODY`, `$ISSUE_BODY`
- `$PR_COMMENTS`, `$ISSUE_COMMENTS`, `$REVIEW_COMMENTS`
- `$CHANGED_FILES`, `$TRIGGER_COMMENT`, `$TRIGGER_USERNAME`
- `$BRANCH_NAME`, `$BASE_BRANCH`, `$EVENT_TYPE`, `$IS_PR`
## How It Works
1. **Trigger Detection**: Listens for comments containing the trigger phrase (default: `@claude`) or issue assignment to a specific user

View File

@@ -50,6 +50,10 @@ inputs:
description: "Direct instruction for Claude (bypasses normal trigger detection)"
required: false
default: ""
override_prompt:
description: "Complete replacement of Claude's prompt with custom template (supports variable substitution)"
required: false
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
additional_permissions:
@@ -142,6 +146,7 @@ runs:
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
MCP_CONFIG: ${{ inputs.mcp_config }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
GITHUB_RUN_ID: ${{ github.run_id }}

View File

@@ -120,6 +120,7 @@ export function prepareContext(
const allowedTools = context.inputs.allowedTools;
const disallowedTools = context.inputs.disallowedTools;
const directPrompt = context.inputs.directPrompt;
const overridePrompt = context.inputs.overridePrompt;
const isPR = context.isPR;
// Get PR/Issue number from entityNumber
@@ -158,6 +159,7 @@ export function prepareContext(
disallowedTools: disallowedTools.join(","),
}),
...(directPrompt && { directPrompt }),
...(overridePrompt && { overridePrompt }),
...(claudeBranch && { claudeBranch }),
};
@@ -460,11 +462,76 @@ function getCommitInstructions(
}
}
function substitutePromptVariables(
template: string,
context: PreparedContext,
githubData: FetchDataResult,
): string {
const { contextData, comments, reviewData, changedFilesWithSHA } = githubData;
const { eventData } = context;
const variables: Record<string, string> = {
REPOSITORY: context.repository,
PR_NUMBER:
eventData.isPR && "prNumber" in eventData ? eventData.prNumber : "",
ISSUE_NUMBER:
!eventData.isPR && "issueNumber" in eventData
? eventData.issueNumber
: "",
PR_TITLE: eventData.isPR && contextData?.title ? contextData.title : "",
ISSUE_TITLE: !eventData.isPR && contextData?.title ? contextData.title : "",
PR_BODY: eventData.isPR && contextData?.body ? contextData.body : "",
ISSUE_BODY: !eventData.isPR && contextData?.body ? contextData.body : "",
PR_COMMENTS: eventData.isPR
? formatComments(comments, githubData.imageUrlMap)
: "",
ISSUE_COMMENTS: !eventData.isPR
? formatComments(comments, githubData.imageUrlMap)
: "",
REVIEW_COMMENTS: eventData.isPR
? formatReviewComments(reviewData, githubData.imageUrlMap)
: "",
CHANGED_FILES: eventData.isPR
? formatChangedFilesWithSHA(changedFilesWithSHA)
: "",
TRIGGER_COMMENT: "commentBody" in eventData ? eventData.commentBody : "",
TRIGGER_USERNAME: context.triggerUsername || "",
BRANCH_NAME:
"claudeBranch" in eventData && eventData.claudeBranch
? eventData.claudeBranch
: "baseBranch" in eventData && eventData.baseBranch
? eventData.baseBranch
: "",
BASE_BRANCH:
"baseBranch" in eventData && eventData.baseBranch
? eventData.baseBranch
: "",
EVENT_TYPE: eventData.eventName,
IS_PR: eventData.isPR ? "true" : "false",
};
let result = template;
for (const [key, value] of Object.entries(variables)) {
const regex = new RegExp(`\\$${key}`, "g");
result = result.replace(regex, value);
}
return result;
}
export function generatePrompt(
context: PreparedContext,
githubData: FetchDataResult,
useCommitSigning: boolean,
): string {
if (context.overridePrompt) {
return substitutePromptVariables(
context.overridePrompt,
context,
githubData,
);
}
const {
contextData,
comments,

View File

@@ -7,6 +7,7 @@ export type CommonFields = {
allowedTools?: string;
disallowedTools?: string;
directPrompt?: string;
overridePrompt?: string;
};
type PullRequestReviewCommentEvent = {

View File

@@ -34,6 +34,7 @@ export type ParsedGitHubContext = {
disallowedTools: string[];
customInstructions: string;
directPrompt: string;
overridePrompt: string;
baseBranch?: string;
branchPrefix: string;
useStickyComment: boolean;
@@ -63,6 +64,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
directPrompt: process.env.DIRECT_PROMPT ?? "",
overridePrompt: process.env.OVERRIDE_PROMPT ?? "",
baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true",

View File

@@ -322,6 +322,148 @@ describe("generatePrompt", () => {
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
});
test("should use override_prompt when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt: "Simple prompt for $REPOSITORY PR #$PR_NUMBER",
eventData: {
eventName: "pull_request",
eventAction: "opened",
isPR: true,
prNumber: "123",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toBe("Simple prompt for owner/repo PR #123");
expect(prompt).not.toContain("You are Claude, an AI assistant");
});
test("should substitute all variables in override_prompt", () => {
const envVars: PreparedContext = {
repository: "test/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
triggerUsername: "john-doe",
overridePrompt: `Repository: $REPOSITORY
PR: $PR_NUMBER
Title: $PR_TITLE
Body: $PR_BODY
Comments: $PR_COMMENTS
Review Comments: $REVIEW_COMMENTS
Changed Files: $CHANGED_FILES
Trigger Comment: $TRIGGER_COMMENT
Username: $TRIGGER_USERNAME
Branch: $BRANCH_NAME
Base: $BASE_BRANCH
Event: $EVENT_TYPE
Is PR: $IS_PR`,
eventData: {
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "456",
commentBody: "Please review this code",
claudeBranch: "feature-branch",
baseBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("Repository: test/repo");
expect(prompt).toContain("PR: 456");
expect(prompt).toContain("Title: Test PR");
expect(prompt).toContain("Body: This is a test PR");
expect(prompt).toContain("Comments: ");
expect(prompt).toContain("Review Comments: ");
expect(prompt).toContain("Changed Files: ");
expect(prompt).toContain("Trigger Comment: Please review this code");
expect(prompt).toContain("Username: john-doe");
expect(prompt).toContain("Branch: feature-branch");
expect(prompt).toContain("Base: main");
expect(prompt).toContain("Event: pull_request_review_comment");
expect(prompt).toContain("Is PR: true");
});
test("should handle override_prompt for issues", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt: "Issue #$ISSUE_NUMBER: $ISSUE_TITLE in $REPOSITORY",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "789",
baseBranch: "main",
claudeBranch: "claude/issue-789-20240101-1200",
},
};
const issueGitHubData = {
...mockGitHubData,
contextData: {
title: "Bug: Login form broken",
body: "The login form is not working",
author: { login: "testuser" },
state: "OPEN",
createdAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],
},
},
};
const prompt = generatePrompt(envVars, issueGitHubData, false);
expect(prompt).toBe("Issue #789: Bug: Login form broken in owner/repo");
});
test("should handle empty values in override_prompt substitution", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt:
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
eventData: {
eventName: "pull_request",
eventAction: "opened",
isPR: true,
prNumber: "123",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toBe("PR: 123, Issue: , Comment: ");
});
test("should not substitute variables when override_prompt is not provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "123",
baseBranch: "main",
claudeBranch: "claude/issue-123-20240101-1200",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
});
test("should include trigger username when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",

View File

@@ -31,6 +31,7 @@ describe("prepareMcpConfig", () => {
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "",
useStickyComment: false,
additionalPermissions: new Map(),

View File

@@ -16,6 +16,7 @@ const defaultInputs = {
disallowedTools: [] as string[],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
useBedrock: false,
useVertex: false,
timeoutMinutes: 30,

View File

@@ -67,6 +67,7 @@ describe("checkWritePermissions", () => {
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),

View File

@@ -32,6 +32,7 @@ describe("checkContainsTrigger", () => {
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "Fix the bug in the login form",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
@@ -63,6 +64,7 @@ describe("checkContainsTrigger", () => {
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
@@ -278,6 +280,7 @@ describe("checkContainsTrigger", () => {
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
@@ -310,6 +313,7 @@ describe("checkContainsTrigger", () => {
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
@@ -342,6 +346,7 @@ describe("checkContainsTrigger", () => {
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",