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 0fd6567ee..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 }} @@ -172,7 +177,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.69 - name: Setup Network Restrictions if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' @@ -201,7 +206,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/base-action/action.yml b/base-action/action.yml index 8a5d28c71..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.67 + run: bun install -g @anthropic-ai/claude-code@1.0.69 - name: Run Claude Code Action shell: bash 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..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? @@ -135,6 +141,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) diff --git a/examples/bot-automation.yml b/examples/bot-automation.yml new file mode 100644 index 000000000..2cb334515 --- /dev/null +++ b/examples/bot-automation.yml @@ -0,0 +1,66 @@ +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: 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: | + # ONLY review security issues. Ignore style/formatting. + # Do NOT implement any code changes. + # Focus on SQL injection and XSS vulnerabilities. + + # 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: + # - API contract changes + # - Breaking changes + # - Performance implications + # {{prDiff}} # Variables are substituted \ No newline at end of file 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 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/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/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/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/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/src/modes/agent/index.ts b/src/modes/agent/index.ts index 94d247ce1..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 = [ @@ -112,4 +129,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 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/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"); 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", + ); + }); }); 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); + }); +});