From 23fae74fdb7f3bb4fdd3ef029a08b1db410a4240 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 3 Jul 2025 18:58:02 -0700 Subject: [PATCH] Add GitHub Actions MCP server for viewing workflow results (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * actions server * tmp * Replace view_actions_results with additional_permissions input - Changed input from boolean view_actions_results to a more flexible additional_permissions format - Uses newline-separated colon format similar to claude_env (e.g., "actions: read") - Maintains permission checking to warn users when their token lacks required permissions - Updated all tests to use the new format This allows for future extensibility while currently supporting only "actions: read" permission. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Update GitHub Actions MCP server with RUNNER_TEMP and status filtering - Use RUNNER_TEMP environment variable for log storage directory (defaults to /tmp) - Add status parameter to get_ci_status tool to filter workflow runs - Supported statuses: completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending - Pass RUNNER_TEMP from install-mcp-server.ts to the MCP server environment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add GitHub Actions MCP tools to allowed tools when actions:read is granted - Automatically include github_ci MCP server tools in allowed tools list when actions:read permission is granted - Added mcp__github_ci__get_ci_status, mcp__github_ci__get_workflow_run_details, mcp__github_ci__download_job_log - Simplified permission checking to avoid duplicate parsing logic - Added tests for the new functionality This ensures Claude can use the Actions tools when the server is enabled. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Refactor additional permissions parsing to parseGitHubContext - Moved additional permissions parsing from individual functions to centralized parseGitHubContext - Added parseAdditionalPermissions function to handle newline-separated colon format - Removed redundant additionalPermissions parameter from prepareMcpConfig - Updated tests to use permissions from context instead of passing as parameter - Added comprehensive tests for parseAdditionalPermissions function This centralizes all input parsing logic in one place for better maintainability. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Remove unnecessary hasActionsReadPermission parameter from createPrompt - Removed hasActionsReadPermission parameter since createPrompt has access to context - Calculate hasActionsReadPermission directly from context.inputs.additionalPermissions inside createPrompt - Simplified prepare.ts by removing intermediate permission check This completes the refactoring to centralize all permission handling through the context object. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * docs: Add documentation for additional_permissions feature - Document the new additional_permissions input that replaces view_actions_results - Add dedicated section explaining CI/CD integration with actions:read permission - Include example workflow showing how to grant GitHub token permissions - Update main workflow example to show optional additional_permissions usage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * roadmap --------- Co-authored-by: Claude --- README.md | 119 ++++++++++--- ROADMAP.md | 2 +- action.yml | 6 + src/create-prompt/index.ts | 18 +- src/entrypoints/prepare.ts | 1 + src/github/context.ts | 23 +++ src/mcp/github-actions-server.ts | 275 +++++++++++++++++++++++++++++++ src/mcp/install-mcp-server.ts | 72 ++++++++ test/create-prompt.test.ts | 30 ++++ test/github/context.test.ts | 60 ++++++- test/install-mcp-server.test.ts | 185 +++++++++++++++++++++ test/mockContext.ts | 1 + test/permissions.test.ts | 1 + test/trigger-validation.test.ts | 5 + 14 files changed, 772 insertions(+), 26 deletions(-) create mode 100644 src/mcp/github-actions-server.ts diff --git a/README.md b/README.md index 235f772..f608b68 100644 --- a/README.md +++ b/README.md @@ -74,33 +74,37 @@ jobs: # API_URL: https://api.example.com # Optional: limit the number of conversation turns # max_turns: "5" + # Optional: grant additional permissions (requires corresponding GitHub token permissions) + # additional_permissions: | + # actions: read ``` ## Inputs -| Input | Description | Required | Default | -| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | -| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | -| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | 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 | "" | +| Input | Description | Required | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | +| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | +| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | 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 | "" | +| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex) @@ -339,6 +343,75 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi ## Advanced Configuration +### Additional Permissions for CI/CD Integration + +The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues. + +#### Enabling GitHub Actions Access + +To allow Claude to view workflow run results, job logs, and CI status: + +1. **Grant the necessary permission to your GitHub token**: + + - When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow: + + ```yaml + permissions: + contents: write + pull-requests: write + issues: write + actions: read # Add this line + ``` + +2. **Configure the action with additional permissions**: + + ```yaml + - uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + additional_permissions: | + actions: read + # ... other inputs + ``` + +3. **Claude will automatically get access to CI/CD tools**: + When you enable `actions: read`, Claude can use the following MCP tools: + - `mcp__github_ci__get_ci_status` - View workflow run statuses + - `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information + - `mcp__github_ci__download_job_log` - Download and analyze job logs + +#### Example: Debugging Failed CI Runs + +```yaml +name: Claude CI Helper +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + actions: read # Required for CI access + +jobs: + claude-ci-helper: + runs-on: ubuntu-latest + steps: + - uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + additional_permissions: | + actions: read + # Now Claude can respond to "@claude why did the CI fail?" +``` + +**Important Notes**: + +- The GitHub token must have the `actions: read` permission in your workflow +- If the permission is missing, Claude will warn you and suggest adding it +- Currently, only `actions: read` is supported, but the format allows for future extensions + ### Custom Environment Variables You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables: diff --git a/ROADMAP.md b/ROADMAP.md index 9bf66c4..d9fd757 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o ## Path to 1.0 -- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like. +- ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~ - **Cross-repo support** - Enable Claude to work across multiple repositories in a single session - **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files - **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services diff --git a/action.yml b/action.yml index fa56d5d..aaa1b93 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,10 @@ inputs: default: "" mcp_config: description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers" + additional_permissions: + description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results" + required: false + default: "" claude_env: description: "Custom environment variables to pass to Claude Code execution (YAML format)" required: false @@ -124,6 +128,8 @@ runs: OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} GITHUB_RUN_ID: ${{ github.run_id }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} + ACTIONS_TOKEN: ${{ github.token }} + ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} - name: Run Claude Code id: claude-code diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 7e1c9d6..ad91179 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [ ]; const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"]; -export function buildAllowedToolsString(customAllowedTools?: string[]): string { +export function buildAllowedToolsString( + customAllowedTools?: string[], + includeActionsTools: boolean = false, +): string { let baseTools = [...BASE_ALLOWED_TOOLS]; + // Add GitHub Actions MCP tools if enabled + if (includeActionsTools) { + baseTools.push( + "mcp__github_ci__get_ci_status", + "mcp__github_ci__get_workflow_run_details", + "mcp__github_ci__download_job_log", + ); + } + let allAllowedTools = baseTools.join(","); if (customAllowedTools && customAllowedTools.length > 0) { allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`; @@ -665,8 +677,12 @@ export async function createPrompt( ); // Set allowed tools + const hasActionsReadPermission = + context.inputs.additionalPermissions.get("actions") === "read" && + context.isPR; const allAllowedTools = buildAllowedToolsString( context.inputs.allowedTools, + hasActionsReadPermission, ); const allDisallowedTools = buildDisallowedToolsString( context.inputs.disallowedTools, diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index f8b5dc2..23bb74b 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -94,6 +94,7 @@ async function run() { additionalMcpConfig, claudeCommentId: commentId.toString(), allowedTools: context.inputs.allowedTools, + context, }); core.setOutput("mcp_config", mcpConfig); } catch (error) { diff --git a/src/github/context.ts b/src/github/context.ts index 51d5d81..205a955 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -37,6 +37,7 @@ export type ParsedGitHubContext = { baseBranch?: string; branchPrefix: string; useStickyComment: boolean; + additionalPermissions: Map; }; }; @@ -64,6 +65,9 @@ export function parseGitHubContext(): ParsedGitHubContext { baseBranch: process.env.BASE_BRANCH, branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", useStickyComment: process.env.USE_STICKY_COMMENT === "true", + additionalPermissions: parseAdditionalPermissions( + process.env.ADDITIONAL_PERMISSIONS ?? "", + ), }, }; @@ -125,6 +129,25 @@ export function parseMultilineInput(s: string): string[] { .filter((tool) => tool.length > 0); } +export function parseAdditionalPermissions(s: string): Map { + const permissions = new Map(); + if (!s || !s.trim()) { + return permissions; + } + + const lines = s.trim().split("\n"); + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine) { + const [key, value] = trimmedLine.split(":").map((part) => part.trim()); + if (key && value) { + permissions.set(key, value); + } + } + } + return permissions; +} + export function isIssuesEvent( context: ParsedGitHubContext, ): context is ParsedGitHubContext & { payload: IssuesEvent } { diff --git a/src/mcp/github-actions-server.ts b/src/mcp/github-actions-server.ts new file mode 100644 index 0000000..f783575 --- /dev/null +++ b/src/mcp/github-actions-server.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { mkdir, writeFile } from "fs/promises"; +import { Octokit } from "@octokit/rest"; + +const REPO_OWNER = process.env.REPO_OWNER; +const REPO_NAME = process.env.REPO_NAME; +const PR_NUMBER = process.env.PR_NUMBER; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp"; + +if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) { + console.error( + "[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required", + ); + process.exit(1); +} + +const server = new McpServer({ + name: "GitHub CI Server", + version: "0.0.1", +}); + +console.error("[GitHub CI Server] MCP Server instance created"); + +server.tool( + "get_ci_status", + "Get CI status summary for this PR", + { + status: z + .enum([ + "completed", + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "success", + "timed_out", + "in_progress", + "queued", + "requested", + "waiting", + "pending", + ]) + .optional() + .describe("Filter workflow runs by status"), + }, + async ({ status }) => { + try { + const client = new Octokit({ + auth: GITHUB_TOKEN, + }); + + // Get the PR to find the head SHA + const { data: prData } = await client.pulls.get({ + owner: REPO_OWNER!, + repo: REPO_NAME!, + pull_number: parseInt(PR_NUMBER!, 10), + }); + const headSha = prData.head.sha; + + const { data: runsData } = await client.actions.listWorkflowRunsForRepo({ + owner: REPO_OWNER!, + repo: REPO_NAME!, + head_sha: headSha, + ...(status && { status }), + }); + + // Process runs to create summary + const runs = runsData.workflow_runs || []; + const summary = { + total_runs: runs.length, + failed: 0, + passed: 0, + pending: 0, + }; + + const processedRuns = runs.map((run: any) => { + // Update summary counts + if (run.status === "completed") { + if (run.conclusion === "success") { + summary.passed++; + } else if (run.conclusion === "failure") { + summary.failed++; + } + } else { + summary.pending++; + } + + return { + id: run.id, + name: run.name, + status: run.status, + conclusion: run.conclusion, + html_url: run.html_url, + created_at: run.created_at, + }; + }); + + const result = { + summary, + runs: processedRuns, + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Error: ${errorMessage}`, + }, + ], + error: errorMessage, + isError: true, + }; + } + }, +); + +server.tool( + "get_workflow_run_details", + "Get job and step details for a workflow run", + { + run_id: z.number().describe("The workflow run ID"), + }, + async ({ run_id }) => { + try { + const client = new Octokit({ + auth: GITHUB_TOKEN, + }); + + // Get jobs for this workflow run + const { data: jobsData } = await client.actions.listJobsForWorkflowRun({ + owner: REPO_OWNER!, + repo: REPO_NAME!, + run_id, + }); + + const processedJobs = jobsData.jobs.map((job: any) => { + // Extract failed steps + const failedSteps = (job.steps || []) + .filter((step: any) => step.conclusion === "failure") + .map((step: any) => ({ + name: step.name, + number: step.number, + })); + + return { + id: job.id, + name: job.name, + conclusion: job.conclusion, + html_url: job.html_url, + failed_steps: failedSteps, + }; + }); + + const result = { + jobs: processedJobs, + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Error: ${errorMessage}`, + }, + ], + error: errorMessage, + isError: true, + }; + } + }, +); + +server.tool( + "download_job_log", + "Download job logs to disk", + { + job_id: z.number().describe("The job ID"), + }, + async ({ job_id }) => { + try { + const client = new Octokit({ + auth: GITHUB_TOKEN, + }); + + const response = await client.actions.downloadJobLogsForWorkflowRun({ + owner: REPO_OWNER!, + repo: REPO_NAME!, + job_id, + }); + + const logsText = response.data as unknown as string; + + const logsDir = `${RUNNER_TEMP}/github-ci-logs`; + await mkdir(logsDir, { recursive: true }); + + const logPath = `${logsDir}/job-${job_id}.log`; + await writeFile(logPath, logsText, "utf-8"); + + const result = { + path: logPath, + size_bytes: Buffer.byteLength(logsText, "utf-8"), + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Error: ${errorMessage}`, + }, + ], + error: errorMessage, + isError: true, + }; + } + }, +); + +async function runServer() { + try { + const transport = new StdioServerTransport(); + + await server.connect(transport); + + process.on("exit", () => { + server.close(); + }); + } catch (error) { + throw error; + } +} + +runServer().catch(() => { + process.exit(1); +}); diff --git a/src/mcp/install-mcp-server.ts b/src/mcp/install-mcp-server.ts index 8748f67..d51b195 100644 --- a/src/mcp/install-mcp-server.ts +++ b/src/mcp/install-mcp-server.ts @@ -1,5 +1,7 @@ import * as core from "@actions/core"; import { GITHUB_API_URL } from "../github/api/config"; +import type { ParsedGitHubContext } from "../github/context"; +import { Octokit } from "@octokit/rest"; type PrepareConfigParams = { githubToken: string; @@ -9,8 +11,41 @@ type PrepareConfigParams = { additionalMcpConfig?: string; claudeCommentId?: string; allowedTools: string[]; + context: ParsedGitHubContext; }; +async function checkActionsReadPermission( + token: string, + owner: string, + repo: string, +): Promise { + try { + const client = new Octokit({ auth: token }); + + // Try to list workflow runs - this requires actions:read + // We use per_page=1 to minimize the response size + await client.actions.listWorkflowRunsForRepo({ + owner, + repo, + per_page: 1, + }); + + return true; + } catch (error: any) { + // Check if it's a permission error + if ( + error.status === 403 && + error.message?.includes("Resource not accessible") + ) { + return false; + } + + // For other errors (network issues, etc), log but don't fail + core.debug(`Failed to check actions permission: ${error.message}`); + return false; + } +} + export async function prepareMcpConfig( params: PrepareConfigParams, ): Promise { @@ -22,6 +57,7 @@ export async function prepareMcpConfig( additionalMcpConfig, claudeCommentId, allowedTools, + context, } = params; try { const allowedToolsList = allowedTools || []; @@ -53,6 +89,42 @@ export async function prepareMcpConfig( }, }; + // Only add CI server if we have actions:read permission and we're in a PR context + const hasActionsReadPermission = + context.inputs.additionalPermissions.get("actions") === "read"; + + if (context.isPR && hasActionsReadPermission) { + // Verify the token actually has actions:read permission + const actuallyHasPermission = await checkActionsReadPermission( + process.env.ACTIONS_TOKEN || "", + owner, + repo, + ); + + if (!actuallyHasPermission) { + core.warning( + "The github_ci MCP server requires 'actions: read' permission. " + + "Please ensure your GitHub token has this permission. " + + "See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token", + ); + } + baseMcpConfig.mcpServers.github_ci = { + command: "bun", + args: [ + "run", + `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`, + ], + env: { + // Use workflow github token, not app token + GITHUB_TOKEN: process.env.ACTIONS_TOKEN, + REPO_OWNER: owner, + REPO_NAME: repo, + PR_NUMBER: context.entityNumber.toString(), + RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp", + }, + }; + } + if (hasGitHubMcpTools) { baseMcpConfig.mcpServers.github = { command: "docker", diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index df10668..915d3fe 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -743,6 +743,36 @@ describe("buildAllowedToolsString", () => { expect(basePlusCustom).toContain("Tool2"); expect(basePlusCustom).toContain("Tool3"); }); + + test("should include GitHub Actions tools when includeActionsTools is true", () => { + const result = buildAllowedToolsString([], true); + + // Base tools should be present + expect(result).toContain("Edit"); + expect(result).toContain("Glob"); + + // GitHub Actions tools should be included + expect(result).toContain("mcp__github_ci__get_ci_status"); + expect(result).toContain("mcp__github_ci__get_workflow_run_details"); + expect(result).toContain("mcp__github_ci__download_job_log"); + }); + + test("should include both custom and Actions tools when both provided", () => { + const customTools = ["Tool1", "Tool2"]; + const result = buildAllowedToolsString(customTools, true); + + // Base tools should be present + expect(result).toContain("Edit"); + + // Custom tools should be included + expect(result).toContain("Tool1"); + expect(result).toContain("Tool2"); + + // GitHub Actions tools should be included + expect(result).toContain("mcp__github_ci__get_ci_status"); + expect(result).toContain("mcp__github_ci__get_workflow_run_details"); + expect(result).toContain("mcp__github_ci__download_job_log"); + }); }); describe("buildDisallowedToolsString", () => { diff --git a/test/github/context.test.ts b/test/github/context.test.ts index bfdf026..a2b587e 100644 --- a/test/github/context.test.ts +++ b/test/github/context.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "bun:test"; -import { parseMultilineInput } from "../../src/github/context"; +import { + parseMultilineInput, + parseAdditionalPermissions, +} from "../../src/github/context"; describe("parseMultilineInput", () => { it("should parse a comma-separated string", () => { @@ -55,3 +58,58 @@ Bash(bun typecheck) expect(result).toEqual([]); }); }); + +describe("parseAdditionalPermissions", () => { + it("should parse single permission", () => { + const input = "actions: read"; + const result = parseAdditionalPermissions(input); + expect(result.get("actions")).toBe("read"); + expect(result.size).toBe(1); + }); + + it("should parse multiple permissions", () => { + const input = `actions: read +packages: write +contents: read`; + const result = parseAdditionalPermissions(input); + expect(result.get("actions")).toBe("read"); + expect(result.get("packages")).toBe("write"); + expect(result.get("contents")).toBe("read"); + expect(result.size).toBe(3); + }); + + it("should handle empty string", () => { + const input = ""; + const result = parseAdditionalPermissions(input); + expect(result.size).toBe(0); + }); + + it("should handle whitespace and empty lines", () => { + const input = ` + actions: read + + packages: write + `; + const result = parseAdditionalPermissions(input); + expect(result.get("actions")).toBe("read"); + expect(result.get("packages")).toBe("write"); + expect(result.size).toBe(2); + }); + + it("should ignore lines without colon separator", () => { + const input = `actions: read +invalid line +packages: write`; + const result = parseAdditionalPermissions(input); + expect(result.get("actions")).toBe("read"); + expect(result.get("packages")).toBe("write"); + expect(result.size).toBe(2); + }); + + it("should trim whitespace around keys and values", () => { + const input = " actions : read "; + const result = parseAdditionalPermissions(input); + expect(result.get("actions")).toBe("read"); + expect(result.size).toBe(1); + }); +}); diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 4dbb32d..c9485bc 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { prepareMcpConfig } from "../src/mcp/install-mcp-server"; import * as core from "@actions/core"; +import type { ParsedGitHubContext } from "../src/github/context"; describe("prepareMcpConfig", () => { let consoleInfoSpy: any; @@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => { let setFailedSpy: any; let processExitSpy: any; + // Create a mock context for tests + const mockContext: ParsedGitHubContext = { + runId: "test-run-id", + eventName: "issue_comment", + eventAction: "created", + repository: { + owner: "test-owner", + repo: "test-repo", + full_name: "test-owner/test-repo", + }, + actor: "test-actor", + payload: {} as any, + entityNumber: 123, + isPR: false, + inputs: { + triggerPhrase: "@claude", + assigneeTrigger: "", + labelTrigger: "", + allowedTools: [], + disallowedTools: [], + customInstructions: "", + directPrompt: "", + branchPrefix: "", + useStickyComment: false, + additionalPermissions: new Map(), + }, + }; + + const mockPRContext: ParsedGitHubContext = { + ...mockContext, + eventName: "pull_request", + isPR: true, + entityNumber: 456, + }; + beforeEach(() => { consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); @@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => { processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("Process exit"); }); + + // Set up required environment variables + if (!process.env.GITHUB_ACTION_PATH) { + process.env.GITHUB_ACTION_PATH = "/test/action/path"; + } }); afterEach(() => { @@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => { repo: "test-repo", branch: "test-branch", allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => { "mcp__github__create_issue", "mcp__github_file_ops__commit_files", ], + context: mockContext, }); const parsed = JSON.parse(result); @@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => { "mcp__github_file_ops__commit_files", "mcp__github_file_ops__update_claude_comment", ], + context: mockContext, }); const parsed = JSON.parse(result); @@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => { repo: "test-repo", branch: "test-branch", allowedTools: ["Edit", "Read", "Write"], + context: mockContext, }); const parsed = JSON.parse(result); @@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: "", allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: " \n\t ", allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => { "mcp__github__create_issue", "mcp__github_file_ops__commit_files", ], + context: mockContext, }); const parsed = JSON.parse(result); @@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => { "mcp__github__create_issue", "mcp__github_file_ops__commit_files", ], + context: mockContext, }); const parsed = JSON.parse(result); @@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: additionalConfig, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: invalidJson, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: nonObjectJson, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: nullJson, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: arrayJson, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => { branch: "test-branch", additionalMcpConfig: additionalConfig, allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => { repo: "test-repo", branch: "test-branch", allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => { repo: "test-repo", branch: "test-branch", allowedTools: [], + context: mockContext, }); const parsed = JSON.parse(result); @@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => { process.env.GITHUB_WORKSPACE = oldEnv; }); + + test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => { + const oldEnv = process.env.ACTIONS_TOKEN; + process.env.ACTIONS_TOKEN = "workflow-token"; + + const contextWithPermissions = { + ...mockPRContext, + inputs: { + ...mockPRContext.inputs, + additionalPermissions: new Map([["actions", "read"]]), + }, + }; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + allowedTools: [], + context: contextWithPermissions, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).toBeDefined(); + expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token"); + expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456"); + expect(parsed.mcpServers.github_file_ops).toBeDefined(); + + process.env.ACTIONS_TOKEN = oldEnv; + }); + + test("should not include github_ci server when context.isPR is false", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + allowedTools: [], + context: mockContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).not.toBeDefined(); + expect(parsed.mcpServers.github_file_ops).toBeDefined(); + }); + + test("should not include github_ci server when actions:read permission is not granted", async () => { + const oldTokenEnv = process.env.ACTIONS_TOKEN; + process.env.ACTIONS_TOKEN = "workflow-token"; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + allowedTools: [], + context: mockPRContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).not.toBeDefined(); + expect(parsed.mcpServers.github_file_ops).toBeDefined(); + + process.env.ACTIONS_TOKEN = oldTokenEnv; + }); + + test("should parse additional_permissions with multiple lines correctly", async () => { + const oldTokenEnv = process.env.ACTIONS_TOKEN; + process.env.ACTIONS_TOKEN = "workflow-token"; + + const contextWithPermissions = { + ...mockPRContext, + inputs: { + ...mockPRContext.inputs, + additionalPermissions: new Map([ + ["actions", "read"], + ["future", "permission"], + ]), + }, + }; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + allowedTools: [], + context: contextWithPermissions, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).toBeDefined(); + expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token"); + + process.env.ACTIONS_TOKEN = oldTokenEnv; + }); + + test("should warn when actions:read is requested but token lacks permission", async () => { + const oldTokenEnv = process.env.ACTIONS_TOKEN; + process.env.ACTIONS_TOKEN = "invalid-token"; + + const contextWithPermissions = { + ...mockPRContext, + inputs: { + ...mockPRContext.inputs, + additionalPermissions: new Map([["actions", "read"]]), + }, + }; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + allowedTools: [], + context: contextWithPermissions, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).toBeDefined(); + expect(consoleWarningSpy).toHaveBeenCalledWith( + expect.stringContaining( + "The github_ci MCP server requires 'actions: read' permission", + ), + ); + + process.env.ACTIONS_TOKEN = oldTokenEnv; + }); }); diff --git a/test/mockContext.ts b/test/mockContext.ts index a60a80a..8db88da 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -21,6 +21,7 @@ const defaultInputs = { timeoutMinutes: 30, branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }; const defaultRepository = { diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 9343e98..2fb2443 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -69,6 +69,7 @@ describe("checkWritePermissions", () => { directPrompt: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index 0d16d6d..eba2b3c 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -37,6 +37,7 @@ describe("checkContainsTrigger", () => { customInstructions: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -66,6 +67,7 @@ describe("checkContainsTrigger", () => { customInstructions: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); expect(checkContainsTrigger(context)).toBe(false); @@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => { customInstructions: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => { customInstructions: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); expect(checkContainsTrigger(context)).toBe(true); @@ -339,6 +343,7 @@ describe("checkContainsTrigger", () => { customInstructions: "", branchPrefix: "claude/", useStickyComment: false, + additionalPermissions: new Map(), }, }); expect(checkContainsTrigger(context)).toBe(false);