diff --git a/README.md b/README.md index 057b34b..af38239 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yml b/action.yml index 6b7e228..ee36b2b 100644 --- a/action.yml +++ b/action.yml @@ -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 }} diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index eece07f..316fd9d 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -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 = { + 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, diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index 218eb65..e7a7130 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -7,6 +7,7 @@ export type CommonFields = { allowedTools?: string; disallowedTools?: string; directPrompt?: string; + overridePrompt?: string; }; type PullRequestReviewCommentEvent = { diff --git a/src/github/context.ts b/src/github/context.ts index c156b54..66b2582 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -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", diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index de6c7ba..b7af7e7 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -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("ISSUE_CREATED"); + }); + test("should include trigger username when provided", () => { const envVars: PreparedContext = { repository: "owner/repo", diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 3f14a6e..7d0239c 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -31,6 +31,7 @@ describe("prepareMcpConfig", () => { disallowedTools: [], customInstructions: "", directPrompt: "", + overridePrompt: "", branchPrefix: "", useStickyComment: false, additionalPermissions: new Map(), diff --git a/test/mockContext.ts b/test/mockContext.ts index d035afc..2cdd713 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -16,6 +16,7 @@ const defaultInputs = { disallowedTools: [] as string[], customInstructions: "", directPrompt: "", + overridePrompt: "", useBedrock: false, useVertex: false, timeoutMinutes: 30, diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 7471acb..868f6c0 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -67,6 +67,7 @@ describe("checkWritePermissions", () => { disallowedTools: [], customInstructions: "", directPrompt: "", + overridePrompt: "", branchPrefix: "claude/", useStickyComment: false, additionalPermissions: new Map(), diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index eaaf834..9f1471c 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -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: "",