mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
27 Commits
v0.0.55
...
km-anthrop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8478624b | ||
|
|
194fca8b05 | ||
|
|
0f913a6e0e | ||
|
|
68b7ca379c | ||
|
|
900322ca88 | ||
|
|
8f0a7fe9d3 | ||
|
|
db36412854 | ||
|
|
f05d669d5f | ||
|
|
e89411bb6f | ||
|
|
02e9ed3181 | ||
|
|
78b07473f5 | ||
|
|
f562ed53e2 | ||
|
|
a1507aefdc | ||
|
|
ae66eb6a64 | ||
|
|
432c7cc889 | ||
|
|
0b138d9d49 | ||
|
|
c34e066a3b | ||
|
|
449c6791bd | ||
|
|
2b67ac084b | ||
|
|
76de8a48fc | ||
|
|
a80505bbfb | ||
|
|
af23644a50 | ||
|
|
98e6a902bf | ||
|
|
8b2bd6d04f | ||
|
|
4f4f43f044 | ||
|
|
8a5d751740 | ||
|
|
bc423b47f5 |
102
.claude/commands/fix-ci.md
Normal file
102
.claude/commands/fix-ci.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
description: Analyze and fix CI failures by examining logs and making targeted fixes
|
||||
allowed_tools: Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash(git:*),Bash(bun:*),Bash(npm:*),Bash(npx:*),Bash(gh:*)
|
||||
---
|
||||
|
||||
# Fix CI Failures
|
||||
|
||||
You are tasked with analyzing CI failure logs and fixing the issues. Follow these steps:
|
||||
|
||||
## Context Provided
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
## Important Context Information
|
||||
|
||||
Look for these key pieces of information in the arguments:
|
||||
|
||||
- **Failed CI Run URL**: Link to the failed CI run
|
||||
- **Failed Jobs**: List of jobs that failed
|
||||
- **PR Number**: The PR number to comment on
|
||||
- **Branch Name**: The fix branch you're working on
|
||||
- **Base Branch**: The original PR branch
|
||||
- **Error logs**: Detailed logs from failed jobs
|
||||
|
||||
## Step 1: Analyze the Failure
|
||||
|
||||
Parse the provided CI failure information to understand:
|
||||
|
||||
- Which jobs failed and why
|
||||
- The specific error messages and stack traces
|
||||
- Whether failures are test-related, build-related, or linting issues
|
||||
|
||||
## Step 2: Search and Understand the Codebase
|
||||
|
||||
Use search tools to locate the failing code:
|
||||
|
||||
- Search for the failing test names or functions
|
||||
- Find the source files mentioned in error messages
|
||||
- Review related configuration files (package.json, tsconfig.json, etc.)
|
||||
|
||||
## Step 3: Apply Targeted Fixes
|
||||
|
||||
Make minimal, focused changes:
|
||||
|
||||
- **For test failures**: Determine if the test or implementation needs fixing
|
||||
- **For type errors**: Fix type definitions or correct the code logic
|
||||
- **For linting issues**: Apply formatting using the project's tools
|
||||
- **For build errors**: Resolve dependency or configuration issues
|
||||
- **For missing imports**: Add the necessary imports or install packages
|
||||
|
||||
Requirements:
|
||||
|
||||
- Only fix the actual CI failures, avoid unrelated changes
|
||||
- Follow existing code patterns and conventions
|
||||
- Ensure changes are production-ready, not temporary hacks
|
||||
- Preserve existing functionality while fixing issues
|
||||
|
||||
## Step 4: Commit and Push Changes
|
||||
|
||||
After applying ALL fixes:
|
||||
|
||||
1. Stage all modified files with `git add -A`
|
||||
2. Commit with: `git commit -m "Fix CI failures: [describe specific fixes]"`
|
||||
3. Document which CI jobs/tests were addressed
|
||||
4. **CRITICAL**: Push the branch with `git push origin HEAD` - You MUST push the branch after committing
|
||||
|
||||
## Step 5: Create PR Comment
|
||||
|
||||
After successfully pushing the fixes, create a comment on the original PR to notify about the auto-fix:
|
||||
|
||||
1. Extract the PR number, branch name, and base branch from the context provided
|
||||
2. Use gh CLI to create a comment with the fix information
|
||||
3. Include a link to create a pull request from the fix branch
|
||||
|
||||
Use this command format (replace placeholders with actual values):
|
||||
```bash
|
||||
gh pr comment PR_NUMBER --body "## 🤖 CI Auto-Fix Available
|
||||
|
||||
Claude has analyzed the CI failures and prepared fixes.
|
||||
|
||||
[**→ Create pull request to fix CI**](https://github.com/OWNER/REPO/compare/BASE_BRANCH...FIX_BRANCH?quick_pull=1)
|
||||
|
||||
_This fix was generated automatically based on the failed CI run._"
|
||||
```
|
||||
|
||||
## Step 6: Verify Fixes Locally
|
||||
|
||||
Run available verification commands:
|
||||
|
||||
- Execute the failing tests locally to confirm they pass
|
||||
- Run the project's lint command (check package.json for scripts)
|
||||
- Run type checking if available
|
||||
- Execute any build commands to ensure compilation succeeds
|
||||
|
||||
## Important Guidelines
|
||||
|
||||
- Focus exclusively on fixing the reported CI failures
|
||||
- Maintain code quality and follow the project's established patterns
|
||||
- If a fix requires significant refactoring, document why it's necessary
|
||||
- When multiple solutions exist, choose the simplest one that maintains code quality
|
||||
|
||||
Begin by analyzing the failure details provided above.
|
||||
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -104,3 +104,5 @@ jobs:
|
||||
mcp_config: /tmp/mcp-config/mcp-servers.json
|
||||
timeout_minutes: "5"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
13
README.md
13
README.md
@@ -14,6 +14,19 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
|
||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||
|
||||
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
|
||||
|
||||
**We're planning a major update that will significantly change how this action works.** The new version will:
|
||||
|
||||
- ✨ Automatically select the appropriate mode (no more `mode` input)
|
||||
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
|
||||
- 🚀 Align more closely with the Claude Code SDK capabilities
|
||||
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
|
||||
|
||||
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
||||
|
||||
@@ -166,6 +166,7 @@ runs:
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
@@ -177,7 +178,8 @@ runs:
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
bun install -g @anthropic-ai/claude-code@1.0.71
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
@@ -211,6 +213,7 @@ runs:
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||
|
||||
# Model configuration
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
@@ -285,7 +288,7 @@ runs:
|
||||
fi
|
||||
|
||||
- name: Revoke app token
|
||||
if: always() && inputs.github_token == ''
|
||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L \
|
||||
|
||||
@@ -118,7 +118,7 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.71
|
||||
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -110,6 +110,10 @@ export function prepareRunConfig(
|
||||
// Parse custom environment variables
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
|
||||
return {
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
@@ -142,9 +146,11 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
if (Object.keys(config.env).length > 0) {
|
||||
const envKeys = Object.keys(config.env).join(", ");
|
||||
console.log(`Custom environment variables: ${envKeys}`);
|
||||
const customEnvKeys = Object.keys(config.env).filter(
|
||||
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
||||
);
|
||||
if (customEnvKeys.length > 0) {
|
||||
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
// Output to console
|
||||
|
||||
@@ -207,15 +207,8 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
allowed_tools: |
|
||||
Bash(npm install)
|
||||
Bash(npm run test)
|
||||
Edit
|
||||
Replace
|
||||
NotebookEditCell
|
||||
disallowed_tools: |
|
||||
TaskOutput
|
||||
KillTask
|
||||
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
|
||||
disallowed_tools: "TaskOutput,KillTask"
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Claude PR Assistant
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -11,38 +11,53 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude-code-action:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
timeout_minutes: "60"
|
||||
# mode: tag # Default: responds to @claude mentions
|
||||
# Optional: Restrict network access to specific domains only
|
||||
# experimental_allowed_domains: |
|
||||
# .anthropic.com
|
||||
# .github.com
|
||||
# api.github.com
|
||||
# .githubusercontent.com
|
||||
# bun.sh
|
||||
# registry.npmjs.org
|
||||
# .blob.core.windows.net
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
|
||||
@@ -836,7 +836,7 @@ export async function createPrompt(
|
||||
modeContext.claudeBranch,
|
||||
);
|
||||
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -855,7 +855,7 @@ export async function createPrompt(
|
||||
|
||||
// Write the prompt file
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
|
||||
59
src/entrypoints/collect-inputs.ts
Normal file
59
src/entrypoints/collect-inputs.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function collectActionInputsPresence(): void {
|
||||
const inputDefaults: Record<string, string> = {
|
||||
trigger_phrase: "@claude",
|
||||
assignee_trigger: "",
|
||||
label_trigger: "claude",
|
||||
base_branch: "",
|
||||
branch_prefix: "claude/",
|
||||
allowed_bots: "",
|
||||
mode: "tag",
|
||||
model: "",
|
||||
anthropic_model: "",
|
||||
fallback_model: "",
|
||||
allowed_tools: "",
|
||||
disallowed_tools: "",
|
||||
custom_instructions: "",
|
||||
direct_prompt: "",
|
||||
override_prompt: "",
|
||||
mcp_config: "",
|
||||
additional_permissions: "",
|
||||
claude_env: "",
|
||||
settings: "",
|
||||
anthropic_api_key: "",
|
||||
claude_code_oauth_token: "",
|
||||
github_token: "",
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
use_commit_signing: "false",
|
||||
experimental_allowed_domains: "",
|
||||
};
|
||||
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
if (!allInputsJson) {
|
||||
console.log("ALL_INPUTS environment variable not found");
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
|
||||
let allInputs: Record<string, string>;
|
||||
try {
|
||||
allInputs = JSON.parse(allInputsJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ALL_INPUTS JSON:", e);
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
|
||||
const presentInputs: Record<string, boolean> = {};
|
||||
|
||||
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
|
||||
const actualValue = allInputs[name] || "";
|
||||
|
||||
const isSet = actualValue !== defaultValue;
|
||||
presentInputs[name] = isSet;
|
||||
}
|
||||
|
||||
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
|
||||
}
|
||||
@@ -13,9 +13,12 @@ import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
|
||||
import type { ModeName } from "../modes/types";
|
||||
import { prepare } from "../prepare";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
collectActionInputsPresence();
|
||||
|
||||
// Step 1: Get mode first to determine authentication method
|
||||
const modeInput = process.env.MODE || DEFAULT_MODE;
|
||||
|
||||
|
||||
@@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const responseJson = (await response.json()) as {
|
||||
error?: {
|
||||
message?: string;
|
||||
details?: {
|
||||
error_code?: string;
|
||||
};
|
||||
};
|
||||
type?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// Check for specific workflow validation error codes that should skip the action
|
||||
const errorCode = responseJson.error?.details?.error_code;
|
||||
|
||||
if (errorCode === "workflow_not_found_on_default_branch") {
|
||||
const message =
|
||||
responseJson.message ??
|
||||
responseJson.error?.message ??
|
||||
"Workflow validation failed";
|
||||
core.warning(`Skipping action due to workflow validation: ${message}`);
|
||||
console.log(
|
||||
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
|
||||
);
|
||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
||||
);
|
||||
@@ -77,8 +99,9 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
core.setOutput("GITHUB_TOKEN", appToken);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
// Only set failed if we get here - workflow validation errors will exit(0) before this
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
`Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string {
|
||||
content = stripMarkdownLinkTitles(content);
|
||||
content = stripHiddenAttributes(content);
|
||||
content = normalizeHtmlEntities(content);
|
||||
content = redactGitHubTokens(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
export function redactGitHubTokens(content: string): string {
|
||||
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghp_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bgho_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghr_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
|
||||
content = content.replace(
|
||||
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -54,11 +55,13 @@ server.tool(
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body,
|
||||
body: sanitizedBody,
|
||||
isPullRequestReviewComment,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile } from "fs/promises";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { constants } from "fs";
|
||||
import fetch from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
@@ -162,6 +163,34 @@ async function getOrCreateBranchRef(
|
||||
return baseSha;
|
||||
}
|
||||
|
||||
// Get the appropriate Git file mode for a file
|
||||
async function getFileMode(filePath: string): Promise<string> {
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
if (fileStat.isFile()) {
|
||||
// Check if execute bit is set for user
|
||||
if (fileStat.mode & constants.S_IXUSR) {
|
||||
return "100755"; // Executable file
|
||||
} else {
|
||||
return "100644"; // Regular file
|
||||
}
|
||||
} else if (fileStat.isDirectory()) {
|
||||
return "040000"; // Directory (tree)
|
||||
} else if (fileStat.isSymbolicLink()) {
|
||||
return "120000"; // Symbolic link
|
||||
} else {
|
||||
// Fallback for unknown file types
|
||||
return "100644";
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't stat the file, default to regular file
|
||||
console.warn(
|
||||
`Could not determine file mode for ${filePath}, using default: ${error}`,
|
||||
);
|
||||
return "100644";
|
||||
}
|
||||
}
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
@@ -223,6 +252,9 @@ server.tool(
|
||||
? filePath
|
||||
: join(REPO_DIR, filePath);
|
||||
|
||||
// Get the proper file mode based on file permissions
|
||||
const fileMode = await getFileMode(fullPath);
|
||||
|
||||
// Check if file is binary (images, etc.)
|
||||
const isBinaryFile =
|
||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||
@@ -261,7 +293,7 @@ server.tool(
|
||||
// Return tree entry with blob SHA
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
mode: fileMode,
|
||||
type: "blob",
|
||||
sha: blobData.sha,
|
||||
};
|
||||
@@ -270,7 +302,7 @@ server.tool(
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
mode: fileMode,
|
||||
type: "blob",
|
||||
content: content,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -81,6 +82,9 @@ server.tool(
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Sanitize the comment body to remove any potential GitHub tokens
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
throw new Error(
|
||||
@@ -104,7 +108,7 @@ server.tool(
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body,
|
||||
body: sanitizedBody,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const agentMode: Mode = {
|
||||
|
||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||
@@ -57,7 +57,7 @@ export const agentMode: Mode = {
|
||||
context.inputs.directPrompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
@@ -80,9 +80,8 @@ export const agentMode: Mode = {
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
// Agent mode uses a minimal MCP configuration
|
||||
// We don't need comment servers or PR-specific tools for automation
|
||||
|
||||
@@ -297,9 +297,8 @@ This ensures users get value from the review even before checking individual inl
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
// Export as INPUT_ prefixed variables for the base action
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import type { GitHubContext } from "../../src/github/context";
|
||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
describe("Agent Mode", () => {
|
||||
let mockContext: GitHubContext;
|
||||
let exportVariableSpy: any;
|
||||
let setOutputSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exportVariableSpy?.mockClear();
|
||||
setOutputSpy?.mockClear();
|
||||
exportVariableSpy?.mockRestore();
|
||||
setOutputSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test("agent mode has correct properties", () => {
|
||||
@@ -56,4 +70,67 @@ describe("Agent Mode", () => {
|
||||
expect(agentMode.shouldTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method sets up tools environment variables correctly", async () => {
|
||||
// Clear any previous calls before this test
|
||||
exportVariableSpy.mockClear();
|
||||
setOutputSpy.mockClear();
|
||||
|
||||
const contextWithCustomTools = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
|
||||
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
const result = await agentMode.prepare({
|
||||
context: contextWithCustomTools,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"ALLOWED_TOOLS",
|
||||
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
|
||||
);
|
||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
||||
"DISALLOWED_TOOLS",
|
||||
"WebSearch,WebFetch,BadTool",
|
||||
);
|
||||
|
||||
// Verify MCP config is set
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
|
||||
// Verify return structure
|
||||
expect(result).toEqual({
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "",
|
||||
currentBranch: "",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
|
||||
contextWithPrompts.inputs.directPrompt =
|
||||
"Direct prompt (should be ignored)";
|
||||
|
||||
const mockOctokit = {} as any;
|
||||
await agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Note: We can't easily test file creation in this unit test,
|
||||
// but we can verify the method completes without errors
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeHtmlEntities,
|
||||
sanitizeContent,
|
||||
stripHtmlComments,
|
||||
redactGitHubTokens,
|
||||
} from "../src/github/utils/sanitizer";
|
||||
|
||||
describe("stripInvisibleCharacters", () => {
|
||||
@@ -242,6 +243,109 @@ describe("sanitizeContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactGitHubTokens", () => {
|
||||
it("should redact personal access tokens (ghp_)", () => {
|
||||
const token = "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";
|
||||
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
|
||||
"Token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
|
||||
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact OAuth tokens (gho_)", () => {
|
||||
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
|
||||
"OAuth: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact installation tokens (ghs_)", () => {
|
||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||
"Install token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact refresh tokens (ghr_)", () => {
|
||||
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
|
||||
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
|
||||
"Refresh: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact fine-grained tokens (github_pat_)", () => {
|
||||
const token =
|
||||
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
|
||||
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
|
||||
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in code blocks", () => {
|
||||
const content = `\`\`\`bash
|
||||
export GITHUB_TOKEN=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
|
||||
\`\`\``;
|
||||
const expected = `\`\`\`bash
|
||||
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
|
||||
\`\`\``;
|
||||
expect(redactGitHubTokens(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle multiple tokens in one text", () => {
|
||||
const content =
|
||||
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in URLs", () => {
|
||||
const content =
|
||||
"https://api.github.com/user?access_token=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";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact partial matches or invalid tokens", () => {
|
||||
const content =
|
||||
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should preserve normal text", () => {
|
||||
const content = "Normal text with no tokens";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle edge cases", () => {
|
||||
expect(redactGitHubTokens("")).toBe("");
|
||||
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
|
||||
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeContent with token redaction", () => {
|
||||
it("should redact tokens as part of full sanitization", () => {
|
||||
const content = `
|
||||
<!-- Hidden comment with token: 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 -->
|
||||
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
|
||||
And invisible chars: test\u200Btoken
|
||||
`;
|
||||
|
||||
const sanitized = sanitizeContent(content);
|
||||
|
||||
expect(sanitized).not.toContain("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");
|
||||
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
|
||||
expect(sanitized).not.toContain("<!-- Hidden comment");
|
||||
expect(sanitized).not.toContain("\u200B");
|
||||
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(sanitized).toContain("Here's some text with a token:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtmlComments (legacy)", () => {
|
||||
it("should remove HTML comments", () => {
|
||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||
|
||||
Reference in New Issue
Block a user