From bd70a3ef2b0ceb6f444361c541cd24082733515b Mon Sep 17 00:00:00 2001 From: Vibhor Agrawal Date: Mon, 22 Sep 2025 21:50:27 +0530 Subject: [PATCH] fix: add support for pull_request_target event in GitHub Actions workflows (#579) Add pull_request_target event support to enable Claude Code usage with forked repositories while maintaining proper security boundaries. This resolves issues with dependabot PRs and external contributions that require write permissions. Changes: - Add pull_request_target to supported GitHub events in context parsing - Update type definitions to include PullRequestTargetEvent - Modify IS_PR calculation to detect pull_request_target as PR context - Add comprehensive test coverage for pull_request_target workflows - Update documentation to reflect pull_request_target support The pull_request_target event provides the same payload structure as pull_request but runs with write permissions from the base repository, making it ideal for secure automation of external contributions. Fixes #347 --- action.yml | 2 +- docs/custom-automations.md | 2 +- src/create-prompt/index.ts | 1 + src/create-prompt/types.ts | 14 +- src/github/context.ts | 3 +- test/pull-request-target.test.ts | 504 +++++++++++++++++++++++++++++++ 6 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 test/pull-request-target.test.ts diff --git a/action.yml b/action.yml index 6922364..e799909 100644 --- a/action.yml +++ b/action.yml @@ -259,7 +259,7 @@ runs: GITHUB_EVENT_NAME: ${{ github.event_name }} TRIGGER_COMMENT_ID: ${{ github.event.comment.id }} CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} - IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} + IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }} BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }} diff --git a/docs/custom-automations.md b/docs/custom-automations.md index 47fe9d7..fabb52f 100644 --- a/docs/custom-automations.md +++ b/docs/custom-automations.md @@ -15,7 +15,7 @@ The action automatically detects which mode to use based on your configuration: This action supports the following GitHub events ([learn more GitHub event triggers](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)): -- `pull_request` - When PRs are opened or synchronized +- `pull_request` or `pull_request_target` - When PRs are opened or synchronized - `issue_comment` - When comments are created on issues or PRs - `pull_request_comment` - When comments are made on PR diffs - `issues` - When issues are opened or assigned diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index ee4f912..8f4f4e9 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -384,6 +384,7 @@ export function getEventTypeAndContext(envVars: PreparedContext): { }; case "pull_request": + case "pull_request_target": return { eventType: "PULL_REQUEST", triggerContext: eventData.eventAction diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index bfbe7d4..9b7d81f 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -78,8 +78,7 @@ type IssueLabeledEvent = { labelTrigger: string; }; -type PullRequestEvent = { - eventName: "pull_request"; +type PullRequestBaseEvent = { eventAction?: string; // opened, synchronize, etc. isPR: true; prNumber: string; @@ -87,6 +86,14 @@ type PullRequestEvent = { baseBranch?: string; }; +type PullRequestEvent = PullRequestBaseEvent & { + eventName: "pull_request"; +}; + +type PullRequestTargetEvent = PullRequestBaseEvent & { + eventName: "pull_request_target"; +}; + // Union type for all possible event types export type EventData = | PullRequestReviewCommentEvent @@ -96,7 +103,8 @@ export type EventData = | IssueOpenedEvent | IssueAssignedEvent | IssueLabeledEvent - | PullRequestEvent; + | PullRequestEvent + | PullRequestTargetEvent; // Combined type with separate eventData field export type PreparedContext = CommonFields & { diff --git a/src/github/context.ts b/src/github/context.ts index 56a9233..92f272c 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -174,7 +174,8 @@ export function parseGitHubContext(): GitHubContext { isPR: Boolean(payload.issue.pull_request), }; } - case "pull_request": { + case "pull_request": + case "pull_request_target": { const payload = context.payload as PullRequestEvent; return { ...commonFields, diff --git a/test/pull-request-target.test.ts b/test/pull-request-target.test.ts new file mode 100644 index 0000000..de0fe62 --- /dev/null +++ b/test/pull-request-target.test.ts @@ -0,0 +1,504 @@ +#!/usr/bin/env bun + +import { describe, test, expect } from "bun:test"; +import { + getEventTypeAndContext, + generatePrompt, + generateDefaultPrompt, +} from "../src/create-prompt"; +import type { PreparedContext } from "../src/create-prompt"; +import type { Mode } from "../src/modes/types"; + +describe("pull_request_target event support", () => { + // Mock tag mode for testing + const mockTagMode: Mode = { + name: "tag", + description: "Tag mode", + shouldTrigger: () => true, + prepareContext: (context) => ({ mode: "tag", githubContext: context }), + getAllowedTools: () => [], + getDisallowedTools: () => [], + shouldCreateTrackingComment: () => true, + generatePrompt: (context, githubData, useCommitSigning) => + generateDefaultPrompt(context, githubData, useCommitSigning), + prepare: async () => ({ + commentId: 123, + branchInfo: { + baseBranch: "main", + currentBranch: "main", + claudeBranch: undefined, + }, + mcpConfig: "{}", + }), + }; + + const mockGitHubData = { + contextData: { + title: "External PR via pull_request_target", + body: "This PR comes from a forked repository", + author: { login: "external-contributor" }, + state: "OPEN", + createdAt: "2023-01-01T00:00:00Z", + additions: 25, + deletions: 3, + baseRefName: "main", + headRefName: "feature-branch", + headRefOid: "abc123", + commits: { + totalCount: 2, + nodes: [ + { + commit: { + oid: "commit1", + message: "Initial feature implementation", + author: { + name: "External Dev", + email: "external@example.com", + }, + }, + }, + { + commit: { + oid: "commit2", + message: "Fix typos and formatting", + author: { + name: "External Dev", + email: "external@example.com", + }, + }, + }, + ], + }, + files: { + nodes: [ + { + path: "src/feature.ts", + additions: 20, + deletions: 2, + changeType: "MODIFIED", + }, + { + path: "tests/feature.test.ts", + additions: 5, + deletions: 1, + changeType: "ADDED", + }, + ], + }, + comments: { nodes: [] }, + reviews: { nodes: [] }, + }, + comments: [], + changedFiles: [], + changedFilesWithSHA: [ + { + path: "src/feature.ts", + additions: 20, + deletions: 2, + changeType: "MODIFIED", + sha: "abc123", + }, + { + path: "tests/feature.test.ts", + additions: 5, + deletions: 1, + changeType: "ADDED", + sha: "abc123", + }, + ], + reviewData: { nodes: [] }, + imageUrlMap: new Map(), + }; + + describe("prompt generation for pull_request_target", () => { + test("should generate correct prompt for pull_request_target event", () => { + const envVars: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "opened", + isPR: true, + prNumber: "123", + }, + }; + + const prompt = generatePrompt( + envVars, + mockGitHubData, + false, + mockTagMode, + ); + + // Should contain pull request event type and metadata + expect(prompt).toContain("PULL_REQUEST"); + expect(prompt).toContain("true"); + expect(prompt).toContain("123"); + expect(prompt).toContain( + "pull request opened", + ); + + // Should contain PR-specific information + expect(prompt).toContain( + "- src/feature.ts (MODIFIED) +20/-2 SHA: abc123", + ); + expect(prompt).toContain( + "- tests/feature.test.ts (ADDED) +5/-1 SHA: abc123", + ); + expect(prompt).toContain("external-contributor"); + expect(prompt).toContain("owner/repo"); + }); + + test("should handle pull_request_target with commit signing disabled", () => { + const envVars: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "synchronize", + isPR: true, + prNumber: "456", + }, + }; + + const prompt = generatePrompt( + envVars, + mockGitHubData, + false, + mockTagMode, + ); + + // Should include git commands for non-commit-signing mode + expect(prompt).toContain("git push"); + expect(prompt).toContain( + "Always push to the existing branch when triggered on a PR", + ); + expect(prompt).toContain("mcp__github_comment__update_claude_comment"); + + // Should not include commit signing tools + expect(prompt).not.toContain("mcp__github_file_ops__commit_files"); + }); + + test("should handle pull_request_target with commit signing enabled", () => { + const envVars: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "synchronize", + isPR: true, + prNumber: "456", + }, + }; + + const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode); + + // Should include commit signing tools + expect(prompt).toContain("mcp__github_file_ops__commit_files"); + expect(prompt).toContain("mcp__github_file_ops__delete_files"); + expect(prompt).toContain("mcp__github_comment__update_claude_comment"); + + // Should not include git command instructions + expect(prompt).not.toContain("Use git commands via the Bash tool"); + }); + + test("should treat pull_request_target same as pull_request in prompt generation", () => { + const baseContext: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "opened", + isPR: true, + prNumber: "123", + }, + }; + + // Generate prompt for pull_request + const pullRequestContext: PreparedContext = { + ...baseContext, + eventData: { + ...baseContext.eventData, + eventName: "pull_request", + isPR: true, + prNumber: "123", + }, + }; + + // Generate prompt for pull_request_target + const pullRequestTargetContext: PreparedContext = { + ...baseContext, + eventData: { + ...baseContext.eventData, + eventName: "pull_request_target", + isPR: true, + prNumber: "123", + }, + }; + + const pullRequestPrompt = generatePrompt( + pullRequestContext, + mockGitHubData, + false, + mockTagMode, + ); + const pullRequestTargetPrompt = generatePrompt( + pullRequestTargetContext, + mockGitHubData, + false, + mockTagMode, + ); + + // Both should have the same event type and structure + expect(pullRequestPrompt).toContain( + "PULL_REQUEST", + ); + expect(pullRequestTargetPrompt).toContain( + "PULL_REQUEST", + ); + + expect(pullRequestPrompt).toContain( + "pull request opened", + ); + expect(pullRequestTargetPrompt).toContain( + "pull request opened", + ); + + // Both should contain PR-specific instructions + expect(pullRequestPrompt).toContain( + "Always push to the existing branch when triggered on a PR", + ); + expect(pullRequestTargetPrompt).toContain( + "Always push to the existing branch when triggered on a PR", + ); + }); + + test("should handle pull_request_target in agent mode with custom prompt", () => { + const envVars: PreparedContext = { + repository: "test/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + prompt: "Review this pull_request_target PR for security issues", + eventData: { + eventName: "pull_request_target", + eventAction: "opened", + isPR: true, + prNumber: "789", + }, + }; + + // Use agent mode which passes through the prompt as-is + const mockAgentMode: Mode = { + name: "agent", + description: "Agent mode", + shouldTrigger: () => true, + prepareContext: (context) => ({ + mode: "agent", + githubContext: context, + }), + getAllowedTools: () => [], + getDisallowedTools: () => [], + shouldCreateTrackingComment: () => true, + generatePrompt: (context) => context.prompt || "default prompt", + prepare: async () => ({ + commentId: 123, + branchInfo: { + baseBranch: "main", + currentBranch: "main", + claudeBranch: undefined, + }, + mcpConfig: "{}", + }), + }; + + const prompt = generatePrompt( + envVars, + mockGitHubData, + false, + mockAgentMode, + ); + + expect(prompt).toBe( + "Review this pull_request_target PR for security issues", + ); + }); + + test("should handle pull_request_target with no custom prompt", () => { + const envVars: PreparedContext = { + repository: "test/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "synchronize", + isPR: true, + prNumber: "456", + }, + }; + + const prompt = generatePrompt( + envVars, + mockGitHubData, + false, + mockTagMode, + ); + + // Should generate default prompt structure + expect(prompt).toContain("PULL_REQUEST"); + expect(prompt).toContain("456"); + expect(prompt).toContain( + "Always push to the existing branch when triggered on a PR", + ); + }); + }); + + describe("pull_request_target vs pull_request behavior consistency", () => { + test("should produce identical event processing for both event types", () => { + const baseEventData = { + eventAction: "opened", + isPR: true, + prNumber: "100", + }; + + const pullRequestEvent: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + ...baseEventData, + eventName: "pull_request", + isPR: true, + prNumber: "100", + }, + }; + + const pullRequestTargetEvent: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + ...baseEventData, + eventName: "pull_request_target", + isPR: true, + prNumber: "100", + }, + }; + + // Both should have identical event type detection + const prResult = getEventTypeAndContext(pullRequestEvent); + const prtResult = getEventTypeAndContext(pullRequestTargetEvent); + + expect(prResult.eventType).toBe(prtResult.eventType); + expect(prResult.triggerContext).toBe(prtResult.triggerContext); + }); + + test("should handle edge cases in pull_request_target events", () => { + // Test with minimal event data + const minimalContext: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + isPR: true, + prNumber: "1", + }, + }; + + const result = getEventTypeAndContext(minimalContext); + expect(result.eventType).toBe("PULL_REQUEST"); + expect(result.triggerContext).toBe("pull request event"); + + // Should not throw when generating prompt + expect(() => { + generatePrompt(minimalContext, mockGitHubData, false, mockTagMode); + }).not.toThrow(); + }); + + test("should handle all valid pull_request_target actions", () => { + const actions = ["opened", "synchronize", "reopened", "closed", "edited"]; + + actions.forEach((action) => { + const context: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: action, + isPR: true, + prNumber: "1", + }, + }; + + const result = getEventTypeAndContext(context); + expect(result.eventType).toBe("PULL_REQUEST"); + expect(result.triggerContext).toBe(`pull request ${action}`); + }); + }); + }); + + describe("security considerations for pull_request_target", () => { + test("should maintain same prompt structure regardless of event source", () => { + // Test that external PRs don't get different treatment in prompts + const internalPR: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request", + eventAction: "opened", + isPR: true, + prNumber: "1", + }, + }; + + const externalPR: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "pull_request_target", + eventAction: "opened", + isPR: true, + prNumber: "1", + }, + }; + + const internalPrompt = generatePrompt( + internalPR, + mockGitHubData, + false, + mockTagMode, + ); + const externalPrompt = generatePrompt( + externalPR, + mockGitHubData, + false, + mockTagMode, + ); + + // Should have same tool access patterns + expect( + internalPrompt.includes("mcp__github_comment__update_claude_comment"), + ).toBe( + externalPrompt.includes("mcp__github_comment__update_claude_comment"), + ); + + // Should have same branch handling instructions + expect( + internalPrompt.includes( + "Always push to the existing branch when triggered on a PR", + ), + ).toBe( + externalPrompt.includes( + "Always push to the existing branch when triggered on a PR", + ), + ); + }); + }); +});