mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-24 07:24:12 +08:00
Compare commits
24 Commits
test-no-ap
...
ashwin/ver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6147037db9 | ||
|
|
194fca8b05 | ||
|
|
0f913a6e0e | ||
|
|
68b7ca379c | ||
|
|
900322ca88 | ||
|
|
8f0a7fe9d3 | ||
|
|
db36412854 | ||
|
|
f05d669d5f | ||
|
|
e89411bb6f | ||
|
|
02e9ed3181 | ||
|
|
78b07473f5 | ||
|
|
f562ed53e2 | ||
|
|
a1507aefdc | ||
|
|
ae66eb6a64 | ||
|
|
432c7cc889 | ||
|
|
0b138d9d49 | ||
|
|
c34e066a3b | ||
|
|
449c6791bd | ||
|
|
2b67ac084b | ||
|
|
76de8a48fc | ||
|
|
a80505bbfb | ||
|
|
af23644a50 | ||
|
|
98e6a902bf | ||
|
|
8b2bd6d04f |
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -104,3 +104,5 @@ jobs:
|
|||||||
mcp_config: /tmp/mcp-config/mcp-servers.json
|
mcp_config: /tmp/mcp-config/mcp-servers.json
|
||||||
timeout_minutes: "5"
|
timeout_minutes: "5"
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ runs:
|
|||||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||||
|
|
||||||
- name: Install Base Action Dependencies
|
- name: Install Base Action Dependencies
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
@@ -177,7 +178,8 @@ 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.72
|
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- 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 != ''
|
||||||
@@ -211,6 +213,7 @@ 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_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
@@ -285,7 +288,7 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Revoke app token
|
- name: Revoke app token
|
||||||
if: always() && inputs.github_token == ''
|
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
curl -L \
|
||||||
|
|||||||
@@ -118,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.72
|
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.85
|
||||||
|
|
||||||
- name: Run Claude Code Action
|
- name: Run Claude Code Action
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export function prepareRunConfig(
|
|||||||
// Parse custom environment variables
|
// Parse custom environment variables
|
||||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||||
|
|
||||||
|
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||||
|
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claudeArgs,
|
claudeArgs,
|
||||||
promptPath,
|
promptPath,
|
||||||
@@ -142,9 +146,11 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||||
|
|
||||||
// Log custom environment variables if any
|
// Log custom environment variables if any
|
||||||
if (Object.keys(config.env).length > 0) {
|
const customEnvKeys = Object.keys(config.env).filter(
|
||||||
const envKeys = Object.keys(config.env).join(", ");
|
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
||||||
console.log(`Custom environment variables: ${envKeys}`);
|
);
|
||||||
|
if (customEnvKeys.length > 0) {
|
||||||
|
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output to console
|
// Output to console
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Claude PR Assistant
|
name: Claude Code
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -11,38 +11,53 @@ on:
|
|||||||
types: [submitted]
|
types: [submitted]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-code-action:
|
claude:
|
||||||
if: |
|
if: |
|
||||||
(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')) ||
|
||||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
issues: read
|
issues: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude PR Action
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
uses: anthropics/claude-code-action@beta
|
uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
# Or use OAuth token instead:
|
|
||||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
timeout_minutes: "60"
|
additional_permissions: |
|
||||||
# mode: tag # Default: responds to @claude mentions
|
actions: read
|
||||||
# Optional: Restrict network access to specific domains only
|
|
||||||
# experimental_allowed_domains: |
|
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||||
# .anthropic.com
|
# model: "claude-opus-4-1-20250805"
|
||||||
# .github.com
|
|
||||||
# api.github.com
|
# Optional: Customize the trigger phrase (default: @claude)
|
||||||
# .githubusercontent.com
|
# trigger_phrase: "/claude"
|
||||||
# bun.sh
|
|
||||||
# registry.npmjs.org
|
# Optional: Trigger when specific user is assigned to an issue
|
||||||
# .blob.core.windows.net
|
# assignee_trigger: "claude-bot"
|
||||||
|
|
||||||
|
# Optional: Allow Claude to run specific commands
|
||||||
|
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||||
|
|
||||||
|
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||||
|
# custom_instructions: |
|
||||||
|
# Follow our coding standards
|
||||||
|
# Ensure all new code has tests
|
||||||
|
# Use TypeScript for new files
|
||||||
|
|
||||||
|
# Optional: Custom environment variables for Claude
|
||||||
|
# claude_env: |
|
||||||
|
# NODE_ENV: test
|
||||||
|
|||||||
@@ -836,7 +836,7 @@ export async function createPrompt(
|
|||||||
modeContext.claudeBranch,
|
modeContext.claudeBranch,
|
||||||
);
|
);
|
||||||
|
|
||||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -855,7 +855,7 @@ export async function createPrompt(
|
|||||||
|
|
||||||
// Write the prompt file
|
// Write the prompt file
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
promptContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
59
src/entrypoints/collect-inputs.ts
Normal file
59
src/entrypoints/collect-inputs.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
export function collectActionInputsPresence(): void {
|
||||||
|
const inputDefaults: Record<string, string> = {
|
||||||
|
trigger_phrase: "@claude",
|
||||||
|
assignee_trigger: "",
|
||||||
|
label_trigger: "claude",
|
||||||
|
base_branch: "",
|
||||||
|
branch_prefix: "claude/",
|
||||||
|
allowed_bots: "",
|
||||||
|
mode: "tag",
|
||||||
|
model: "",
|
||||||
|
anthropic_model: "",
|
||||||
|
fallback_model: "",
|
||||||
|
allowed_tools: "",
|
||||||
|
disallowed_tools: "",
|
||||||
|
custom_instructions: "",
|
||||||
|
direct_prompt: "",
|
||||||
|
override_prompt: "",
|
||||||
|
mcp_config: "",
|
||||||
|
additional_permissions: "",
|
||||||
|
claude_env: "",
|
||||||
|
settings: "",
|
||||||
|
anthropic_api_key: "",
|
||||||
|
claude_code_oauth_token: "",
|
||||||
|
github_token: "",
|
||||||
|
max_turns: "",
|
||||||
|
use_sticky_comment: "false",
|
||||||
|
use_commit_signing: "false",
|
||||||
|
experimental_allowed_domains: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const allInputsJson = process.env.ALL_INPUTS;
|
||||||
|
if (!allInputsJson) {
|
||||||
|
console.log("ALL_INPUTS environment variable not found");
|
||||||
|
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allInputs: Record<string, string>;
|
||||||
|
try {
|
||||||
|
allInputs = JSON.parse(allInputsJson);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse ALL_INPUTS JSON:", e);
|
||||||
|
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentInputs: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
|
||||||
|
const actualValue = allInputs[name] || "";
|
||||||
|
|
||||||
|
const isSet = actualValue !== defaultValue;
|
||||||
|
presentInputs[name] = isSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
|
||||||
|
}
|
||||||
@@ -13,9 +13,12 @@ import { parseGitHubContext, isEntityContext } from "../github/context";
|
|||||||
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
|
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
|
||||||
import type { ModeName } from "../modes/types";
|
import type { ModeName } from "../modes/types";
|
||||||
import { prepare } from "../prepare";
|
import { prepare } from "../prepare";
|
||||||
|
import { collectActionInputsPresence } from "./collect-inputs";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
|
collectActionInputsPresence();
|
||||||
|
|
||||||
// Step 1: Get mode first to determine authentication method
|
// Step 1: Get mode first to determine authentication method
|
||||||
const modeInput = process.env.MODE || DEFAULT_MODE;
|
const modeInput = process.env.MODE || DEFAULT_MODE;
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
|||||||
const responseJson = (await response.json()) as {
|
const responseJson = (await response.json()) as {
|
||||||
error?: {
|
error?: {
|
||||||
message?: string;
|
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(
|
console.error(
|
||||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
||||||
);
|
);
|
||||||
@@ -77,8 +99,9 @@ export async function setupGitHubToken(): Promise<string> {
|
|||||||
core.setOutput("GITHUB_TOKEN", appToken);
|
core.setOutput("GITHUB_TOKEN", appToken);
|
||||||
return appToken;
|
return appToken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Only set failed if we get here - workflow validation errors will exit(0) before this
|
||||||
core.setFailed(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string {
|
|||||||
content = stripMarkdownLinkTitles(content);
|
content = stripMarkdownLinkTitles(content);
|
||||||
content = stripHiddenAttributes(content);
|
content = stripHiddenAttributes(content);
|
||||||
content = normalizeHtmlEntities(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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||||
|
|
||||||
// Get repository information from environment variables
|
// Get repository information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@@ -54,11 +55,13 @@ server.tool(
|
|||||||
const isPullRequestReviewComment =
|
const isPullRequestReviewComment =
|
||||||
eventName === "pull_request_review_comment";
|
eventName === "pull_request_review_comment";
|
||||||
|
|
||||||
|
const sanitizedBody = sanitizeContent(body);
|
||||||
|
|
||||||
const result = await updateClaudeComment(octokit, {
|
const result = await updateClaudeComment(octokit, {
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
commentId,
|
commentId,
|
||||||
body,
|
body: sanitizedBody,
|
||||||
isPullRequestReviewComment,
|
isPullRequestReviewComment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { constants } from "fs";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { retryWithBackoff } from "../utils/retry";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
@@ -162,6 +163,34 @@ async function getOrCreateBranchRef(
|
|||||||
return baseSha;
|
return baseSha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the appropriate Git file mode for a file
|
||||||
|
async function getFileMode(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
if (fileStat.isFile()) {
|
||||||
|
// Check if execute bit is set for user
|
||||||
|
if (fileStat.mode & constants.S_IXUSR) {
|
||||||
|
return "100755"; // Executable file
|
||||||
|
} else {
|
||||||
|
return "100644"; // Regular file
|
||||||
|
}
|
||||||
|
} else if (fileStat.isDirectory()) {
|
||||||
|
return "040000"; // Directory (tree)
|
||||||
|
} else if (fileStat.isSymbolicLink()) {
|
||||||
|
return "120000"; // Symbolic link
|
||||||
|
} else {
|
||||||
|
// Fallback for unknown file types
|
||||||
|
return "100644";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't stat the file, default to regular file
|
||||||
|
console.warn(
|
||||||
|
`Could not determine file mode for ${filePath}, using default: ${error}`,
|
||||||
|
);
|
||||||
|
return "100644";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Commit files tool
|
// Commit files tool
|
||||||
server.tool(
|
server.tool(
|
||||||
"commit_files",
|
"commit_files",
|
||||||
@@ -223,6 +252,9 @@ server.tool(
|
|||||||
? filePath
|
? filePath
|
||||||
: join(REPO_DIR, filePath);
|
: join(REPO_DIR, filePath);
|
||||||
|
|
||||||
|
// Get the proper file mode based on file permissions
|
||||||
|
const fileMode = await getFileMode(fullPath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
// Check if file is binary (images, etc.)
|
||||||
const isBinaryFile =
|
const isBinaryFile =
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||||
@@ -261,7 +293,7 @@ server.tool(
|
|||||||
// Return tree entry with blob SHA
|
// Return tree entry with blob SHA
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
sha: blobData.sha,
|
sha: blobData.sha,
|
||||||
};
|
};
|
||||||
@@ -270,7 +302,7 @@ server.tool(
|
|||||||
const content = await readFile(fullPath, "utf-8");
|
const content = await readFile(fullPath, "utf-8");
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: content,
|
||||||
};
|
};
|
||||||
@@ -335,6 +367,7 @@ server.tool(
|
|||||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
// on certain repos when updating git references. These appear to be transient
|
// on certain repos when updating git references. These appear to be transient
|
||||||
// GitHub API issues that succeed on retry.
|
// GitHub API issues that succeed on retry.
|
||||||
|
let lastErrorDetails: any = null;
|
||||||
await retryWithBackoff(
|
await retryWithBackoff(
|
||||||
async () => {
|
async () => {
|
||||||
const updateRefResponse = await fetch(updateRefUrl, {
|
const updateRefResponse = await fetch(updateRefUrl, {
|
||||||
@@ -353,17 +386,48 @@ server.tool(
|
|||||||
|
|
||||||
if (!updateRefResponse.ok) {
|
if (!updateRefResponse.ok) {
|
||||||
const errorText = await updateRefResponse.text();
|
const errorText = await updateRefResponse.text();
|
||||||
|
let errorJson: any = {};
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText);
|
||||||
|
} catch {
|
||||||
|
// If not JSON, use the text as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect debugging information
|
||||||
|
const debugInfo = {
|
||||||
|
status: updateRefResponse.status,
|
||||||
|
statusText: updateRefResponse.statusText,
|
||||||
|
headers: Object.fromEntries(updateRefResponse.headers.entries()),
|
||||||
|
errorBody: errorJson || errorText,
|
||||||
|
context: {
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
branch: branch,
|
||||||
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
|
targetSha: newCommitData.sha,
|
||||||
|
parentSha: baseSha,
|
||||||
|
isGitHubAction: !!process.env.GITHUB_ACTIONS,
|
||||||
|
eventName: process.env.GITHUB_EVENT_NAME,
|
||||||
|
isPR: process.env.IS_PR,
|
||||||
|
tokenLength: githubToken?.length || 0,
|
||||||
|
tokenPrefix: githubToken?.substring(0, 10) + "...",
|
||||||
|
apiUrl: updateRefUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
lastErrorDetails = debugInfo;
|
||||||
|
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}\n\nDebug Info: ${JSON.stringify(debugInfo, null, 2)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||||
if (updateRefResponse.status === 403) {
|
if (updateRefResponse.status === 403) {
|
||||||
|
console.error("403 Error encountered (will retry):", debugInfo);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-403 errors, fail immediately without retry
|
// For non-403 errors, fail immediately without retry
|
||||||
console.error("Non-retryable error:", updateRefResponse.status);
|
console.error("Non-retryable error:", debugInfo);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -373,7 +437,23 @@ server.tool(
|
|||||||
maxDelayMs: 5000, // Max 5 seconds delay
|
maxDelayMs: 5000, // Max 5 seconds delay
|
||||||
backoffFactor: 2, // Double the delay each time
|
backoffFactor: 2, // Double the delay each time
|
||||||
},
|
},
|
||||||
);
|
).catch((error) => {
|
||||||
|
// If all retries failed, enhance the error message with collected details
|
||||||
|
if (lastErrorDetails) {
|
||||||
|
throw new Error(
|
||||||
|
`All retry attempts failed for ref update.\n\n` +
|
||||||
|
`Final error: ${error.message}\n\n` +
|
||||||
|
`Debugging hints:\n` +
|
||||||
|
`- Check if branch '${branch}' is protected and the GitHub App has bypass permissions\n` +
|
||||||
|
`- Verify the token has 'contents:write' permission for ${owner}/${repo}\n` +
|
||||||
|
`- Check for concurrent operations updating the same branch\n` +
|
||||||
|
`- Token appears to be: ${lastErrorDetails.context.tokenPrefix}\n` +
|
||||||
|
`- This may be a transient GitHub API issue if it works on retry\n\n` +
|
||||||
|
`Full debug details: ${JSON.stringify(lastErrorDetails, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
const simplifiedResult = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
@@ -541,6 +621,7 @@ server.tool(
|
|||||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
// on certain repos when updating git references. These appear to be transient
|
// on certain repos when updating git references. These appear to be transient
|
||||||
// GitHub API issues that succeed on retry.
|
// GitHub API issues that succeed on retry.
|
||||||
|
let lastErrorDetails: any = null;
|
||||||
await retryWithBackoff(
|
await retryWithBackoff(
|
||||||
async () => {
|
async () => {
|
||||||
const updateRefResponse = await fetch(updateRefUrl, {
|
const updateRefResponse = await fetch(updateRefUrl, {
|
||||||
@@ -559,18 +640,52 @@ server.tool(
|
|||||||
|
|
||||||
if (!updateRefResponse.ok) {
|
if (!updateRefResponse.ok) {
|
||||||
const errorText = await updateRefResponse.text();
|
const errorText = await updateRefResponse.text();
|
||||||
|
let errorJson: any = {};
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText);
|
||||||
|
} catch {
|
||||||
|
// If not JSON, use the text as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect debugging information
|
||||||
|
const debugInfo = {
|
||||||
|
status: updateRefResponse.status,
|
||||||
|
statusText: updateRefResponse.statusText,
|
||||||
|
headers: Object.fromEntries(updateRefResponse.headers.entries()),
|
||||||
|
errorBody: errorJson || errorText,
|
||||||
|
context: {
|
||||||
|
operation: "delete_files",
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
branch: branch,
|
||||||
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
|
targetSha: newCommitData.sha,
|
||||||
|
parentSha: baseSha,
|
||||||
|
isGitHubAction: !!process.env.GITHUB_ACTIONS,
|
||||||
|
eventName: process.env.GITHUB_EVENT_NAME,
|
||||||
|
isPR: process.env.IS_PR,
|
||||||
|
tokenLength: githubToken?.length || 0,
|
||||||
|
tokenPrefix: githubToken?.substring(0, 10) + "...",
|
||||||
|
apiUrl: updateRefUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
lastErrorDetails = debugInfo;
|
||||||
|
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}\n\nDebug Info: ${JSON.stringify(debugInfo, null, 2)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||||
if (updateRefResponse.status === 403) {
|
if (updateRefResponse.status === 403) {
|
||||||
console.log("Received 403 error, will retry...");
|
console.error(
|
||||||
|
"403 Error encountered during delete (will retry):",
|
||||||
|
debugInfo,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-403 errors, fail immediately without retry
|
// For non-403 errors, fail immediately without retry
|
||||||
console.error("Non-retryable error:", updateRefResponse.status);
|
console.error("Non-retryable error during delete:", debugInfo);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -580,7 +695,23 @@ server.tool(
|
|||||||
maxDelayMs: 5000, // Max 5 seconds delay
|
maxDelayMs: 5000, // Max 5 seconds delay
|
||||||
backoffFactor: 2, // Double the delay each time
|
backoffFactor: 2, // Double the delay each time
|
||||||
},
|
},
|
||||||
);
|
).catch((error) => {
|
||||||
|
// If all retries failed, enhance the error message with collected details
|
||||||
|
if (lastErrorDetails) {
|
||||||
|
throw new Error(
|
||||||
|
`All retry attempts failed for ref update during file deletion.\n\n` +
|
||||||
|
`Final error: ${error.message}\n\n` +
|
||||||
|
`Debugging hints:\n` +
|
||||||
|
`- Check if branch '${branch}' is protected and the GitHub App has bypass permissions\n` +
|
||||||
|
`- Verify the token has 'contents:write' permission for ${owner}/${repo}\n` +
|
||||||
|
`- Check for concurrent operations updating the same branch\n` +
|
||||||
|
`- Token appears to be: ${lastErrorDetails.context.tokenPrefix}\n` +
|
||||||
|
`- This may be a transient GitHub API issue if it works on retry\n\n` +
|
||||||
|
`Full debug details: ${JSON.stringify(lastErrorDetails, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
const simplifiedResult = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
|
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||||
|
|
||||||
// Get repository and PR information from environment variables
|
// Get repository and PR information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@@ -81,6 +82,9 @@ server.tool(
|
|||||||
|
|
||||||
const octokit = createOctokit(githubToken).rest;
|
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
|
// Validate that either line or both startLine and line are provided
|
||||||
if (!line && !startLine) {
|
if (!line && !startLine) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -104,7 +108,7 @@ server.tool(
|
|||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
pull_number,
|
pull_number,
|
||||||
body,
|
body: sanitizedBody,
|
||||||
path,
|
path,
|
||||||
side: side || "RIGHT",
|
side: side || "RIGHT",
|
||||||
commit_id: commit_id || pr.data.head.sha,
|
commit_id: commit_id || pr.data.head.sha,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const agentMode: Mode = {
|
|||||||
|
|
||||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||||
// Create prompt directory
|
// Create prompt directory
|
||||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||||
@@ -57,7 +57,7 @@ export const agentMode: Mode = {
|
|||||||
context.inputs.directPrompt ||
|
context.inputs.directPrompt ||
|
||||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
promptContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
normalizeHtmlEntities,
|
normalizeHtmlEntities,
|
||||||
sanitizeContent,
|
sanitizeContent,
|
||||||
stripHtmlComments,
|
stripHtmlComments,
|
||||||
|
redactGitHubTokens,
|
||||||
} from "../src/github/utils/sanitizer";
|
} from "../src/github/utils/sanitizer";
|
||||||
|
|
||||||
describe("stripInvisibleCharacters", () => {
|
describe("stripInvisibleCharacters", () => {
|
||||||
@@ -242,6 +243,109 @@ describe("sanitizeContent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redactGitHubTokens", () => {
|
||||||
|
it("should redact personal access tokens (ghp_)", () => {
|
||||||
|
const token = "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";
|
||||||
|
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
|
||||||
|
"Token: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
|
||||||
|
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact OAuth tokens (gho_)", () => {
|
||||||
|
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||||
|
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
|
||||||
|
"OAuth: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact installation tokens (ghs_)", () => {
|
||||||
|
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||||
|
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||||
|
"Install token: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact refresh tokens (ghr_)", () => {
|
||||||
|
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
|
||||||
|
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
|
||||||
|
"Refresh: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact fine-grained tokens (github_pat_)", () => {
|
||||||
|
const token =
|
||||||
|
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
|
||||||
|
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
|
||||||
|
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tokens in code blocks", () => {
|
||||||
|
const content = `\`\`\`bash
|
||||||
|
export GITHUB_TOKEN=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
|
||||||
|
\`\`\``;
|
||||||
|
const expected = `\`\`\`bash
|
||||||
|
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
|
||||||
|
\`\`\``;
|
||||||
|
expect(redactGitHubTokens(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple tokens in one text", () => {
|
||||||
|
const content =
|
||||||
|
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||||
|
expect(redactGitHubTokens(content)).toBe(
|
||||||
|
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tokens in URLs", () => {
|
||||||
|
const content =
|
||||||
|
"https://api.github.com/user?access_token=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";
|
||||||
|
expect(redactGitHubTokens(content)).toBe(
|
||||||
|
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not redact partial matches or invalid tokens", () => {
|
||||||
|
const content =
|
||||||
|
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
|
||||||
|
expect(redactGitHubTokens(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve normal text", () => {
|
||||||
|
const content = "Normal text with no tokens";
|
||||||
|
expect(redactGitHubTokens(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle edge cases", () => {
|
||||||
|
expect(redactGitHubTokens("")).toBe("");
|
||||||
|
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
|
||||||
|
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeContent with token redaction", () => {
|
||||||
|
it("should redact tokens as part of full sanitization", () => {
|
||||||
|
const content = `
|
||||||
|
<!-- Hidden comment with token: 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 -->
|
||||||
|
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
|
||||||
|
And invisible chars: test\u200Btoken
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sanitized = sanitizeContent(content);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain("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");
|
||||||
|
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
|
||||||
|
expect(sanitized).not.toContain("<!-- Hidden comment");
|
||||||
|
expect(sanitized).not.toContain("\u200B");
|
||||||
|
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||||
|
expect(sanitized).toContain("Here's some text with a token:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stripHtmlComments (legacy)", () => {
|
describe("stripHtmlComments (legacy)", () => {
|
||||||
it("should remove HTML comments", () => {
|
it("should remove HTML comments", () => {
|
||||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||||
|
|||||||
Reference in New Issue
Block a user