mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
1 Commits
feat/ttyd-
...
ashwin/tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c5b0a6e9c |
16
.github/workflows/claude.yml
vendored
16
.github/workflows/claude.yml
vendored
@@ -9,21 +9,10 @@ on:
|
|||||||
types: [opened, assigned]
|
types: [opened, assigned]
|
||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [submitted]
|
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:
|
jobs:
|
||||||
claude:
|
claude:
|
||||||
if: |
|
if: |
|
||||||
github.event_name == 'workflow_dispatch' ||
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
(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_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
@@ -47,7 +36,4 @@ jobs:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
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."
|
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-1-20250805"
|
model: "claude-opus-4-20250514"
|
||||||
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,10 +114,6 @@ inputs:
|
|||||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
cloudflare_tunnel_token:
|
|
||||||
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -176,7 +172,7 @@ runs:
|
|||||||
echo "Base-action dependencies installed"
|
echo "Base-action dependencies installed"
|
||||||
cd -
|
cd -
|
||||||
# Install Claude Code globally
|
# Install Claude Code globally
|
||||||
bun install -g @anthropic-ai/claude-code@1.0.70
|
bun install -g @anthropic-ai/claude-code@1.0.68
|
||||||
|
|
||||||
- name: Setup Network Restrictions
|
- name: Setup Network Restrictions
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||||
@@ -210,7 +206,6 @@ runs:
|
|||||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||||
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
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
|
uses: anthropics/claude-code-base-action@beta
|
||||||
with:
|
with:
|
||||||
prompt: "Review and fix TypeScript errors"
|
prompt: "Review and fix TypeScript errors"
|
||||||
model: "claude-opus-4-1-20250805"
|
model: "claude-opus-4-20250514"
|
||||||
fallback_model: "claude-sonnet-4-20250514"
|
fallback_model: "claude-sonnet-4-20250514"
|
||||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
@@ -217,7 +217,7 @@ Provide the settings configuration directly as a JSON string:
|
|||||||
prompt: "Your prompt here"
|
prompt: "Your prompt here"
|
||||||
settings: |
|
settings: |
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-1-20250805",
|
"model": "claude-opus-4-20250514",
|
||||||
"env": {
|
"env": {
|
||||||
"DEBUG": "true",
|
"DEBUG": "true",
|
||||||
"API_URL": "https://api.example.com"
|
"API_URL": "https://api.example.com"
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ inputs:
|
|||||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
cloudflare_tunnel_token:
|
|
||||||
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -122,7 +118,7 @@ runs:
|
|||||||
|
|
||||||
- name: Install Claude Code
|
- name: Install Claude Code
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun install -g @anthropic-ai/claude-code@1.0.70
|
run: bun install -g @anthropic-ai/claude-code@1.0.68
|
||||||
|
|
||||||
- name: Run Claude Code Action
|
- name: Run Claude Code Action
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -151,7 +147,6 @@ runs:
|
|||||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
|
||||||
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { preparePrompt } from "./prepare-prompt";
|
|||||||
import { runClaude } from "./run-claude";
|
import { runClaude } from "./run-claude";
|
||||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||||
import { validateEnvironmentVariables } from "./validate-env";
|
import { validateEnvironmentVariables } from "./validate-env";
|
||||||
import { spawn } from "child_process";
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -22,40 +21,7 @@ async function run() {
|
|||||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup ttyd and cloudflared tunnel if token provided
|
await runClaude(promptConfig.path, {
|
||||||
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,
|
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
@@ -66,23 +32,6 @@ async function run() {
|
|||||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||||
model: process.env.ANTHROPIC_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) {
|
} catch (error) {
|
||||||
core.setFailed(`Action failed with error: ${error}`);
|
core.setFailed(`Action failed with error: ${error}`);
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
|
|||||||
// Then, add new settings
|
// Then, add new settings
|
||||||
const newSettings = JSON.stringify({
|
const newSettings = JSON.stringify({
|
||||||
newKey: "newValue",
|
newKey: "newValue",
|
||||||
model: "claude-opus-4-1-20250805",
|
model: "claude-opus-4-20250514",
|
||||||
});
|
});
|
||||||
|
|
||||||
await setupClaudeCodeSettings(newSettings, testHomeDir);
|
await setupClaudeCodeSettings(newSettings, testHomeDir);
|
||||||
@@ -145,7 +145,7 @@ describe("setupClaudeCodeSettings", () => {
|
|||||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||||
expect(settings.existingKey).toBe("existingValue");
|
expect(settings.existingKey).toBe("existingValue");
|
||||||
expect(settings.newKey).toBe("newValue");
|
expect(settings.newKey).toBe("newValue");
|
||||||
expect(settings.model).toBe("claude-opus-4-1-20250805");
|
expect(settings.model).toBe("claude-opus-4-20250514");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should copy slash commands to .claude directory when path provided", async () => {
|
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:
|
with:
|
||||||
settings: |
|
settings: |
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-1-20250805",
|
"model": "claude-opus-4-20250514",
|
||||||
"env": {
|
"env": {
|
||||||
"DEBUG": "true",
|
"DEBUG": "true",
|
||||||
"API_URL": "https://api.example.com"
|
"API_URL": "https://api.example.com"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ echo "Installing git hooks..."
|
|||||||
# Make sure hooks directory exists
|
# Make sure hooks directory exists
|
||||||
mkdir -p .git/hooks
|
mkdir -p .git/hooks
|
||||||
|
|
||||||
# Install pre-commit hook
|
# Install pre-push hook
|
||||||
cp scripts/pre-commit .git/hooks/pre-commit
|
cp scripts/pre-push .git/hooks/pre-push
|
||||||
chmod +x .git/hooks/pre-commit
|
chmod +x .git/hooks/pre-push
|
||||||
|
|
||||||
echo "Git hooks installed successfully!"
|
echo "Git hooks installed successfully!"
|
||||||
@@ -60,6 +60,8 @@ export function buildAllowedToolsString(
|
|||||||
"Bash(git diff:*)",
|
"Bash(git diff:*)",
|
||||||
"Bash(git log:*)",
|
"Bash(git log:*)",
|
||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git config user.name:*)",
|
||||||
|
"Bash(git config user.email:*)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function configureGitAuth(
|
|||||||
if (user) {
|
if (user) {
|
||||||
const botName = user.login;
|
const botName = user.login;
|
||||||
const botId = user.id;
|
const botId = user.id;
|
||||||
console.log(`Setting git user as ${botName}...`);
|
console.log(`Setting git user as ${botName} (id: ${botId})...`);
|
||||||
await $`git config user.name "${botName}"`;
|
await $`git config user.name "${botName}"`;
|
||||||
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
|
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
|
||||||
console.log(`✓ Set git user as ${botName}`);
|
console.log(`✓ Set git user as ${botName}`);
|
||||||
|
|||||||
@@ -3,17 +3,11 @@ import path from "path";
|
|||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const IMAGE_REGEX = new RegExp(
|
const IMAGE_REGEX = new RegExp(
|
||||||
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||||
"g",
|
"g",
|
||||||
);
|
);
|
||||||
|
|
||||||
const HTML_IMG_REGEX = new RegExp(
|
|
||||||
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
|
|
||||||
"gi",
|
|
||||||
);
|
|
||||||
|
|
||||||
type IssueComment = {
|
type IssueComment = {
|
||||||
type: "issue_comment";
|
type: "issue_comment";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,16 +63,8 @@ export async function downloadCommentImages(
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
// Extract URLs from Markdown format
|
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||||
const markdownMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
const urls = imageMatches.map((match) => match[1] as string);
|
||||||
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) {
|
if (urls.length > 0) {
|
||||||
commentsWithImages.push({ comment, urls });
|
commentsWithImages.push({ comment, urls });
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
#!/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,24 +111,6 @@ 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
|
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||||
const hasActionsReadPermission =
|
const hasActionsReadPermission =
|
||||||
context.inputs.additionalPermissions.get("actions") === "read";
|
context.inputs.additionalPermissions.get("actions") === "read";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { mkdir, writeFile } from "fs/promises";
|
|
||||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||||
import { isAutomationContext } from "../../github/context";
|
import { isAutomationContext } from "../../github/context";
|
||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
@@ -43,23 +42,7 @@ export const agentMode: Mode = {
|
|||||||
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
||||||
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
||||||
|
|
||||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
// Agent mode doesn't need to create prompt files here - handled by createPrompt
|
||||||
// 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
|
// Export tool environment variables for agent mode
|
||||||
const baseTools = [
|
const baseTools = [
|
||||||
|
|||||||
@@ -60,8 +60,20 @@ export const reviewMode: Mode = {
|
|||||||
|
|
||||||
getAllowedTools() {
|
getAllowedTools() {
|
||||||
return [
|
return [
|
||||||
"Bash(gh issue comment:*)",
|
// Context tools - to know who the current user is
|
||||||
"mcp__github_inline_comment__create_inline_comment",
|
"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",
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -103,9 +115,6 @@ export const reviewMode: Mode = {
|
|||||||
? formatBody(contextData.body, imageUrlMap)
|
? formatBody(contextData.body, imageUrlMap)
|
||||||
: "No description provided";
|
: "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.
|
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>
|
<formatted_context>
|
||||||
@@ -154,50 +163,68 @@ REVIEW MODE WORKFLOW:
|
|||||||
|
|
||||||
1. First, understand the PR context:
|
1. First, understand the PR context:
|
||||||
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository}
|
- 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
|
- 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
|
- This provides the full context and latest state of the code
|
||||||
- Look at the changed_files section above to see which files were modified
|
- Look at the changed_files section above to see which files were modified
|
||||||
|
|
||||||
2. Create review comments using GitHub MCP tools:
|
2. Create a pending review:
|
||||||
- Use Bash(gh issue comment:*) for general PR-level comments
|
- Use mcp__github__create_pending_pull_request_review to start your review
|
||||||
- Use mcp__github_inline_comment__create_inline_comment for line-specific feedback (strongly preferred)
|
- 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
|
||||||
|
|
||||||
3. When creating inline comments with suggestions:
|
- When to use multi-line comments:
|
||||||
CRITICAL: GitHub's suggestion blocks REPLACE the ENTIRE line range you select
|
* When replacing multiple consecutive lines
|
||||||
- For single-line comments: Use 'line' parameter only
|
* When the fix requires changes across several lines
|
||||||
- For multi-line comments: Use both 'startLine' and 'line' parameters
|
* Example: To replace lines 19-20, use startLine: 19, line: 20
|
||||||
- The 'body' parameter should contain your comment and/or suggestion block
|
|
||||||
|
|
||||||
How to write code suggestions correctly:
|
- For code suggestions, use this EXACT format in the body:
|
||||||
a) To remove a line (e.g., removing console.log on line 22):
|
\`\`\`suggestion
|
||||||
- Set line: 22
|
corrected code here
|
||||||
- Body: ${codeBlock}suggestion
|
\`\`\`
|
||||||
${codeBlock}
|
|
||||||
(Empty suggestion block removes the line)
|
|
||||||
|
|
||||||
b) To modify a single line (e.g., fixing line 22):
|
CRITICAL: GitHub suggestion blocks must ONLY contain the replacement for the specific line(s) being commented on:
|
||||||
- Set line: 22
|
- For single-line comments: Replace ONLY that line
|
||||||
- Body: ${codeBlock}suggestion
|
- For multi-line comments: Replace ONLY the lines in the range
|
||||||
await this.emailInput.fill(email);
|
- Do NOT include surrounding context or function signatures
|
||||||
${codeBlock}
|
- Do NOT suggest changes that span beyond the commented lines
|
||||||
|
|
||||||
c) To replace multiple lines (e.g., lines 21-23):
|
Example for line 19 \`var name = user.name;\`:
|
||||||
- Set startLine: 21, line: 23
|
WRONG:
|
||||||
- Body must include ALL lines being replaced:
|
\\\`\\\`\\\`suggestion
|
||||||
${codeBlock}suggestion
|
function processUser(user) {
|
||||||
async typeEmail(email: string): Promise<void> {
|
if (!user) throw new Error('Invalid user');
|
||||||
await this.emailInput.fill(email);
|
const name = user.name;
|
||||||
}
|
\\\`\\\`\\\`
|
||||||
${codeBlock}
|
|
||||||
|
|
||||||
COMMON MISTAKE TO AVOID:
|
CORRECT:
|
||||||
Never duplicate code in suggestions. For example, DON'T do this:
|
\\\`\\\`\\\`suggestion
|
||||||
${codeBlock}suggestion
|
const name = user.name;
|
||||||
async typeEmail(email: string): Promise<void> {
|
\\\`\\\`\\\`
|
||||||
async typeEmail(email: string): Promise<void> { // WRONG: Duplicate signature!
|
|
||||||
await this.emailInput.fill(email);
|
For validation suggestions, comment on the function declaration line or create separate comments for each concern.
|
||||||
}
|
|
||||||
${codeBlock}
|
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
|
||||||
|
|
||||||
REVIEW GUIDELINES:
|
REVIEW GUIDELINES:
|
||||||
|
|
||||||
@@ -211,11 +238,13 @@ REVIEW GUIDELINES:
|
|||||||
|
|
||||||
- Provide:
|
- Provide:
|
||||||
* Specific, actionable feedback
|
* Specific, actionable feedback
|
||||||
* Code suggestions using the exact format described above
|
* Code suggestions when possible (following GitHub's format exactly)
|
||||||
* Clear explanations of issues found
|
* Clear explanations of issues
|
||||||
* Constructive criticism with solutions
|
* Constructive criticism
|
||||||
* Recognition of good practices
|
* Recognition of good practices
|
||||||
* For complex changes: Create separate inline comments for each logical change
|
* 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
|
||||||
|
|
||||||
- Communication:
|
- Communication:
|
||||||
* All feedback goes through GitHub's review system
|
* All feedback goes through GitHub's review system
|
||||||
|
|||||||
@@ -1041,6 +1041,8 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(result).toContain("Bash(git diff:*)");
|
expect(result).toContain("Bash(git diff:*)");
|
||||||
expect(result).toContain("Bash(git log:*)");
|
expect(result).toContain("Bash(git log:*)");
|
||||||
expect(result).toContain("Bash(git rm:*)");
|
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
|
// Comment tool from minimal server should be included
|
||||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|||||||
@@ -662,255 +662,4 @@ describe("downloadCommentImages", () => {
|
|||||||
);
|
);
|
||||||
expect(result.get(imageUrl2)).toBeUndefined();
|
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