From 618565bc0e02678d2f88851d27c4c3f7a8229f7d Mon Sep 17 00:00:00 2001 From: Matthew Burke Date: Mon, 4 Aug 2025 11:00:22 -0500 Subject: [PATCH 01/11] Update documentation incorrectly reverted after refactor (#399) --- docs/experimental.md | 29 ++++++++++++++++++--- docs/faq.md | 8 ++++++ docs/usage.md | 60 ++++++++++++++++++++++---------------------- 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/docs/experimental.md b/docs/experimental.md index d5c125596..f5938818f 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -4,7 +4,7 @@ ## Execution Modes -The action supports two execution modes, each optimized for different use cases: +The action supports three execution modes, each optimized for different use cases: ### Tag Mode (Default) @@ -23,9 +23,11 @@ The traditional implementation mode that responds to @claude mentions, issue ass ### Agent Mode -For automation and scheduled tasks without trigger checking. +**Note: Agent mode is currently in active development and may undergo breaking changes.** -- **Triggers**: Always runs (no trigger checking) +For automation with workflow_dispatch and scheduled events only. + +- **Triggers**: Only works with `workflow_dispatch` and `schedule` events - does NOT work with PR/issue events - **Features**: Perfect for scheduled tasks, works with `override_prompt` - **Use case**: Maintenance tasks, automated reporting, scheduled checks @@ -38,7 +40,26 @@ For automation and scheduled tasks without trigger checking. Check for outdated dependencies and create an issue if any are found. ``` -See [`examples/claude-modes.yml`](../examples/claude-modes.yml) for complete examples of each mode. +### Experimental Review Mode + +**Warning: This is an experimental feature that may change or be removed at any time.** + +For automated code reviews on pull requests. + +- **Triggers**: Pull request events (`opened`, `synchronize`) or `@claude review` comments +- **Features**: Provides detailed code reviews with inline comments and suggestions +- **Use case**: Automated PR reviews, code quality checks + +```yaml +- uses: anthropics/claude-code-action@beta + with: + mode: experimental-review + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + custom_instructions: | + Focus on code quality, security, and best practices. +``` + +See [`examples/claude-modes.yml`](../examples/claude-modes.yml) and [`examples/claude-experimental-review-mode.yml`](../examples/claude-experimental-review-mode.yml) for complete examples of each mode. ## Network Restrictions diff --git a/docs/faq.md b/docs/faq.md index c0da5072c..2f03b31a7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -135,6 +135,14 @@ allowed_tools: "Bash(npm:*),Bash(git:*)" # Allows only npm and git commands No, Claude's GitHub app token is sandboxed to the current repository only. It cannot push to any other repositories. It can, however, read public repositories, but to get access to this, you must configure it with tools to do so. +### Why aren't comments posted as claude[bot]? + +Comments appear as claude[bot] when the action uses its built-in authentication. However, if you provide a `github_token` in your workflow, the action will use that token's authentication instead, causing comments to appear under a different username. + +**Solution**: Remove `github_token` from your workflow file unless you're using a custom GitHub App. + +**Note**: The `use_sticky_comment` feature only works with claude[bot] authentication. If you're using a custom `github_token`, sticky comments won't update properly since they expect the claude[bot] username. + ## MCP Servers and Extended Functionality ### What MCP servers are available by default? diff --git a/docs/usage.md b/docs/usage.md index 0599dbdc5..0d8ed421e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -46,36 +46,36 @@ jobs: ## Inputs -| Input | Description | Required | Default | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -------- | --------- | -| `mode` | Execution mode: 'tag' (default - triggered by mentions/assignments), 'agent' (for automation with no trigger checking) | No | `tag` | -| `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` | -| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | -| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - | -| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | -| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - | -| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - | -| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` | -| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` | -| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" | -| `disallowed_tools` | Tools that Claude should never use | No | "" | -| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | -| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" | -| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | -| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | -| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | -| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | -| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" | -| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | -| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | -| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" | -| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` | +| Input | Description | Required | Default | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | +| `mode` | Execution mode: 'tag' (default - triggered by mentions/assignments), 'agent' (for automation), 'experimental-review' (for PR reviews) | No | `tag` | +| `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` | +| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | +| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - | +| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | +| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - | +| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - | +| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` | +| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` | +| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" | +| `disallowed_tools` | Tools that Claude should never use | No | "" | +| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | +| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" | +| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | +| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | +| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | +| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | +| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" | +| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | +| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | +| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" | +| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` | \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex) From b39377f9bcc6f88c9cd3e00e08f5423febff8dc5 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Mon, 4 Aug 2025 10:51:30 -0700 Subject: [PATCH 02/11] feat: add getSystemPrompt method to mode interface (#400) Allows modes to provide custom system prompts that are appended to Claude's base system prompt. This enables mode-specific instructions without modifying the core action logic. - Add optional getSystemPrompt method to Mode interface - Implement method in all existing modes (tag, agent, review) - Update prepare.ts to call getSystemPrompt and export as env var - Wire up APPEND_SYSTEM_PROMPT in action.yml to pass to base-action All modes currently return undefined (no additional prompts), but the infrastructure is now in place for future modes to provide custom instructions. --- action.yml | 2 +- src/entrypoints/prepare.ts | 13 +++++++++++++ src/modes/agent/index.ts | 5 +++++ src/modes/review/index.ts | 6 ++++++ src/modes/tag/index.ts | 5 +++++ src/modes/types.ts | 7 +++++++ 6 files changed, 37 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 0fd6567ee..8bde037ab 100644 --- a/action.yml +++ b/action.yml @@ -201,7 +201,7 @@ runs: INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }} INPUT_SETTINGS: ${{ inputs.settings }} INPUT_SYSTEM_PROMPT: "" - INPUT_APPEND_SYSTEM_PROMPT: "" + INPUT_APPEND_SYSTEM_PROMPT: ${{ env.APPEND_SYSTEM_PROMPT }} INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index 20373f2f1..b9995dfdd 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -81,6 +81,19 @@ async function run() { // Set the MCP config output core.setOutput("mcp_config", result.mcpConfig); + + // Step 6: Get system prompt from mode if available + if (mode.getSystemPrompt) { + const modeContext = mode.prepareContext(context, { + commentId: result.commentId, + baseBranch: result.branchInfo.baseBranch, + claudeBranch: result.branchInfo.claudeBranch, + }); + const systemPrompt = mode.getSystemPrompt(modeContext); + if (systemPrompt) { + core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt); + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); core.setFailed(`Prepare step failed with error: ${errorMessage}`); diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 94d247ce1..56f337f01 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -112,4 +112,9 @@ export const agentMode: Mode = { // Minimal fallback - repository is a string in PreparedContext return `Repository: ${context.repository}`; }, + + getSystemPrompt() { + // Agent mode doesn't need additional system prompts + return undefined; + }, }; diff --git a/src/modes/review/index.ts b/src/modes/review/index.ts index fdc2033a6..4213c1c6d 100644 --- a/src/modes/review/index.ts +++ b/src/modes/review/index.ts @@ -349,4 +349,10 @@ This ensures users get value from the review even before checking individual inl mcpConfig, }; }, + + getSystemPrompt() { + // Review mode doesn't need additional system prompts + // The review-specific instructions are included in the main prompt + return undefined; + }, }; diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 027682cde..f9aabafc5 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -130,4 +130,9 @@ export const tagMode: Mode = { ): string { return generateDefaultPrompt(context, githubData, useCommitSigning); }, + + getSystemPrompt() { + // Tag mode doesn't need additional system prompts + return undefined; + }, }; diff --git a/src/modes/types.ts b/src/modes/types.ts index a2344a917..f51f7fcc6 100644 --- a/src/modes/types.ts +++ b/src/modes/types.ts @@ -73,6 +73,13 @@ export type Mode = { * @returns PrepareResult with commentId, branchInfo, and mcpConfig */ prepare(options: ModeOptions): Promise; + + /** + * Returns an optional system prompt to append to Claude's base system prompt. + * This allows modes to add mode-specific instructions. + * @returns The system prompt string or undefined if no additional prompt is needed + */ + getSystemPrompt?(context: ModeContext): string | undefined; }; // Define types for mode prepare method From 284568588053c1bc93e8724f8d8bf9ea0b85079d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 4 Aug 2025 23:29:44 +0000 Subject: [PATCH 03/11] chore: bump Claude Code version to 1.0.68 --- action.yml | 2 +- base-action/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 8bde037ab..7b77fab66 100644 --- a/action.yml +++ b/action.yml @@ -172,7 +172,7 @@ runs: echo "Base-action dependencies installed" cd - # Install Claude Code globally - bun install -g @anthropic-ai/claude-code@1.0.67 + bun install -g @anthropic-ai/claude-code@1.0.68 - name: Setup Network Restrictions if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' diff --git a/base-action/action.yml b/base-action/action.yml index 8a5d28c71..a3aab8c25 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -118,7 +118,7 @@ runs: - name: Install Claude Code shell: bash - run: bun install -g @anthropic-ai/claude-code@1.0.67 + run: bun install -g @anthropic-ai/claude-code@1.0.68 - name: Run Claude Code Action shell: bash From 0c5d54472f57859665a75d5e3911e51e17fa58d4 Mon Sep 17 00:00:00 2001 From: atsushi-ishibashi Date: Tue, 5 Aug 2025 11:37:50 +0900 Subject: [PATCH 04/11] feat: Add HTML img tag support to GitHub image downloader (#402) * feat: support html img tag * rm files * refactor --- src/github/utils/image-downloader.ts | 20 ++- test/image-downloader.test.ts | 251 +++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/src/github/utils/image-downloader.ts b/src/github/utils/image-downloader.ts index 40cc9747f..1e819fff7 100644 --- a/src/github/utils/image-downloader.ts +++ b/src/github/utils/image-downloader.ts @@ -3,11 +3,17 @@ import path from "path"; import type { Octokits } from "../api/client"; import { GITHUB_SERVER_URL } from "../api/config"; +const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const IMAGE_REGEX = new RegExp( - `!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`, + `!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`, "g", ); +const HTML_IMG_REGEX = new RegExp( + `]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`, + "gi", +); + type IssueComment = { type: "issue_comment"; id: string; @@ -63,8 +69,16 @@ export async function downloadCommentImages( }> = []; for (const comment of comments) { - const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)]; - const urls = imageMatches.map((match) => match[1] as string); + // Extract URLs from Markdown format + const markdownMatches = [...comment.body.matchAll(IMAGE_REGEX)]; + const markdownUrls = markdownMatches.map((match) => match[1] as string); + + // Extract URLs from HTML format + const htmlMatches = [...comment.body.matchAll(HTML_IMG_REGEX)]; + const htmlUrls = htmlMatches.map((match) => match[1] as string); + + // Combine and deduplicate URLs + const urls = [...new Set([...markdownUrls, ...htmlUrls])]; if (urls.length > 0) { commentsWithImages.push({ comment, urls }); diff --git a/test/image-downloader.test.ts b/test/image-downloader.test.ts index 01f30fa2d..e00b6d05f 100644 --- a/test/image-downloader.test.ts +++ b/test/image-downloader.test.ts @@ -662,4 +662,255 @@ describe("downloadCommentImages", () => { ); expect(result.get(imageUrl2)).toBeUndefined(); }); + + test("should detect and download images from HTML img tags", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = + "https://github.com/user-attachments/assets/html-image.png"; + const signedUrl = + "https://private-user-images.githubusercontent.com/html.png?jwt=token"; + + // Mock octokit response + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + // Mock fetch for image download + const mockArrayBuffer = new ArrayBuffer(8); + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => mockArrayBuffer, + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "777", + body: `Here's an HTML image: test`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + comment_id: 777, + mediaType: { format: "full+json" }, + }); + + expect(fetchSpy).toHaveBeenCalledWith(signedUrl); + expect(fsWriteFileSpy).toHaveBeenCalledWith( + "/tmp/github-images/image-1704067200000-0.png", + Buffer.from(mockArrayBuffer), + ); + + expect(result.size).toBe(1); + expect(result.get(imageUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.png", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Found 1 image(s) in issue_comment 777", + ); + expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`); + expect(consoleLogSpy).toHaveBeenCalledWith( + "✓ Saved: /tmp/github-images/image-1704067200000-0.png", + ); + }); + + test("should handle HTML img tags with different quote styles", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl1 = + "https://github.com/user-attachments/assets/single-quote.jpg"; + const imageUrl2 = + "https://github.com/user-attachments/assets/double-quote.png"; + const signedUrl1 = + "https://private-user-images.githubusercontent.com/single.jpg?jwt=token1"; + const signedUrl2 = + "https://private-user-images.githubusercontent.com/double.png?jwt=token2"; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "888", + body: `Single quote: test and double quote: test`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.size).toBe(2); + expect(result.get(imageUrl1)).toBe( + "/tmp/github-images/image-1704067200000-0.jpg", + ); + expect(result.get(imageUrl2)).toBe( + "/tmp/github-images/image-1704067200000-1.png", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Found 2 image(s) in issue_comment 888", + ); + }); + + test("should handle mixed Markdown and HTML images", async () => { + const mockOctokit = createMockOctokit(); + const markdownUrl = + "https://github.com/user-attachments/assets/markdown.png"; + const htmlUrl = "https://github.com/user-attachments/assets/html.jpg"; + const signedUrl1 = + "https://private-user-images.githubusercontent.com/md.png?jwt=token1"; + const signedUrl2 = + "https://private-user-images.githubusercontent.com/html.jpg?jwt=token2"; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "999", + body: `Markdown: ![test](${markdownUrl}) and HTML: test`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.size).toBe(2); + expect(result.get(markdownUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.png", + ); + expect(result.get(htmlUrl)).toBe( + "/tmp/github-images/image-1704067200000-1.jpg", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Found 2 image(s) in issue_comment 999", + ); + }); + + test("should deduplicate identical URLs from Markdown and HTML", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = "https://github.com/user-attachments/assets/duplicate.png"; + const signedUrl = + "https://private-user-images.githubusercontent.com/dup.png?jwt=token"; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1000", + body: `Same image twice: ![test](${imageUrl}) and test`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once + expect(result.size).toBe(1); + expect(result.get(imageUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.png", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Found 1 image(s) in issue_comment 1000", + ); + }); + + test("should handle HTML img tags with additional attributes", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = + "https://github.com/user-attachments/assets/complex-tag.webp"; + const signedUrl = + "https://private-user-images.githubusercontent.com/complex.webp?jwt=token"; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1001", + body: `Complex tag: test image`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.size).toBe(1); + expect(result.get(imageUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.webp", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Found 1 image(s) in issue_comment 1001", + ); + }); }); From c6a07895d72897f6dfefa488afbfef41fcf4b525 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 5 Aug 2025 16:50:23 +0000 Subject: [PATCH 05/11] chore: bump Claude Code version to 1.0.69 --- action.yml | 2 +- base-action/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 7b77fab66..b7dfe229e 100644 --- a/action.yml +++ b/action.yml @@ -172,7 +172,7 @@ runs: echo "Base-action dependencies installed" cd - # Install Claude Code globally - bun install -g @anthropic-ai/claude-code@1.0.68 + bun install -g @anthropic-ai/claude-code@1.0.69 - name: Setup Network Restrictions if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' diff --git a/base-action/action.yml b/base-action/action.yml index a3aab8c25..250db3d81 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -118,7 +118,7 @@ runs: - name: Install Claude Code shell: bash - run: bun install -g @anthropic-ai/claude-code@1.0.68 + run: bun install -g @anthropic-ai/claude-code@1.0.69 - name: Run Claude Code Action shell: bash From 85287e957da85d41d87fb98c923b81842e967d6c Mon Sep 17 00:00:00 2001 From: yoshikouki <53972292+yoshikouki@users.noreply.github.com> Date: Wed, 6 Aug 2025 03:14:28 +0900 Subject: [PATCH 06/11] fix: restore prompt file creation in agent mode (#405) - Restore prompt file creation logic that was accidentally removed in PR #374 - Agent mode now creates the prompt file directly in prepare() method - Uses override_prompt or direct_prompt if available, falls back to minimal prompt - Fixes 'Prompt file does not exist' error for workflow_dispatch and schedule events - Add TODO comment to refactor this to use createPrompt in the future Fixes #403 --- src/modes/agent/index.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 56f337f01..9aeb8b317 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -1,4 +1,5 @@ import * as core from "@actions/core"; +import { mkdir, writeFile } from "fs/promises"; import type { Mode, ModeOptions, ModeResult } from "../types"; import { isAutomationContext } from "../../github/context"; import type { PreparedContext } from "../../create-prompt/types"; @@ -42,7 +43,23 @@ export const agentMode: Mode = { async prepare({ context }: ModeOptions): Promise { // Agent mode handles automation events (workflow_dispatch, schedule) only - // Agent mode doesn't need to create prompt files here - handled by createPrompt + // TODO: handle by createPrompt (similar to tag and review modes) + // Create prompt directory + await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, { + recursive: true, + }); + // Write the prompt file - the base action requires a prompt_file parameter, + // so we must create this file even though agent mode typically uses + // override_prompt or direct_prompt. If neither is provided, we write + // a minimal prompt with just the repository information. + const promptContent = + context.inputs.overridePrompt || + context.inputs.directPrompt || + `Repository: ${context.repository.owner}/${context.repository.repo}`; + await writeFile( + `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`, + promptContent, + ); // Export tool environment variables for agent mode const baseTools = [ From a519840051f28104f828d0341e481e656a0186e2 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 5 Aug 2025 11:32:46 -0700 Subject: [PATCH 07/11] fix: remove git config user.name and user.email from allowed tools (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These git config commands are no longer needed as allowed tools since Claude should not be modifying git configuration settings. Updated the corresponding test to reflect this intentional change. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- src/create-prompt/index.ts | 2 -- test/create-prompt.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 135b020b5..5f6d6c7df 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -60,8 +60,6 @@ export function buildAllowedToolsString( "Bash(git diff:*)", "Bash(git log:*)", "Bash(git rm:*)", - "Bash(git config user.name:*)", - "Bash(git config user.email:*)", ); } diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 5e86ab11d..c97f15981 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -1041,8 +1041,6 @@ describe("buildAllowedToolsString", () => { expect(result).toContain("Bash(git diff:*)"); expect(result).toContain("Bash(git log:*)"); expect(result).toContain("Bash(git rm:*)"); - expect(result).toContain("Bash(git config user.name:*)"); - expect(result).toContain("Bash(git config user.email:*)"); // Comment tool from minimal server should be included expect(result).toContain("mcp__github_comment__update_claude_comment"); From 188d526721c4b76a779f8af9a10fe73b500a2fbf Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 5 Aug 2025 17:02:34 -0700 Subject: [PATCH 08/11] refactor: change git hook from pre-push to pre-commit (#401) - Renamed scripts/pre-push to scripts/pre-commit - Updated install-hooks.sh to install pre-commit hook - Hook now runs formatting, type checking, and tests before commit --- scripts/install-hooks.sh | 6 +++--- scripts/{pre-push => pre-commit} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename scripts/{pre-push => pre-commit} (100%) diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 863bf6117..8f27e512f 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -6,8 +6,8 @@ echo "Installing git hooks..." # Make sure hooks directory exists mkdir -p .git/hooks -# Install pre-push hook -cp scripts/pre-push .git/hooks/pre-push -chmod +x .git/hooks/pre-push +# Install pre-commit hook +cp scripts/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit echo "Git hooks installed successfully!" \ No newline at end of file diff --git a/scripts/pre-push b/scripts/pre-commit similarity index 100% rename from scripts/pre-push rename to scripts/pre-commit From 78f6a5f9876d8b77be4b13ad577e62ab77226eb5 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Mon, 4 Aug 2025 16:58:57 +0800 Subject: [PATCH 09/11] feat: add allow_bot_actor parameter for automated workflows - Add allow_bot_actor parameter to enable GitHub bots to trigger Claude Code Action - Implement robust bot write permission validation - Add test coverage for bot scenarios - Update documentation with security considerations This enables automated workflows like documentation updates, CI-triggered code reviews, and scheduled maintenance while maintaining security through explicit opt-in and proper permission validation. Relevant works: #388 #280 #194 #117 --- ROADMAP.md | 2 +- action.yml | 5 + docs/faq.md | 8 +- src/github/context.ts | 2 + src/github/validation/actor.ts | 35 +- src/github/validation/permissions.ts | 186 ++++++++++- test/actor-validation.test.ts | 142 ++++++++ test/permissions-validation.test.ts | 478 +++++++++++++++++++++++++++ 8 files changed, 837 insertions(+), 21 deletions(-) create mode 100644 test/actor-validation.test.ts create mode 100644 test/permissions-validation.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index d9fd75797..97f1b60ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o - **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services - **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added. - **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback -- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude +- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~ - **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data --- diff --git a/action.yml b/action.yml index b7dfe229e..778880524 100644 --- a/action.yml +++ b/action.yml @@ -60,6 +60,10 @@ inputs: description: "Complete replacement of Claude's prompt with custom template (supports variable substitution)" required: false default: "" + allow_bot_actor: + description: "Allow bot actors to trigger the action. Default is false for security reasons." + required: false + default: "false" mcp_config: description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers" additional_permissions: @@ -154,6 +158,7 @@ runs: CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} DIRECT_PROMPT: ${{ inputs.direct_prompt }} OVERRIDE_PROMPT: ${{ inputs.override_prompt }} + ALLOW_BOT_ACTOR: ${{ inputs.allow_bot_actor }} MCP_CONFIG: ${{ inputs.mcp_config }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} GITHUB_RUN_ID: ${{ github.run_id }} diff --git a/docs/faq.md b/docs/faq.md index 2f03b31a7..b25d1f4f4 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -6,7 +6,13 @@ This FAQ addresses common questions and gotchas when using the Claude Code GitHu ### Why doesn't tagging @claude from my automated workflow work? -The `github-actions` user cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user, or use a separate app token of your own. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow. +By default, bots cannot trigger Claude for security reasons. With `allow_bot_actor: true`, you can enable bot triggers, but there are important distinctions: + +1. **GitHub Apps** (recommended): Create a GitHub App, use app tokens, and set `allow_bot_actor: true`. The app needs write permissions. +2. **Personal Access Tokens**: Use a PAT instead of `GITHUB_TOKEN` in your workflows with `allow_bot_actor: true`. +3. **github-actions[bot]**: Can trigger Claude with `allow_bot_actor: true`, BUT due to GitHub's security, responses won't trigger subsequent workflows. + +**Important**: With `allow_bot_actor: true`, `github-actions[bot]` CAN trigger Claude initially. However, Claude's responses (when using `GITHUB_TOKEN`) cannot trigger subsequent workflows due to GitHub's anti-loop security feature. ### Why does Claude say I don't have permission to trigger it? diff --git a/src/github/context.ts b/src/github/context.ts index 58ae761cf..ddc55a119 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -77,6 +77,7 @@ type BaseContext = { useStickyComment: boolean; additionalPermissions: Map; useCommitSigning: boolean; + allowBotActor: boolean; }; }; @@ -136,6 +137,7 @@ export function parseGitHubContext(): GitHubContext { process.env.ADDITIONAL_PERMISSIONS ?? "", ), useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", + allowBotActor: process.env.ALLOW_BOT_ACTOR === "true", }, }; diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index c48764b92..a6a661b9e 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -5,22 +5,47 @@ * Prevents automated tools or bots from triggering Claude */ +import * as core from "@actions/core"; import type { Octokit } from "@octokit/rest"; import type { ParsedGitHubContext } from "../context"; +/** + * Get the GitHub actor type (User, Bot, Organization, etc.) + */ +async function getActorType( + octokit: Octokit, + actor: string, +): Promise { + try { + const { data } = await octokit.users.getByUsername({ username: actor }); + return data.type; + } catch (error) { + core.warning(`Failed to get user data for ${actor}: ${error}`); + return null; + } +} + export async function checkHumanActor( octokit: Octokit, githubContext: ParsedGitHubContext, ) { - // Fetch user information from GitHub API - const { data: userData } = await octokit.users.getByUsername({ - username: githubContext.actor, - }); + const actorType = await getActorType(octokit, githubContext.actor); - const actorType = userData.type; + if (!actorType) { + throw new Error( + `Could not determine actor type for: ${githubContext.actor}`, + ); + } console.log(`Actor type: ${actorType}`); + if (githubContext.inputs.allowBotActor && actorType === "Bot") { + console.log( + `Bot actor allowed, skipping human actor check for: ${githubContext.actor}`, + ); + return; + } + if (actorType !== "User") { throw new Error( `Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`, diff --git a/src/github/validation/permissions.ts b/src/github/validation/permissions.ts index d34e3965c..2f5944cd0 100644 --- a/src/github/validation/permissions.ts +++ b/src/github/validation/permissions.ts @@ -3,7 +3,113 @@ import type { ParsedGitHubContext } from "../context"; import type { Octokit } from "@octokit/rest"; /** - * Check if the actor has write permissions to the repository + * Return the GitHub user type (User, Bot, Organization, ...) + * @param octokit - The Octokit REST client + * @param actor - The GitHub actor username + * @returns The actor type string or null if unable to determine + */ +async function getActorType( + octokit: Octokit, + actor: string, +): Promise { + try { + const { data } = await octokit.users.getByUsername({ username: actor }); + return data.type; + } catch (error) { + core.warning(`Failed to get user data for ${actor}: ${error}`); + return null; + } +} + +/** + * Try to perform a real write operation test for GitHub App tokens + * This is more reliable than checking repo.permissions.push (always false for App tokens) + * @param octokit - The Octokit REST client + * @param context - The GitHub context + * @returns true if write access is confirmed, false otherwise + */ +async function testWriteAccess( + octokit: Octokit, + context: ParsedGitHubContext, +): Promise { + try { + const { data: repo } = await octokit.repos.get({ + owner: context.repository.owner, + repo: context.repository.repo, + }); + + // For App tokens, repo.permissions.push is always false, so we can't rely on it + // Instead, let's try a write operation that would fail if we don't have write access + try { + const { data: defaultBranchRef } = await octokit.git.getRef({ + owner: context.repository.owner, + repo: context.repository.repo, + ref: `heads/${repo.default_branch}`, + }); + + core.info( + `Successfully accessed default branch ref: ${defaultBranchRef.ref}`, + ); + + return true; + } catch (refError) { + core.warning(`Could not access git refs: ${refError}`); + return false; + } + } catch (error) { + core.warning(`Failed to test write access: ${error}`); + return false; + } +} + +/** + * Check GitHub App installation permissions by trying the installation endpoint + * This may work with installation tokens in some cases + * @param octokit - The Octokit REST client + * @param context - The GitHub context + * @returns true if the app has write permissions via installation, false otherwise + */ +async function checkAppInstallationPermissions( + octokit: Octokit, + context: ParsedGitHubContext, +): Promise { + try { + // Try to get the installation for this repository + // Note: This might fail if called with an installation token instead of JWT + const { data: installation } = await octokit.apps.getRepoInstallation({ + owner: context.repository.owner, + repo: context.repository.repo, + }); + + core.info(`App installation found: ${installation.id}`); + + const permissions = installation.permissions || {}; + const hasWrite = + permissions.contents === "write" || permissions.contents === "admin"; + + core.info( + `App installation permissions → contents:${permissions.contents}`, + ); + if (hasWrite) { + core.info("App has write-level access via installation permissions"); + } else { + core.warning("App lacks write-level access via installation permissions"); + } + + return hasWrite; + } catch (error) { + core.warning( + `Failed to check app installation permissions (may require JWT): ${error}`, + ); + return false; + } +} + +/** + * Determine whether the supplied token grants **write‑level** access to the target repository. + * + * For GitHub Apps, we use multiple approaches since repo.permissions.push is unreliable. + * For human users, we check collaborator permissions. * @param octokit - The Octokit REST client * @param context - The GitHub context * @returns true if the actor has write permissions, false otherwise @@ -14,28 +120,80 @@ export async function checkWritePermissions( ): Promise { const { repository, actor } = context; - try { - core.info(`Checking permissions for actor: ${actor}`); + core.info(`Checking write permissions for actor: ${actor}`); + + // 1. Get actor type to determine approach + const actorType = await getActorType(octokit, actor); + + // 2. For GitHub Apps/Bots, use multiple approaches + if (actorType === "Bot") { + core.info( + `GitHub App detected: ${actor}, checking permissions via multiple methods`, + ); - // Check permissions directly using the permission endpoint - const response = await octokit.repos.getCollaboratorPermissionLevel({ + // Method 1: Try installation permissions check (may fail with installation tokens) + const hasInstallationAccess = await checkAppInstallationPermissions( + octokit, + context, + ); + if (hasInstallationAccess) { + return true; + } + + // Method 2: Check if bot is a direct collaborator + try { + const { data } = await octokit.repos.getCollaboratorPermissionLevel({ + owner: repository.owner, + repo: repository.repo, + username: actor, + }); + + const level = data.permission; + core.info(`App collaborator permission level: ${level}`); + const hasCollaboratorAccess = level === "admin" || level === "write"; + + if (hasCollaboratorAccess) { + core.info(`App has write access via collaborator: ${level}`); + return true; + } + } catch (error) { + core.warning( + `Could not check collaborator permissions for bot: ${error}`, + ); + } + + // Method 3: Test actual write access capability + const hasWriteAccess = await testWriteAccess(octokit, context); + if (hasWriteAccess) { + core.info("App has write access based on capability test"); + return true; + } + core.warning(`Bot lacks write permissions based on all checks`); + return false; + } + + // 3. For human users, check collaborator permission level + try { + const { data } = await octokit.repos.getCollaboratorPermissionLevel({ owner: repository.owner, repo: repository.repo, username: actor, }); - const permissionLevel = response.data.permission; - core.info(`Permission level retrieved: ${permissionLevel}`); + const level = data.permission; + core.info(`Human collaborator permission level: ${level}`); + const hasWrite = level === "admin" || level === "write"; - if (permissionLevel === "admin" || permissionLevel === "write") { - core.info(`Actor has write access: ${permissionLevel}`); - return true; + if (hasWrite) { + core.info(`Human has write access: ${level}`); } else { - core.warning(`Actor has insufficient permissions: ${permissionLevel}`); - return false; + core.warning(`Human has insufficient permissions: ${level}`); } + + return hasWrite; } catch (error) { - core.error(`Failed to check permissions: ${error}`); - throw new Error(`Failed to check permissions for ${actor}: ${error}`); + core.warning(`Unable to fetch collaborator level for ${actor}: ${error}`); + + return false; } } diff --git a/test/actor-validation.test.ts b/test/actor-validation.test.ts new file mode 100644 index 000000000..744a802eb --- /dev/null +++ b/test/actor-validation.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"; +import { checkHumanActor } from "../src/github/validation/actor"; +import type { ParsedGitHubContext } from "../src/github/context"; + +describe("checkHumanActor", () => { + let consoleSpy: any; + + beforeEach(() => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + const createMockOctokit = (userType: "User" | "Bot") => { + return { + users: { + getByUsername: async () => ({ + data: { type: userType }, + }), + }, + } as any; + }; + + const createContext = ( + actor: string = "test-user", + allowBotActor: boolean = false, + ): ParsedGitHubContext => ({ + runId: "1234567890", + eventName: "issue_comment", + eventAction: "created", + repository: { + full_name: "test-owner/test-repo", + owner: "test-owner", + repo: "test-repo", + }, + actor, + payload: { + action: "created", + issue: { + number: 1, + title: "Test Issue", + body: "Test body", + user: { login: actor }, + }, + comment: { + id: 123, + body: "@claude test", + user: { login: actor }, + html_url: + "https://github.com/test-owner/test-repo/issues/1#issuecomment-123", + }, + } as any, + entityNumber: 1, + isPR: false, + inputs: { + triggerPhrase: "@claude", + assigneeTrigger: "", + labelTrigger: "", + allowedTools: [], + disallowedTools: [], + customInstructions: "", + directPrompt: "", + branchPrefix: "claude/", + useStickyComment: false, + additionalPermissions: new Map(), + useCommitSigning: false, + allowBotActor, + }, + }); + + test("should pass for human users", async () => { + const mockOctokit = createMockOctokit("User"); + const context = createContext("human-user"); + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalledWith("Actor type: User"); + expect(consoleSpy).toHaveBeenCalledWith("Verified human actor: human-user"); + }); + + test("should reject bot actors by default", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createContext("bot-actor"); + + await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow( + "Workflow initiated by non-human actor: bot-actor (type: Bot).", + ); + + expect(consoleSpy).toHaveBeenCalledWith("Actor type: Bot"); + }); + + test("should allow bot actors when allowBotActor is true", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createContext("bot-actor", true); + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalledWith("Actor type: Bot"); + expect(consoleSpy).toHaveBeenCalledWith( + "Bot actor allowed, skipping human actor check for: bot-actor", + ); + }); + + test("should call GitHub API with correct username", async () => { + let capturedUsername: string; + const mockOctokit = { + users: { + getByUsername: async (params: { username: string }) => { + capturedUsername = params.username; + return { data: { type: "User" } }; + }, + }, + } as any; + const context = createContext("test-actor"); + + await checkHumanActor(mockOctokit, context); + + expect(capturedUsername!).toBe("test-actor"); + }); + + test("should propagate GitHub API errors", async () => { + const error = new Error("User not found"); + const mockOctokit = { + users: { + getByUsername: async () => { + throw error; + }, + }, + } as any; + const context = createContext("nonexistent-user"); + + await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow( + "Could not determine actor type for: nonexistent-user", + ); + }); +}); diff --git a/test/permissions-validation.test.ts b/test/permissions-validation.test.ts new file mode 100644 index 000000000..d8dd54ea1 --- /dev/null +++ b/test/permissions-validation.test.ts @@ -0,0 +1,478 @@ +import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"; +import { checkWritePermissions } from "../src/github/validation/permissions"; +import type { ParsedGitHubContext } from "../src/github/context"; + +describe("checkWritePermissions", () => { + let coreSpy: any; + + beforeEach(() => { + coreSpy = { + info: spyOn(console, "log").mockImplementation(() => {}), + warning: spyOn(console, "warn").mockImplementation(() => {}), + error: spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + coreSpy.info.mockRestore(); + coreSpy.warning.mockRestore(); + coreSpy.error.mockRestore(); + }); + + const createContext = (actor: string = "test-user"): ParsedGitHubContext => ({ + runId: "1234567890", + eventName: "issue_comment", + eventAction: "created", + repository: { + full_name: "test-owner/test-repo", + owner: "test-owner", + repo: "test-repo", + }, + actor, + payload: { + action: "created", + issue: { + number: 1, + title: "Test Issue", + body: "Test body", + user: { login: actor }, + }, + comment: { + id: 123, + body: "@claude test", + user: { login: actor }, + html_url: + "https://github.com/test-owner/test-repo/issues/1#issuecomment-123", + }, + } as any, + entityNumber: 1, + isPR: false, + inputs: { + triggerPhrase: "@claude", + assigneeTrigger: "", + labelTrigger: "", + allowedTools: [], + disallowedTools: [], + customInstructions: "", + directPrompt: "", + branchPrefix: "claude/", + useStickyComment: false, + additionalPermissions: new Map(), + useCommitSigning: false, + allowBotActor: false, + }, + }); + + test("should grant write permissions to human users with write access", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "User" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: "write" }, + }), + }, + } as any; + + const context = createContext("human-user"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should deny write permissions to human users with read access", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "User" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: "read" }, + }), + }, + } as any; + + const context = createContext("human-user"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); + + test("should grant write permissions to bots with write access via installation", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + permissions: { admin: false, push: false, maintain: false }, + }, + }), + }, + apps: { + getRepoInstallation: async () => ({ + data: { + id: 123, + permissions: { contents: "write" }, + }, + }), + }, + git: { + getRef: async () => ({ + data: { ref: "refs/heads/main" }, + }), + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should grant write permissions to bots with capability test", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + permissions: { admin: false, push: false, maintain: false }, + }, + }), + }, + apps: { + getRepoInstallation: async () => { + throw new Error("Installation not found"); + }, + }, + git: { + getRef: async () => ({ + data: { ref: "refs/heads/main" }, + }), + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should deny write permissions to bots with read access via repo permissions", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + permissions: { admin: false, push: false, maintain: false }, + }, + }), + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); + + test("should grant write permissions to bots with admin installation", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + permissions: { admin: false, push: false, maintain: false }, + }, + }), + }, + apps: { + getRepoInstallation: async () => ({ + data: { + id: 123, + permissions: { contents: "admin" }, + }, + }), + }, + git: { + getRef: async () => ({ + data: { ref: "refs/heads/main" }, + }), + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should deny write permissions to bots with no repo permissions", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + permissions: { admin: false, push: false, maintain: false }, + }, + }), + }, + apps: { + getRepoInstallation: async () => { + throw new Error("Installation not found"); + }, + }, + git: { + getRef: async () => { + throw new Error("Access denied"); + }, + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); + + test("should deny write permissions to bots when repo.get fails", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => { + throw new Error("Repository access denied"); + }, + }, + apps: { + getRepoInstallation: async () => { + throw new Error("Installation not found"); + }, + }, + git: { + getRef: async () => { + throw new Error("Access denied"); + }, + }, + } as any; + + const context = createContext("claude-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); + + test("should handle API errors gracefully", async () => { + const mockOctokit = { + users: { + getByUsername: async () => { + throw new Error("API Error"); + }, + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("API Error"); + }, + get: async () => { + throw new Error("API Error"); + }, + }, + } as any; + + const context = createContext("test-user"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); + + test("should grant write permissions to users with admin access", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "User" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: "admin" }, + }), + }, + } as any; + + const context = createContext("admin-user"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should grant write permissions to bots with admin access via collaborator", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: "admin" }, + }), + get: async () => ({ + data: { + permissions: { admin: true, push: false, maintain: false }, + }, + }), + }, + } as any; + + const context = createContext("admin-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should grant write permissions to bots with admin access via repo permissions", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + permissions: { admin: true, push: false, maintain: false }, + }, + }), + }, + apps: { + getRepoInstallation: async () => { + throw new Error("Installation not found"); + }, + }, + git: { + getRef: async () => ({ + data: { ref: "refs/heads/main" }, + }), + }, + } as any; + + const context = createContext("admin-bot"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should continue when getActorType fails", async () => { + const mockOctokit = { + users: { + getByUsername: async () => { + throw new Error("User not found"); + }, + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: "write" }, + }), + get: async () => ({ + data: { + permissions: { admin: false, push: true, maintain: false }, + }, + }), + }, + } as any; + + const context = createContext("unknown-user"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should handle repo without permissions object", async () => { + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: { type: "Bot" }, + }), + }, + repos: { + getCollaboratorPermissionLevel: async () => { + throw new Error("Not found"); + }, + get: async () => ({ + data: { + default_branch: "main", + // permissions is undefined + }, + }), + }, + apps: { + getRepoInstallation: async () => { + throw new Error("Installation not found"); + }, + }, + git: { + getRef: async () => { + throw new Error("Access denied"); + }, + }, + } as any; + + const context = createContext("bot-no-perms"); + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + }); +}); From b26ab84feefb992005756378287a0baa96af40ea Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 5 Aug 2025 00:25:35 -0700 Subject: [PATCH 10/11] docs: add bot automation example with prompt options explained --- examples/bot-automation.yml | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 examples/bot-automation.yml diff --git a/examples/bot-automation.yml b/examples/bot-automation.yml new file mode 100644 index 000000000..05cf48d74 --- /dev/null +++ b/examples/bot-automation.yml @@ -0,0 +1,60 @@ +name: Bot Automation with Claude + +on: + pull_request: + types: [opened, reopened] + issue_comment: + types: [created] + +jobs: + claude-bot-review: + # Allow bots to trigger this workflow + if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) + + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + + steps: + # Step 1: Generate GitHub App token (required for bot actors) + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + permission-issues: write + + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Run Claude with Bot Actor + uses: anthropics/claude-code-action@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ steps.app-token.outputs.token }} # Use App token, not GITHUB_TOKEN + allow_bot_actor: true # Enable bot actors to trigger Claude + + # Option 1: direct_prompt - Adds priority instructions to standard prompt + # direct_prompt: | + # Focus on security implications and performance impacts. + # Be extra thorough with database migrations. + # Always suggest tests for new features. + + # Option 2: override_prompt - Completely replaces the standard prompt + # override_prompt: | + # You are a specialized code reviewer for our API. + # Review the PR focusing only on: + # - API contract changes + # - Breaking changes + # - Performance implications + # {{prDiff}} # Variables are substituted \ No newline at end of file From ff606b8c0e9e3eae58dc881cdf9d8348e76758f3 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 5 Aug 2025 00:52:50 -0700 Subject: [PATCH 11/11] docs: clarify prompt options hierarchy in bot automation example --- examples/bot-automation.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/bot-automation.yml b/examples/bot-automation.yml index 05cf48d74..2cb334515 100644 --- a/examples/bot-automation.yml +++ b/examples/bot-automation.yml @@ -44,13 +44,19 @@ jobs: github_token: ${{ steps.app-token.outputs.token }} # Use App token, not GITHUB_TOKEN allow_bot_actor: true # Enable bot actors to trigger Claude - # Option 1: direct_prompt - Adds priority instructions to standard prompt + # Option 1: custom_instructions - Adds personality/rules while keeping standard GitHub context + # custom_instructions: | + # You're a friendly but thorough code reviewer. + # Always check for edge cases and suggest tests. + # Be encouraging to new contributors. + + # Option 2: direct_prompt - High-priority override instructions # direct_prompt: | - # Focus on security implications and performance impacts. - # Be extra thorough with database migrations. - # Always suggest tests for new features. + # ONLY review security issues. Ignore style/formatting. + # Do NOT implement any code changes. + # Focus on SQL injection and XSS vulnerabilities. - # Option 2: override_prompt - Completely replaces the standard prompt + # Option 3: override_prompt - Completely replaces the standard prompt (loses GitHub context) # override_prompt: | # You are a specialized code reviewer for our API. # Review the PR focusing only on: