mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
12 Commits
v0.0.52
...
feat/ttyd-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7680c1d501 | ||
|
|
dee63efcf4 | ||
|
|
7afc848186 | ||
|
|
6debac392b | ||
|
|
55fb6a96d0 | ||
|
|
15db2b3c79 | ||
|
|
188d526721 | ||
|
|
a519840051 | ||
|
|
85287e957d | ||
|
|
c6a07895d7 | ||
|
|
0c5d54472f | ||
|
|
2845685880 |
16
.github/workflows/claude.yml
vendored
16
.github/workflows/claude.yml
vendored
@@ -9,10 +9,21 @@ on:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cloudflare_tunnel_token:
|
||||
description: 'Cloudflare tunnel token to expose Claude UI via browser'
|
||||
required: false
|
||||
type: string
|
||||
direct_prompt:
|
||||
description: 'Direct instruction for Claude'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(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')) ||
|
||||
@@ -36,4 +47,7 @@ jobs:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||
model: "claude-opus-4-20250514"
|
||||
model: "claude-opus-4-1-20250805"
|
||||
cloudflare_tunnel_token: ${{ github.event.inputs.cloudflare_tunnel_token }}
|
||||
direct_prompt: ${{ github.event.inputs.direct_prompt }}
|
||||
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
|
||||
|
||||
@@ -114,6 +114,10 @@ inputs:
|
||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||
required: false
|
||||
default: ""
|
||||
cloudflare_tunnel_token:
|
||||
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
execution_file:
|
||||
@@ -172,7 +176,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.70
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
@@ -206,6 +210,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_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
||||
|
||||
# Model configuration
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
|
||||
@@ -69,7 +69,7 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review and fix TypeScript errors"
|
||||
model: "claude-opus-4-20250514"
|
||||
model: "claude-opus-4-1-20250805"
|
||||
fallback_model: "claude-sonnet-4-20250514"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
@@ -217,7 +217,7 @@ Provide the settings configuration directly as a JSON string:
|
||||
prompt: "Your prompt here"
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-20250514",
|
||||
"model": "claude-opus-4-1-20250805",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
|
||||
@@ -87,6 +87,10 @@ inputs:
|
||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||
required: false
|
||||
default: "false"
|
||||
cloudflare_tunnel_token:
|
||||
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
conclusion:
|
||||
@@ -118,7 +122,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.70
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
@@ -147,6 +151,7 @@ runs:
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
|
||||
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
|
||||
import { runClaude } from "./run-claude";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -21,7 +22,40 @@ async function run() {
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
});
|
||||
|
||||
await runClaude(promptConfig.path, {
|
||||
// Setup ttyd and cloudflared tunnel if token provided
|
||||
let ttydProcess: any = null;
|
||||
let cloudflaredProcess: any = null;
|
||||
|
||||
if (process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN) {
|
||||
console.log("Setting up ttyd and cloudflared tunnel...");
|
||||
|
||||
// Start ttyd process in background
|
||||
ttydProcess = spawn("ttyd", ["-p", "7681", "-i", "0.0.0.0", "claude"], {
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
});
|
||||
|
||||
ttydProcess.on("error", (error: Error) => {
|
||||
console.warn(`ttyd process error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Start cloudflared tunnel
|
||||
cloudflaredProcess = spawn("cloudflared", ["tunnel", "run", "--token", process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN], {
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
});
|
||||
|
||||
cloudflaredProcess.on("error", (error: Error) => {
|
||||
console.warn(`cloudflared process error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Give processes time to start up
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
console.log("ttyd and cloudflared tunnel started");
|
||||
}
|
||||
|
||||
try {
|
||||
await runClaude(promptConfig.path, {
|
||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||
@@ -32,6 +66,23 @@ async function run() {
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
});
|
||||
} finally {
|
||||
// Clean up processes
|
||||
if (ttydProcess) {
|
||||
try {
|
||||
ttydProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
console.warn("Failed to terminate ttyd process");
|
||||
}
|
||||
}
|
||||
if (cloudflaredProcess) {
|
||||
try {
|
||||
cloudflaredProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
console.warn("Failed to terminate cloudflared process");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
|
||||
// Then, add new settings
|
||||
const newSettings = JSON.stringify({
|
||||
newKey: "newValue",
|
||||
model: "claude-opus-4-20250514",
|
||||
model: "claude-opus-4-1-20250805",
|
||||
});
|
||||
|
||||
await setupClaudeCodeSettings(newSettings, testHomeDir);
|
||||
@@ -145,7 +145,7 @@ describe("setupClaudeCodeSettings", () => {
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.existingKey).toBe("existingValue");
|
||||
expect(settings.newKey).toBe("newValue");
|
||||
expect(settings.model).toBe("claude-opus-4-20250514");
|
||||
expect(settings.model).toBe("claude-opus-4-1-20250805");
|
||||
});
|
||||
|
||||
test("should copy slash commands to .claude directory when path provided", async () => {
|
||||
|
||||
@@ -252,7 +252,7 @@ You can provide Claude Code settings to customize behavior such as model selecti
|
||||
with:
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-20250514",
|
||||
"model": "claude-opus-4-1-20250805",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
|
||||
@@ -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!"
|
||||
@@ -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:*)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
`<img[^>]+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 });
|
||||
|
||||
178
src/mcp/github-inline-comment-server.ts
Normal file
178
src/mcp/github-inline-comment-server.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/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 { createOctokit } from "../github/api/client";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const PR_NUMBER = process.env.PR_NUMBER;
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and PR_NUMBER environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GitHub Inline Comment MCP Server - Provides inline PR comment functionality
|
||||
// Provides an inline comment tool without exposing full PR review capabilities, so that
|
||||
// Claude can't accidentally approve a PR
|
||||
const server = new McpServer({
|
||||
name: "GitHub Inline Comment Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"create_inline_comment",
|
||||
"Create an inline comment on a specific line or lines in a PR file",
|
||||
{
|
||||
path: z
|
||||
.string()
|
||||
.describe("The file path to comment on (e.g., 'src/index.js')"),
|
||||
body: z
|
||||
.string()
|
||||
.describe(
|
||||
"The comment text (supports markdown and GitHub code suggestion blocks). " +
|
||||
"For code suggestions, use: ```suggestion\\nreplacement code\\n```. " +
|
||||
"IMPORTANT: The suggestion block will REPLACE the ENTIRE line range (single line or startLine to line). " +
|
||||
"Ensure the replacement is syntactically complete and valid - it must work as a drop-in replacement for the selected lines.",
|
||||
),
|
||||
line: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Line number for single-line comments (required if startLine is not provided)",
|
||||
),
|
||||
startLine: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Start line for multi-line comments (use with line parameter for the end line)",
|
||||
),
|
||||
side: z
|
||||
.enum(["LEFT", "RIGHT"])
|
||||
.optional()
|
||||
.default("RIGHT")
|
||||
.describe(
|
||||
"Side of the diff to comment on: LEFT (old code) or RIGHT (new code)",
|
||||
),
|
||||
commit_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Specific commit SHA to comment on (defaults to latest commit)",
|
||||
),
|
||||
},
|
||||
async ({ path, body, line, startLine, side, commit_id }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const pull_number = parseInt(PR_NUMBER, 10);
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
throw new Error(
|
||||
"Either 'line' for single-line comments or both 'startLine' and 'line' for multi-line comments must be provided",
|
||||
);
|
||||
}
|
||||
|
||||
// If only line is provided, it's a single-line comment
|
||||
// If both startLine and line are provided, it's a multi-line comment
|
||||
const isSingleLine = !startLine;
|
||||
|
||||
const pr = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const params: Parameters<
|
||||
typeof octokit.rest.pulls.createReviewComment
|
||||
>[0] = {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
};
|
||||
|
||||
if (isSingleLine) {
|
||||
// Single-line comment
|
||||
params.line = line;
|
||||
} else {
|
||||
// Multi-line comment
|
||||
params.start_line = startLine;
|
||||
params.start_side = side || "RIGHT";
|
||||
params.line = line;
|
||||
}
|
||||
|
||||
const result = await octokit.rest.pulls.createReviewComment(params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
comment_id: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
path: result.data.path,
|
||||
line: result.data.line || result.data.original_line,
|
||||
message: `Inline comment created successfully on ${path}${isSingleLine ? ` at line ${line}` : ` from line ${startLine} to ${line}`}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Provide more helpful error messages for common issues
|
||||
let helpMessage = "";
|
||||
if (errorMessage.includes("Validation Failed")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the line number doesn't exist in the diff or the file path is incorrect. Make sure you're commenting on lines that are part of the PR's changes.";
|
||||
} else if (errorMessage.includes("Not Found")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the PR number, repository, or file path is incorrect.";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating inline comment: ${errorMessage}${helpMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -111,6 +111,24 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Include inline comment server for experimental review mode
|
||||
if (context.inputs.mode === "experimental-review" && context.isPR) {
|
||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
@@ -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<ModeResult> {
|
||||
// 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 = [
|
||||
|
||||
@@ -60,20 +60,8 @@ export const reviewMode: Mode = {
|
||||
|
||||
getAllowedTools() {
|
||||
return [
|
||||
// Context tools - to know who the current user is
|
||||
"mcp__github__get_me",
|
||||
// Core review tools
|
||||
"mcp__github__create_pending_pull_request_review",
|
||||
"mcp__github__add_comment_to_pending_review",
|
||||
"mcp__github__submit_pending_pull_request_review",
|
||||
"mcp__github__delete_pending_pull_request_review",
|
||||
"mcp__github__create_and_submit_pull_request_review",
|
||||
// Comment tools
|
||||
"mcp__github__add_issue_comment",
|
||||
// PR information tools
|
||||
"mcp__github__get_pull_request",
|
||||
"mcp__github__get_pull_request_reviews",
|
||||
"mcp__github__get_pull_request_status",
|
||||
"Bash(gh issue comment:*)",
|
||||
"mcp__github_inline_comment__create_inline_comment",
|
||||
];
|
||||
},
|
||||
|
||||
@@ -115,6 +103,9 @@ export const reviewMode: Mode = {
|
||||
? formatBody(contextData.body, imageUrlMap)
|
||||
: "No description provided";
|
||||
|
||||
// Using a variable for code blocks to avoid escaping backticks in the template string
|
||||
const codeBlock = "```";
|
||||
|
||||
return `You are Claude, an AI assistant specialized in code reviews for GitHub pull requests. You are operating in REVIEW MODE, which means you should focus on providing thorough code review feedback using GitHub MCP tools for inline comments and suggestions.
|
||||
|
||||
<formatted_context>
|
||||
@@ -163,68 +154,50 @@ REVIEW MODE WORKFLOW:
|
||||
|
||||
1. First, understand the PR context:
|
||||
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository}
|
||||
- Use mcp__github__get_pull_request to get PR metadata
|
||||
- Use the Read, Grep, and Glob tools to examine the modified files directly from disk
|
||||
- This provides the full context and latest state of the code
|
||||
- Look at the changed_files section above to see which files were modified
|
||||
|
||||
2. Create a pending review:
|
||||
- Use mcp__github__create_pending_pull_request_review to start your review
|
||||
- This allows you to batch comments before submitting
|
||||
|
||||
3. Add inline comments:
|
||||
- Use mcp__github__add_comment_to_pending_review for each issue or suggestion
|
||||
- Parameters:
|
||||
* path: The file path (e.g., "src/index.js")
|
||||
* line: Line number for single-line comments
|
||||
* startLine & line: For multi-line comments (startLine is the first line, line is the last)
|
||||
* side: "LEFT" (old code) or "RIGHT" (new code)
|
||||
* subjectType: "line" for line-level comments
|
||||
* body: Your comment text
|
||||
2. Create review comments using GitHub MCP tools:
|
||||
- Use Bash(gh issue comment:*) for general PR-level comments
|
||||
- Use mcp__github_inline_comment__create_inline_comment for line-specific feedback (strongly preferred)
|
||||
|
||||
- When to use multi-line comments:
|
||||
* When replacing multiple consecutive lines
|
||||
* When the fix requires changes across several lines
|
||||
* Example: To replace lines 19-20, use startLine: 19, line: 20
|
||||
3. When creating inline comments with suggestions:
|
||||
CRITICAL: GitHub's suggestion blocks REPLACE the ENTIRE line range you select
|
||||
- For single-line comments: Use 'line' parameter only
|
||||
- For multi-line comments: Use both 'startLine' and 'line' parameters
|
||||
- The 'body' parameter should contain your comment and/or suggestion block
|
||||
|
||||
- For code suggestions, use this EXACT format in the body:
|
||||
\`\`\`suggestion
|
||||
corrected code here
|
||||
\`\`\`
|
||||
How to write code suggestions correctly:
|
||||
a) To remove a line (e.g., removing console.log on line 22):
|
||||
- Set line: 22
|
||||
- Body: ${codeBlock}suggestion
|
||||
${codeBlock}
|
||||
(Empty suggestion block removes the line)
|
||||
|
||||
CRITICAL: GitHub suggestion blocks must ONLY contain the replacement for the specific line(s) being commented on:
|
||||
- For single-line comments: Replace ONLY that line
|
||||
- For multi-line comments: Replace ONLY the lines in the range
|
||||
- Do NOT include surrounding context or function signatures
|
||||
- Do NOT suggest changes that span beyond the commented lines
|
||||
b) To modify a single line (e.g., fixing line 22):
|
||||
- Set line: 22
|
||||
- Body: ${codeBlock}suggestion
|
||||
await this.emailInput.fill(email);
|
||||
${codeBlock}
|
||||
|
||||
Example for line 19 \`var name = user.name;\`:
|
||||
WRONG:
|
||||
\\\`\\\`\\\`suggestion
|
||||
function processUser(user) {
|
||||
if (!user) throw new Error('Invalid user');
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
c) To replace multiple lines (e.g., lines 21-23):
|
||||
- Set startLine: 21, line: 23
|
||||
- Body must include ALL lines being replaced:
|
||||
${codeBlock}suggestion
|
||||
async typeEmail(email: string): Promise<void> {
|
||||
await this.emailInput.fill(email);
|
||||
}
|
||||
${codeBlock}
|
||||
|
||||
CORRECT:
|
||||
\\\`\\\`\\\`suggestion
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
|
||||
For validation suggestions, comment on the function declaration line or create separate comments for each concern.
|
||||
|
||||
4. Submit your review:
|
||||
- Use mcp__github__submit_pending_pull_request_review
|
||||
- Parameters:
|
||||
* event: "COMMENT" (general feedback), "REQUEST_CHANGES" (issues found), or "APPROVE" (if appropriate)
|
||||
* body: Write a comprehensive review summary that includes:
|
||||
- Overview of what was reviewed (files, scope, focus areas)
|
||||
- Summary of all issues found (with counts by severity if applicable)
|
||||
- Key recommendations and action items
|
||||
- Highlights of good practices observed
|
||||
- Overall assessment and recommendation
|
||||
- The body should be detailed and informative since it's the main review content
|
||||
- Structure the body with clear sections using markdown headers
|
||||
COMMON MISTAKE TO AVOID:
|
||||
Never duplicate code in suggestions. For example, DON'T do this:
|
||||
${codeBlock}suggestion
|
||||
async typeEmail(email: string): Promise<void> {
|
||||
async typeEmail(email: string): Promise<void> { // WRONG: Duplicate signature!
|
||||
await this.emailInput.fill(email);
|
||||
}
|
||||
${codeBlock}
|
||||
|
||||
REVIEW GUIDELINES:
|
||||
|
||||
@@ -238,13 +211,11 @@ REVIEW GUIDELINES:
|
||||
|
||||
- Provide:
|
||||
* Specific, actionable feedback
|
||||
* Code suggestions when possible (following GitHub's format exactly)
|
||||
* Clear explanations of issues
|
||||
* Constructive criticism
|
||||
* Code suggestions using the exact format described above
|
||||
* Clear explanations of issues found
|
||||
* Constructive criticism with solutions
|
||||
* Recognition of good practices
|
||||
* For complex changes that require multiple modifications:
|
||||
- Create separate comments for each logical change
|
||||
- Or explain the full solution in text without a suggestion block
|
||||
* For complex changes: Create separate inline comments for each logical change
|
||||
|
||||
- Communication:
|
||||
* All feedback goes through GitHub's review system
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
// 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: <img src="${imageUrl}" alt="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: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
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: <img src='${imageUrl1}' alt="test"> and double quote: <img src="${imageUrl2}" alt="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: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "999",
|
||||
body: `Markdown:  and HTML: <img src="${htmlUrl}" alt="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: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
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:  and <img src="${imageUrl}" alt="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: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
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: <img class="image" src="${imageUrl}" alt="test image" width="100" height="200">`,
|
||||
},
|
||||
];
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user