mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
Compare commits
5 Commits
support-bo
...
v0.0.53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188d526721 | ||
|
|
a519840051 | ||
|
|
85287e957d | ||
|
|
c6a07895d7 | ||
|
|
0c5d54472f |
@@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
|
||||
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
|
||||
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~
|
||||
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
|
||||
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
|
||||
|
||||
---
|
||||
|
||||
@@ -23,10 +23,6 @@ inputs:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
default: "claude/"
|
||||
allowed_bots:
|
||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Mode configuration
|
||||
mode:
|
||||
@@ -160,7 +156,6 @@ runs:
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
@@ -177,7 +172,7 @@ runs:
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
bun install -g @anthropic-ai/claude-code@1.0.68
|
||||
bun install -g @anthropic-ai/claude-code@1.0.69
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
|
||||
@@ -118,7 +118,7 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.68
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.69
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Access Control
|
||||
|
||||
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
|
||||
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
@@ -42,8 +42,6 @@ jobs:
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
# Optional: allow bot users to trigger the action
|
||||
# allowed_bots: "dependabot[bot],renovate[bot]"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -78,7 +76,6 @@ jobs:
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
|
||||
@@ -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:*)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ type BaseContext = {
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
useCommitSigning: boolean;
|
||||
allowedBots: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -137,7 +136,6 @@ export function parseGitHubContext(): GitHubContext {
|
||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||
),
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -21,42 +21,9 @@ export async function checkHumanActor(
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
// Check bot permissions if actor is not a User
|
||||
if (actorType !== "User") {
|
||||
const allowedBots = githubContext.inputs.allowedBots;
|
||||
|
||||
// Check if all bots are allowed
|
||||
if (allowedBots.trim() === "*") {
|
||||
console.log(
|
||||
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse allowed bots list
|
||||
const allowedBotsList = allowedBots
|
||||
.split(",")
|
||||
.map((bot) =>
|
||||
bot
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\[bot\]$/, ""),
|
||||
)
|
||||
.filter((bot) => bot.length > 0);
|
||||
|
||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
||||
|
||||
// Check if specific bot is allowed
|
||||
if (allowedBotsList.includes(botName)) {
|
||||
console.log(
|
||||
`Bot ${botName} is in allowed list, skipping human actor check`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bot not allowed
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ export async function checkWritePermissions(
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
|
||||
// Check if the actor is a GitHub App (bot user)
|
||||
if (actor.endsWith("[bot]")) {
|
||||
core.info(`Actor is a GitHub App: ${actor}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { checkHumanActor } from "../src/github/validation/actor";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createMockContext } from "./mockContext";
|
||||
|
||||
function createMockOctokit(userType: string): Octokit {
|
||||
return {
|
||||
users: {
|
||||
getByUsername: async () => ({
|
||||
data: {
|
||||
type: userType,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
}
|
||||
|
||||
describe("checkHumanActor", () => {
|
||||
test("should pass for human actor", async () => {
|
||||
const mockOctokit = createMockOctokit("User");
|
||||
const context = createMockContext();
|
||||
context.actor = "human-user";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot actor when not allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should pass for bot actor when all bots allowed", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
context.inputs.allowedBots = "*";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should pass for specific bot when in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "dependabot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(
|
||||
checkHumanActor(mockOctokit, context),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for bot not in allowed list (without [bot])", async () => {
|
||||
const mockOctokit = createMockOctokit("Bot");
|
||||
const context = createMockContext();
|
||||
context.actor = "other-bot[bot]";
|
||||
context.inputs.allowedBots = "dependabot,renovate";
|
||||
|
||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ const defaultInputs = {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,16 +126,6 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should return true for bot user", async () => {
|
||||
const mockOctokit = createMockOctokit("none");
|
||||
const context = createContext();
|
||||
context.actor = "test-bot[bot]";
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should throw error when permission check fails", async () => {
|
||||
const error = new Error("API error");
|
||||
const mockOctokit = {
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -75,7 +74,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -293,7 +291,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -328,7 +325,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -363,7 +359,6 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user