Compare commits

..

9 Commits

Author SHA1 Message Date
Yuku Kotani
bd0b40e98e feat: normalize bot names for allowed_bots validation
- Strip [bot] suffix from both actor names and allowed bot list for comparison
- Allow both "dependabot" and "dependabot[bot]" formats in allowed_bots input
- Display normalized bot names in error messages for consistency
- Add comprehensive test coverage for both naming formats

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 14:24:17 +09:00
Yuku Kotani
28cc702619 fix: update bot name format to include [bot] suffix in tests and docs
- Update test cases to use correct bot actor names with [bot] suffix
- Update documentation example to show correct bot name format
- Align with GitHub's actual bot naming convention

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 14:19:37 +09:00
Yuku Kotani
15f62ad7ce fix: add missing allowedBots property in permissions test
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 12:43:57 +09:00
Yuku Kotani
d0f57ec761 Merge remote-tracking branch 'origin/main' into support-bot-user 2025-08-06 12:37:39 +09:00
Yuku Kotani
e82e97e178 docs: update README for bot user support feature
Add documentation for the new allowed_bots parameter that enables
bot users to trigger Claude actions with granular control.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 19:02:59 +09:00
Yuku Kotani
bf34e22e43 refactor: move allowedBots parameter to context object
Move allowedBots from function parameter to context.inputs to maintain
consistency with other input handling throughout the codebase.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 16:10:35 +09:00
Yuku Kotani
d46f8d940d docs: mark bot user support feature as completed in roadmap
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 11:06:31 +09:00
Yuku Kotani
3d56fc960a feat: add allow_bot_users option to control bot user access
- Add allow_bot_users input parameter (default: false)
- Modify checkHumanActor to optionally allow bot users
- Add comprehensive tests for bot user handling
- Improve security by blocking bot users by default

This change prevents potential prompt injection attacks from bot users
while providing flexibility for trusted bot integrations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 11:06:31 +09:00
Yuku Kotani
529716dcad feat: skip permission check for GitHub App bot users
GitHub Apps (users ending with [bot]) now bypass permission checks
as they have their own authorization mechanism.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 11:06:31 +09:00
27 changed files with 136 additions and 921 deletions

View File

@@ -36,4 +36,4 @@ 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-1-20250805"
model: "claude-opus-4-20250514"

View File

@@ -104,5 +104,3 @@ jobs:
mcp_config: /tmp/mcp-config/mcp-servers.json
timeout_minutes: "5"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,19 +14,6 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
**We're planning a major update that will significantly change how this action works.** The new version will:
- ✨ Automatically select the appropriate mode (no more `mode` input)
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
- 🚀 Align more closely with the Claude Code SDK capabilities
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
---
## Quickstart
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.

View File

@@ -166,7 +166,6 @@ runs:
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
ALL_INPUTS: ${{ toJson(inputs) }}
- name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true'
@@ -178,8 +177,7 @@ runs:
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
bun install -g @anthropic-ai/claude-code@1.0.68
- name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
@@ -213,7 +211,6 @@ runs:
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
# Model configuration
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
@@ -288,7 +285,7 @@ runs:
fi
- name: Revoke app token
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
if: always() && inputs.github_token == ''
shell: bash
run: |
curl -L \

View File

@@ -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-1-20250805"
model: "claude-opus-4-20250514"
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-1-20250805",
"model": "claude-opus-4-20250514",
"env": {
"DEBUG": "true",
"API_URL": "https://api.example.com"

View File

@@ -118,7 +118,7 @@ runs:
- name: Install Claude Code
shell: bash
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
run: bun install -g @anthropic-ai/claude-code@1.0.68
- name: Run Claude Code Action
shell: bash

View File

@@ -110,10 +110,6 @@ export function prepareRunConfig(
// Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv);
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
}
return {
claudeArgs,
promptPath,
@@ -146,11 +142,9 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any
const customEnvKeys = Object.keys(config.env).filter(
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
);
if (customEnvKeys.length > 0) {
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
if (Object.keys(config.env).length > 0) {
const envKeys = Object.keys(config.env).join(", ");
console.log(`Custom environment variables: ${envKeys}`);
}
// Output to console

View File

@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
// Then, add new settings
const newSettings = JSON.stringify({
newKey: "newValue",
model: "claude-opus-4-1-20250805",
model: "claude-opus-4-20250514",
});
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-1-20250805");
expect(settings.model).toBe("claude-opus-4-20250514");
});
test("should copy slash commands to .claude directory when path provided", async () => {

View File

@@ -207,8 +207,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
```yaml
- uses: anthropics/claude-code-action@beta
with:
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
disallowed_tools: "TaskOutput,KillTask"
allowed_tools: |
Bash(npm install)
Bash(npm run test)
Edit
Replace
NotebookEditCell
disallowed_tools: |
TaskOutput
KillTask
# ... other inputs
```
@@ -245,7 +252,7 @@ You can provide Claude Code settings to customize behavior such as model selecti
with:
settings: |
{
"model": "claude-opus-4-1-20250805",
"model": "claude-opus-4-20250514",
"env": {
"DEBUG": "true",
"API_URL": "https://api.example.com"

View File

@@ -1,4 +1,4 @@
name: Claude Code
name: Claude PR Assistant
on:
issue_comment:
@@ -11,53 +11,38 @@ on:
types: [submitted]
jobs:
claude:
claude-code-action:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
- name: Run Claude PR Action
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: "60"
# mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: |
# .anthropic.com
# .github.com
# api.github.com
# .githubusercontent.com
# bun.sh
# registry.npmjs.org
# .blob.core.windows.net

View File

@@ -6,8 +6,8 @@ echo "Installing git hooks..."
# Make sure hooks directory exists
mkdir -p .git/hooks
# Install pre-commit hook
cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
# Install pre-push hook
cp scripts/pre-push .git/hooks/pre-push
chmod +x .git/hooks/pre-push
echo "Git hooks installed successfully!"

View File

@@ -60,6 +60,8 @@ export function buildAllowedToolsString(
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git rm:*)",
"Bash(git config user.name:*)",
"Bash(git config user.email:*)",
);
}
@@ -836,7 +838,7 @@ export async function createPrompt(
modeContext.claudeBranch,
);
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true,
});
@@ -855,7 +857,7 @@ export async function createPrompt(
// Write the prompt file
await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
promptContent,
);

View File

@@ -1,59 +0,0 @@
import * as core from "@actions/core";
export function collectActionInputsPresence(): void {
const inputDefaults: Record<string, string> = {
trigger_phrase: "@claude",
assignee_trigger: "",
label_trigger: "claude",
base_branch: "",
branch_prefix: "claude/",
allowed_bots: "",
mode: "tag",
model: "",
anthropic_model: "",
fallback_model: "",
allowed_tools: "",
disallowed_tools: "",
custom_instructions: "",
direct_prompt: "",
override_prompt: "",
mcp_config: "",
additional_permissions: "",
claude_env: "",
settings: "",
anthropic_api_key: "",
claude_code_oauth_token: "",
github_token: "",
max_turns: "",
use_sticky_comment: "false",
use_commit_signing: "false",
experimental_allowed_domains: "",
};
const allInputsJson = process.env.ALL_INPUTS;
if (!allInputsJson) {
console.log("ALL_INPUTS environment variable not found");
core.setOutput("action_inputs_present", JSON.stringify({}));
return;
}
let allInputs: Record<string, string>;
try {
allInputs = JSON.parse(allInputsJson);
} catch (e) {
console.error("Failed to parse ALL_INPUTS JSON:", e);
core.setOutput("action_inputs_present", JSON.stringify({}));
return;
}
const presentInputs: Record<string, boolean> = {};
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
const actualValue = allInputs[name] || "";
const isSet = actualValue !== defaultValue;
presentInputs[name] = isSet;
}
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
}

View File

@@ -13,12 +13,9 @@ import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
import type { ModeName } from "../modes/types";
import { prepare } from "../prepare";
import { collectActionInputsPresence } from "./collect-inputs";
async function run() {
try {
collectActionInputsPresence();
// Step 1: Get mode first to determine authentication method
const modeInput = process.env.MODE || DEFAULT_MODE;

View File

@@ -31,30 +31,8 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
const responseJson = (await response.json()) as {
error?: {
message?: string;
details?: {
error_code?: string;
};
};
type?: string;
message?: string;
};
// Check for specific workflow validation error codes that should skip the action
const errorCode = responseJson.error?.details?.error_code;
if (errorCode === "workflow_not_found_on_default_branch") {
const message =
responseJson.message ??
responseJson.error?.message ??
"Workflow validation failed";
core.warning(`Skipping action due to workflow validation: ${message}`);
console.log(
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
);
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
process.exit(0);
}
console.error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
);
@@ -99,9 +77,8 @@ export async function setupGitHubToken(): Promise<string> {
core.setOutput("GITHUB_TOKEN", appToken);
return appToken;
} catch (error) {
// Only set failed if we get here - workflow validation errors will exit(0) before this
core.setFailed(
`Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
);
process.exit(1);
}

View File

@@ -3,17 +3,11 @@ 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(
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
"g",
);
const HTML_IMG_REGEX = new RegExp(
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
"gi",
);
type IssueComment = {
type: "issue_comment";
id: string;
@@ -69,16 +63,8 @@ export async function downloadCommentImages(
}> = [];
for (const comment of comments) {
// 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])];
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
const urls = imageMatches.map((match) => match[1] as string);
if (urls.length > 0) {
commentsWithImages.push({ comment, urls });

View File

@@ -58,41 +58,6 @@ export function sanitizeContent(content: string): string {
content = stripMarkdownLinkTitles(content);
content = stripHiddenAttributes(content);
content = normalizeHtmlEntities(content);
content = redactGitHubTokens(content);
return content;
}
export function redactGitHubTokens(content: string): string {
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghp_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bgho_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghr_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
content = content.replace(
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
return content;
}

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository information from environment variables
const REPO_OWNER = process.env.REPO_OWNER;
@@ -55,13 +54,11 @@ server.tool(
const isPullRequestReviewComment =
eventName === "pull_request_review_comment";
const sanitizedBody = sanitizeContent(body);
const result = await updateClaudeComment(octokit, {
owner,
repo,
commentId,
body: sanitizedBody,
body,
isPullRequestReviewComment,
});

View File

@@ -1,184 +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";
import { sanitizeContent } from "../github/utils/sanitizer";
// 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()
.nonnegative()
.optional()
.describe(
"Line number for single-line comments (required if startLine is not provided)",
),
startLine: z
.number()
.nonnegative()
.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;
// Sanitize the comment body to remove any potential GitHub tokens
const sanitizedBody = sanitizeContent(body);
// Validate that either line or both startLine and line are provided
if (!line && !startLine) {
throw new Error(
"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: sanitizedBody,
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);

View File

@@ -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
const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read";

View File

@@ -1,5 +1,4 @@
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";
@@ -43,23 +42,7 @@ export const agentMode: Mode = {
async prepare({ context }: ModeOptions): Promise<ModeResult> {
// Agent mode handles automation events (workflow_dispatch, schedule) only
// TODO: handle by createPrompt (similar to tag and review modes)
// Create prompt directory
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/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 || "/tmp"}/claude-prompts/claude-prompt.txt`,
promptContent,
);
// Agent mode doesn't need to create prompt files here - handled by createPrompt
// Export tool environment variables for agent mode
const baseTools = [
@@ -80,8 +63,9 @@ export const agentMode: Mode = {
...context.inputs.disallowedTools,
];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
// Export as INPUT_ prefixed variables for the base action
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
// Agent mode uses a minimal MCP configuration
// We don't need comment servers or PR-specific tools for automation

View File

@@ -60,8 +60,20 @@ export const reviewMode: Mode = {
getAllowedTools() {
return [
"Bash(gh issue comment:*)",
"mcp__github_inline_comment__create_inline_comment",
// 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",
];
},
@@ -103,9 +115,6 @@ 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>
@@ -154,50 +163,68 @@ 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 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)
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. 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
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
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)
- 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
b) To modify a single line (e.g., fixing line 22):
- Set line: 22
- Body: ${codeBlock}suggestion
await this.emailInput.fill(email);
${codeBlock}
- For code suggestions, use this EXACT format in the body:
\`\`\`suggestion
corrected code here
\`\`\`
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}
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
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}
Example for line 19 \`var name = user.name;\`:
WRONG:
\\\`\\\`\\\`suggestion
function processUser(user) {
if (!user) throw new Error('Invalid user');
const name = user.name;
\\\`\\\`\\\`
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
REVIEW GUIDELINES:
@@ -211,11 +238,13 @@ REVIEW GUIDELINES:
- Provide:
* Specific, actionable feedback
* Code suggestions using the exact format described above
* Clear explanations of issues found
* Constructive criticism with solutions
* Code suggestions when possible (following GitHub's format exactly)
* Clear explanations of issues
* Constructive criticism
* 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:
* All feedback goes through GitHub's review system
@@ -297,8 +326,9 @@ This ensures users get value from the review even before checking individual inl
...context.inputs.disallowedTools,
];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
// Export as INPUT_ prefixed variables for the base action
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
const additionalMcpConfig = process.env.MCP_CONFIG || "";
const mcpConfig = await prepareMcpConfig({

View File

@@ -1041,6 +1041,8 @@ 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");

View File

@@ -662,255 +662,4 @@ 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: ![test](${markdownUrl}) 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: ![test](${imageUrl}) 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",
);
});
});

View File

@@ -1,29 +1,15 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { describe, test, expect, beforeEach } from "bun:test";
import { agentMode } from "../../src/modes/agent";
import type { GitHubContext } from "../../src/github/context";
import { createMockContext, createMockAutomationContext } from "../mockContext";
import * as core from "@actions/core";
describe("Agent Mode", () => {
let mockContext: GitHubContext;
let exportVariableSpy: any;
let setOutputSpy: any;
beforeEach(() => {
mockContext = createMockAutomationContext({
eventName: "workflow_dispatch",
});
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
() => {},
);
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
});
afterEach(() => {
exportVariableSpy?.mockClear();
setOutputSpy?.mockClear();
exportVariableSpy?.mockRestore();
setOutputSpy?.mockRestore();
});
test("agent mode has correct properties", () => {
@@ -70,67 +56,4 @@ describe("Agent Mode", () => {
expect(agentMode.shouldTrigger(context)).toBe(false);
});
});
test("prepare method sets up tools environment variables correctly", async () => {
// Clear any previous calls before this test
exportVariableSpy.mockClear();
setOutputSpy.mockClear();
const contextWithCustomTools = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
const mockOctokit = {} as any;
const result = await agentMode.prepare({
context: contextWithCustomTools,
octokit: mockOctokit,
githubToken: "test-token",
});
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
expect(exportVariableSpy).toHaveBeenCalledWith(
"ALLOWED_TOOLS",
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
);
expect(exportVariableSpy).toHaveBeenCalledWith(
"DISALLOWED_TOOLS",
"WebSearch,WebFetch,BadTool",
);
// Verify MCP config is set
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
// Verify return structure
expect(result).toEqual({
commentId: undefined,
branchInfo: {
baseBranch: "",
currentBranch: "",
claudeBranch: undefined,
},
mcpConfig: expect.any(String),
});
});
test("prepare method creates prompt file with correct content", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
contextWithPrompts.inputs.directPrompt =
"Direct prompt (should be ignored)";
const mockOctokit = {} as any;
await agentMode.prepare({
context: contextWithPrompts,
octokit: mockOctokit,
githubToken: "test-token",
});
// Note: We can't easily test file creation in this unit test,
// but we can verify the method completes without errors
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
});
});

View File

@@ -7,7 +7,6 @@ import {
normalizeHtmlEntities,
sanitizeContent,
stripHtmlComments,
redactGitHubTokens,
} from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => {
@@ -243,109 +242,6 @@ describe("sanitizeContent", () => {
});
});
describe("redactGitHubTokens", () => {
it("should redact personal access tokens (ghp_)", () => {
const token = "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";
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
"Token: [REDACTED_GITHUB_TOKEN]",
);
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
);
});
it("should redact OAuth tokens (gho_)", () => {
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
"OAuth: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact installation tokens (ghs_)", () => {
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
"Install token: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact refresh tokens (ghr_)", () => {
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
"Refresh: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact fine-grained tokens (github_pat_)", () => {
const token =
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in code blocks", () => {
const content = `\`\`\`bash
export GITHUB_TOKEN=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
\`\`\``;
const expected = `\`\`\`bash
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
\`\`\``;
expect(redactGitHubTokens(content)).toBe(expected);
});
it("should handle multiple tokens in one text", () => {
const content =
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(content)).toBe(
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in URLs", () => {
const content =
"https://api.github.com/user?access_token=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";
expect(redactGitHubTokens(content)).toBe(
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
);
});
it("should not redact partial matches or invalid tokens", () => {
const content =
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should preserve normal text", () => {
const content = "Normal text with no tokens";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should handle edge cases", () => {
expect(redactGitHubTokens("")).toBe("");
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
});
});
describe("sanitizeContent with token redaction", () => {
it("should redact tokens as part of full sanitization", () => {
const content = `
<!-- Hidden comment with token: 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 -->
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
And invisible chars: test\u200Btoken
`;
const sanitized = sanitizeContent(content);
expect(sanitized).not.toContain("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");
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
expect(sanitized).not.toContain("<!-- Hidden comment");
expect(sanitized).not.toContain("\u200B");
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
expect(sanitized).toContain("Here's some text with a token:");
});
});
describe("stripHtmlComments (legacy)", () => {
it("should remove HTML comments", () => {
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(