mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
2 Commits
update-cla
...
testbranch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61c881f0e | ||
|
|
e98246e0d1 |
1
.github/workflows/claude.yml
vendored
1
.github/workflows/claude.yml
vendored
@@ -36,4 +36,3 @@ jobs:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||||
model: "claude-opus-4-20250514"
|
|
||||||
|
|||||||
2
FAQ.md
2
FAQ.md
@@ -6,7 +6,7 @@ This FAQ addresses common questions and gotchas when using the Claude Code GitHu
|
|||||||
|
|
||||||
### Why doesn't tagging @claude from my automated workflow work?
|
### Why doesn't tagging @claude from my automated workflow work?
|
||||||
|
|
||||||
The `github-actions` user cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user, or use a separate app token of your own. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
|
The `github-actions` user (and other GitHub Apps/bots) cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
|
||||||
|
|
||||||
### Why does Claude say I don't have permission to trigger it?
|
### Why does Claude say I don't have permission to trigger it?
|
||||||
|
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -65,11 +65,6 @@ jobs:
|
|||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
# assignee_trigger: "claude"
|
# assignee_trigger: "claude"
|
||||||
# Optional: add custom environment variables (YAML format)
|
|
||||||
# claude_env: |
|
|
||||||
# NODE_ENV: test
|
|
||||||
# DEBUG: true
|
|
||||||
# API_URL: https://api.example.com
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
@@ -87,70 +82,13 @@ jobs:
|
|||||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
|
||||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
|
||||||
|
|
||||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||||
|
|
||||||
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
|
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
|
||||||
|
|
||||||
### Using Custom MCP Configuration
|
|
||||||
|
|
||||||
The `mcp_config` input allows you to add custom MCP (Model Context Protocol) servers to extend Claude's capabilities. These servers merge with the built-in GitHub MCP servers.
|
|
||||||
|
|
||||||
#### Basic Example: Adding a Sequential Thinking Server
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@beta
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
mcp_config: |
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"sequential-thinking": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-sequential-thinking"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allowed_tools: "mcp__sequential-thinking__sequentialthinking" # Important: Each MCP tool from your server must be listed here, comma-separated
|
|
||||||
# ... other inputs
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Passing Secrets to MCP Servers
|
|
||||||
|
|
||||||
For MCP servers that require sensitive information like API keys or tokens, use GitHub Secrets in the environment variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@beta
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
mcp_config: |
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"custom-api-server": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@example/api-server"],
|
|
||||||
"env": {
|
|
||||||
"API_KEY": "${{ secrets.CUSTOM_API_KEY }}",
|
|
||||||
"BASE_URL": "https://api.example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# ... other inputs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**:
|
|
||||||
|
|
||||||
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
|
|
||||||
- Your custom servers will override any built-in servers with the same name.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Ways to Tag @claude
|
### Ways to Tag @claude
|
||||||
@@ -295,22 +233,6 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
|
|
||||||
## Advanced Configuration
|
## Advanced Configuration
|
||||||
|
|
||||||
### Custom Environment Variables
|
|
||||||
|
|
||||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@beta
|
|
||||||
with:
|
|
||||||
claude_env: |
|
|
||||||
NODE_ENV: test
|
|
||||||
CI: true
|
|
||||||
DATABASE_URL: postgres://test:test@localhost:5432/test_db
|
|
||||||
# ... other inputs
|
|
||||||
```
|
|
||||||
|
|
||||||
The `claude_env` input accepts YAML format where each line defines a key-value pair. These environment variables will be available to Claude Code during execution, allowing it to run tests, build processes, or other commands that depend on specific environment configurations.
|
|
||||||
|
|
||||||
### Custom Tools
|
### Custom Tools
|
||||||
|
|
||||||
By default, Claude only has access to:
|
By default, Claude only has access to:
|
||||||
|
|||||||
10
action.yml
10
action.yml
@@ -39,12 +39,6 @@ inputs:
|
|||||||
description: "Direct instruction for Claude (bypasses normal trigger detection)"
|
description: "Direct instruction for Claude (bypasses normal trigger detection)"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
mcp_config:
|
|
||||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
|
||||||
claude_env:
|
|
||||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
# Auth configuration
|
# Auth configuration
|
||||||
anthropic_api_key:
|
anthropic_api_key:
|
||||||
@@ -98,14 +92,13 @@ runs:
|
|||||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||||
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
|
||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
uses: anthropics/claude-code-base-action@1370ac97fbba9bddec20ea2924b5726bf10d8b94 # v0.0.9
|
uses: anthropics/claude-code-base-action@c8e31bd52d9a149b3f8309d7978c6edaa282688d # v0.0.8
|
||||||
with:
|
with:
|
||||||
prompt_file: /tmp/claude-prompts/claude-prompt.txt
|
prompt_file: /tmp/claude-prompts/claude-prompt.txt
|
||||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||||
@@ -116,7 +109,6 @@ runs:
|
|||||||
use_bedrock: ${{ inputs.use_bedrock }}
|
use_bedrock: ${{ inputs.use_bedrock }}
|
||||||
use_vertex: ${{ inputs.use_vertex }}
|
use_vertex: ${{ inputs.use_vertex }}
|
||||||
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
||||||
claude_env: ${{ inputs.claude_env }}
|
|
||||||
env:
|
env:
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
|
|||||||
@@ -84,15 +84,13 @@ async function run() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Step 11: Get MCP configuration
|
// Step 11: Get MCP configuration
|
||||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
const mcpConfig = await prepareMcpConfig(
|
||||||
const mcpConfig = await prepareMcpConfig({
|
|
||||||
githubToken,
|
githubToken,
|
||||||
owner: context.repository.owner,
|
context.repository.owner,
|
||||||
repo: context.repository.repo,
|
context.repository.repo,
|
||||||
branch: branchInfo.currentBranch,
|
branchInfo.currentBranch,
|
||||||
additionalMcpConfig,
|
commentId.toString(),
|
||||||
claudeCommentId: commentId.toString(),
|
);
|
||||||
});
|
|
||||||
core.setOutput("mcp_config", mcpConfig);
|
core.setOutput("mcp_config", mcpConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "../github/context";
|
} from "../github/context";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
|
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -167,7 +166,7 @@ async function run() {
|
|||||||
if (Array.isArray(outputData) && outputData.length > 0) {
|
if (Array.isArray(outputData) && outputData.length > 0) {
|
||||||
const lastElement = outputData[outputData.length - 1];
|
const lastElement = outputData[outputData.length - 1];
|
||||||
if (
|
if (
|
||||||
lastElement.type === "result" &&
|
lastElement.role === "system" &&
|
||||||
"cost_usd" in lastElement &&
|
"cost_usd" in lastElement &&
|
||||||
"duration_ms" in lastElement
|
"duration_ms" in lastElement
|
||||||
) {
|
) {
|
||||||
@@ -205,14 +204,23 @@ async function run() {
|
|||||||
|
|
||||||
const updatedBody = updateCommentBody(commentInput);
|
const updatedBody = updateCommentBody(commentInput);
|
||||||
|
|
||||||
|
// Update the comment using the appropriate API
|
||||||
try {
|
try {
|
||||||
await updateClaudeComment(octokit.rest, {
|
if (isPRReviewComment) {
|
||||||
owner,
|
await octokit.rest.pulls.updateReviewComment({
|
||||||
repo,
|
owner,
|
||||||
commentId,
|
repo,
|
||||||
body: updatedBody,
|
comment_id: commentId,
|
||||||
isPullRequestReviewComment: isPRReviewComment,
|
body: updatedBody,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await octokit.rest.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body: updatedBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
|
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ export type Octokits = {
|
|||||||
|
|
||||||
export function createOctokit(token: string): Octokits {
|
export function createOctokit(token: string): Octokits {
|
||||||
return {
|
return {
|
||||||
rest: new Octokit({
|
rest: new Octokit({ auth: token }),
|
||||||
auth: token,
|
|
||||||
baseUrl: GITHUB_API_URL,
|
|
||||||
}),
|
|
||||||
graphql: graphql.defaults({
|
graphql: graphql.defaults({
|
||||||
baseUrl: GITHUB_API_URL,
|
baseUrl: GITHUB_API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
|
|
||||||
export type UpdateClaudeCommentParams = {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
commentId: number;
|
|
||||||
body: string;
|
|
||||||
isPullRequestReviewComment: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateClaudeCommentResult = {
|
|
||||||
id: number;
|
|
||||||
html_url: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a Claude comment on GitHub (either an issue/PR comment or a PR review comment)
|
|
||||||
*
|
|
||||||
* @param octokit - Authenticated Octokit instance
|
|
||||||
* @param params - Parameters for updating the comment
|
|
||||||
* @returns The updated comment details
|
|
||||||
* @throws Error if the update fails
|
|
||||||
*/
|
|
||||||
export async function updateClaudeComment(
|
|
||||||
octokit: Octokit,
|
|
||||||
params: UpdateClaudeCommentParams,
|
|
||||||
): Promise<UpdateClaudeCommentResult> {
|
|
||||||
const { owner, repo, commentId, body, isPullRequestReviewComment } = params;
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isPullRequestReviewComment) {
|
|
||||||
// Try PR review comment API first
|
|
||||||
response = await octokit.rest.pulls.updateReviewComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: commentId,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Use issue comment API (works for both issues and PR general comments)
|
|
||||||
response = await octokit.rest.issues.updateComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: commentId,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// If PR review comment update fails with 404, fall back to issue comment API
|
|
||||||
if (isPullRequestReviewComment && error.status === 404) {
|
|
||||||
response = await octokit.rest.issues.updateComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: commentId,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: response.data.id,
|
|
||||||
html_url: response.data.html_url,
|
|
||||||
updated_at: response.data.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
type ParsedGitHubContext,
|
type ParsedGitHubContext,
|
||||||
} from "../../context";
|
} from "../../context";
|
||||||
import { updateClaudeComment } from "./update-claude-comment";
|
|
||||||
|
|
||||||
export async function updateTrackingComment(
|
export async function updateTrackingComment(
|
||||||
octokit: Octokits,
|
octokit: Octokits,
|
||||||
@@ -37,19 +36,25 @@ export async function updateTrackingComment(
|
|||||||
|
|
||||||
// Update the existing comment with the branch link
|
// Update the existing comment with the branch link
|
||||||
try {
|
try {
|
||||||
const isPRReviewComment = isPullRequestReviewCommentEvent(context);
|
if (isPullRequestReviewCommentEvent(context)) {
|
||||||
|
// For PR review comments (inline comments), use the pulls API
|
||||||
await updateClaudeComment(octokit.rest, {
|
await octokit.rest.pulls.updateReviewComment({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
commentId,
|
comment_id: commentId,
|
||||||
body: updatedBody,
|
body: updatedBody,
|
||||||
isPullRequestReviewComment: isPRReviewComment,
|
});
|
||||||
});
|
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
|
||||||
|
} else {
|
||||||
console.log(
|
// For all other comments, use the issues API
|
||||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with branch link`,
|
await octokit.rest.issues.updateComment({
|
||||||
);
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body: updatedBody,
|
||||||
|
});
|
||||||
|
console.log(`✅ Updated issue comment ${commentId} with branch link`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating comment with branch link:", error);
|
console.error("Error updating comment with branch link:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { join } from "path";
|
|||||||
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 { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -441,6 +440,7 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update Claude comment tool
|
||||||
server.tool(
|
server.tool(
|
||||||
"update_claude_comment",
|
"update_claude_comment",
|
||||||
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
||||||
@@ -464,26 +464,62 @@ server.tool(
|
|||||||
const repo = REPO_NAME;
|
const repo = REPO_NAME;
|
||||||
const commentId = parseInt(claudeCommentId, 10);
|
const commentId = parseInt(claudeCommentId, 10);
|
||||||
|
|
||||||
|
// Create Octokit instance
|
||||||
const octokit = new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: githubToken,
|
auth: githubToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Determine if this is a PR review comment based on event type
|
||||||
const isPullRequestReviewComment =
|
const isPullRequestReviewComment =
|
||||||
eventName === "pull_request_review_comment";
|
eventName === "pull_request_review_comment";
|
||||||
|
|
||||||
const result = await updateClaudeComment(octokit, {
|
let response;
|
||||||
owner,
|
|
||||||
repo,
|
try {
|
||||||
commentId,
|
if (isPullRequestReviewComment) {
|
||||||
body,
|
// Try PR review comment API first
|
||||||
isPullRequestReviewComment,
|
response = await octokit.rest.pulls.updateReviewComment({
|
||||||
});
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use issue comment API (works for both issues and PR general comments)
|
||||||
|
response = await octokit.rest.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// If PR review comment update fails with 404, fall back to issue comment API
|
||||||
|
if (isPullRequestReviewComment && error.status === 404) {
|
||||||
|
response = await octokit.rest.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(result, null, 2),
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
id: response.data.id,
|
||||||
|
html_url: response.data.html_url,
|
||||||
|
updated_at: response.data.updated_at,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
type PrepareConfigParams = {
|
|
||||||
githubToken: string;
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
branch: string;
|
|
||||||
additionalMcpConfig?: string;
|
|
||||||
claudeCommentId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function prepareMcpConfig(
|
export async function prepareMcpConfig(
|
||||||
params: PrepareConfigParams,
|
githubToken: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch: string,
|
||||||
|
claudeCommentId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const {
|
|
||||||
githubToken,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch,
|
|
||||||
additionalMcpConfig,
|
|
||||||
claudeCommentId,
|
|
||||||
} = params;
|
|
||||||
try {
|
try {
|
||||||
const baseMcpConfig = {
|
const mcpConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
github: {
|
github: {
|
||||||
command: "docker",
|
command: "docker",
|
||||||
@@ -57,39 +44,7 @@ export async function prepareMcpConfig(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge with additional MCP config if provided
|
return JSON.stringify(mcpConfig, null, 2);
|
||||||
if (additionalMcpConfig && additionalMcpConfig.trim()) {
|
|
||||||
try {
|
|
||||||
const additionalConfig = JSON.parse(additionalMcpConfig);
|
|
||||||
|
|
||||||
// Validate that parsed JSON is an object
|
|
||||||
if (typeof additionalConfig !== "object" || additionalConfig === null) {
|
|
||||||
throw new Error("MCP config must be a valid JSON object");
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info(
|
|
||||||
"Merging additional MCP server configuration with built-in servers",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Merge configurations with user config overriding built-in servers
|
|
||||||
const mergedConfig = {
|
|
||||||
...baseMcpConfig,
|
|
||||||
...additionalConfig,
|
|
||||||
mcpServers: {
|
|
||||||
...baseMcpConfig.mcpServers,
|
|
||||||
...additionalConfig.mcpServers,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return JSON.stringify(mergedConfig, null, 2);
|
|
||||||
} catch (parseError) {
|
|
||||||
core.warning(
|
|
||||||
`Failed to parse additional MCP config: ${parseError}. Using base config only.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(baseMcpConfig, null, 2);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setFailed(`Install MCP server failed with error: ${error}`);
|
core.setFailed(`Install MCP server failed with error: ${error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
|
||||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
|
||||||
import * as core from "@actions/core";
|
|
||||||
|
|
||||||
describe("prepareMcpConfig", () => {
|
|
||||||
let consoleInfoSpy: any;
|
|
||||||
let consoleWarningSpy: any;
|
|
||||||
let setFailedSpy: any;
|
|
||||||
let processExitSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
|
||||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
|
||||||
setFailedSpy = spyOn(core, "setFailed").mockImplementation(() => {});
|
|
||||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
|
||||||
throw new Error("Process exit");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleInfoSpy.mockRestore();
|
|
||||||
consoleWarningSpy.mockRestore();
|
|
||||||
setFailedSpy.mockRestore();
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return base config when no additional config is provided", async () => {
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
|
||||||
"test-token",
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
|
||||||
"test-token",
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
|
|
||||||
"test-branch",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return base config when additional config is empty string", async () => {
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return base config when additional config is whitespace only", async () => {
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: " \n\t ",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge valid additional config with base config", async () => {
|
|
||||||
const additionalConfig = JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
custom_server: {
|
|
||||||
command: "custom-command",
|
|
||||||
args: ["arg1", "arg2"],
|
|
||||||
env: {
|
|
||||||
CUSTOM_ENV: "custom-value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: additionalConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
||||||
"Merging additional MCP server configuration with built-in servers",
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.custom_server).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
|
|
||||||
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
|
|
||||||
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should override built-in servers when additional config has same server names", async () => {
|
|
||||||
const additionalConfig = JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
github: {
|
|
||||||
command: "overridden-command",
|
|
||||||
args: ["overridden-arg"],
|
|
||||||
env: {
|
|
||||||
OVERRIDDEN_ENV: "overridden-value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: additionalConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
||||||
"Merging additional MCP server configuration with built-in servers",
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github.command).toBe("overridden-command");
|
|
||||||
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
|
|
||||||
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
|
|
||||||
"overridden-value",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
|
|
||||||
).toBeUndefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge additional root-level properties", async () => {
|
|
||||||
const additionalConfig = JSON.stringify({
|
|
||||||
customProperty: "custom-value",
|
|
||||||
anotherProperty: {
|
|
||||||
nested: "value",
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
custom_server: {
|
|
||||||
command: "custom",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: additionalConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.customProperty).toBe("custom-value");
|
|
||||||
expect(parsed.anotherProperty).toEqual({ nested: "value" });
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.custom_server).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle invalid JSON gracefully", async () => {
|
|
||||||
const invalidJson = "{ invalid json }";
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: invalidJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle non-object JSON values", async () => {
|
|
||||||
const nonObjectJson = JSON.stringify("string value");
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: nonObjectJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
||||||
);
|
|
||||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("MCP config must be a valid JSON object"),
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle null JSON value", async () => {
|
|
||||||
const nullJson = JSON.stringify(null);
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: nullJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
||||||
);
|
|
||||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("MCP config must be a valid JSON object"),
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle array JSON value", async () => {
|
|
||||||
const arrayJson = JSON.stringify([1, 2, 3]);
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: arrayJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
// Arrays are objects in JavaScript, so they pass the object check
|
|
||||||
// But they'll fail when trying to spread or access mcpServers property
|
|
||||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
||||||
"Merging additional MCP server configuration with built-in servers",
|
|
||||||
);
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
||||||
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
|
|
||||||
expect(parsed[0]).toBe(1);
|
|
||||||
expect(parsed[1]).toBe(2);
|
|
||||||
expect(parsed[2]).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge complex nested configurations", async () => {
|
|
||||||
const additionalConfig = JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
server1: {
|
|
||||||
command: "cmd1",
|
|
||||||
env: { KEY1: "value1" },
|
|
||||||
},
|
|
||||||
server2: {
|
|
||||||
command: "cmd2",
|
|
||||||
env: { KEY2: "value2" },
|
|
||||||
},
|
|
||||||
github_file_ops: {
|
|
||||||
command: "overridden",
|
|
||||||
env: { CUSTOM: "value" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
otherConfig: {
|
|
||||||
nested: {
|
|
||||||
deeply: "value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
additionalMcpConfig: additionalConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.server1).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.server2).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
|
|
||||||
expect(parsed.otherConfig.nested.deeply).toBe("value");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
|
|
||||||
const oldEnv = process.env.GITHUB_ACTION_PATH;
|
|
||||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
|
|
||||||
"/test/action/path/src/mcp/github-file-ops-server.ts",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.env.GITHUB_ACTION_PATH = oldEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
|
|
||||||
const oldEnv = process.env.GITHUB_WORKSPACE;
|
|
||||||
delete process.env.GITHUB_WORKSPACE;
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
|
|
||||||
|
|
||||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
import { describe, test, expect, jest, beforeEach } from "bun:test";
|
|
||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
import {
|
|
||||||
updateClaudeComment,
|
|
||||||
type UpdateClaudeCommentParams,
|
|
||||||
} from "../src/github/operations/comments/update-claude-comment";
|
|
||||||
|
|
||||||
describe("updateClaudeComment", () => {
|
|
||||||
let mockOctokit: Octokit;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockOctokit = {
|
|
||||||
rest: {
|
|
||||||
issues: {
|
|
||||||
updateComment: jest.fn(),
|
|
||||||
},
|
|
||||||
pulls: {
|
|
||||||
updateReviewComment: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any as Octokit;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update issue comment successfully", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 123456,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
body: "Updated comment",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 123456,
|
|
||||||
body: "Updated comment",
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 123456,
|
|
||||||
body: "Updated comment",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 123456,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update PR comment successfully", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 789012,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
|
|
||||||
updated_at: "2024-01-02T00:00:00Z",
|
|
||||||
body: "Updated PR comment",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 789012,
|
|
||||||
body: "Updated PR comment",
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 789012,
|
|
||||||
body: "Updated PR comment",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 789012,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
|
|
||||||
updated_at: "2024-01-02T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update PR review comment successfully", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 345678,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
body: "Updated review comment",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 345678,
|
|
||||||
body: "Updated review comment",
|
|
||||||
isPullRequestReviewComment: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 345678,
|
|
||||||
body: "Updated review comment",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 345678,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fallback to issue comment API when PR review comment update fails with 404", async () => {
|
|
||||||
const mockError = new Error("Not Found") as any;
|
|
||||||
mockError.status = 404;
|
|
||||||
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 456789,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
|
|
||||||
updated_at: "2024-01-04T00:00:00Z",
|
|
||||||
body: "Updated via fallback",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(mockError);
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 456789,
|
|
||||||
body: "Updated via fallback",
|
|
||||||
isPullRequestReviewComment: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 456789,
|
|
||||||
body: "Updated via fallback",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 456789,
|
|
||||||
body: "Updated via fallback",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 456789,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
|
|
||||||
updated_at: "2024-01-04T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should propagate error when PR review comment update fails with non-404 error", async () => {
|
|
||||||
const mockError = new Error("Internal Server Error") as any;
|
|
||||||
mockError.status = 500;
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(mockError);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 567890,
|
|
||||||
body: "This will fail",
|
|
||||||
isPullRequestReviewComment: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
|
|
||||||
mockError,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 567890,
|
|
||||||
body: "This will fail",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure fallback wasn't attempted
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should propagate error when issue comment update fails", async () => {
|
|
||||||
const mockError = new Error("Forbidden");
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(mockError);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 678901,
|
|
||||||
body: "This will also fail",
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
|
|
||||||
mockError,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 678901,
|
|
||||||
body: "This will also fail",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty body", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 111222,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
|
|
||||||
updated_at: "2024-01-05T00:00:00Z",
|
|
||||||
body: "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 111222,
|
|
||||||
body: "",
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 111222,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
|
|
||||||
updated_at: "2024-01-05T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle very long body", async () => {
|
|
||||||
const longBody = "x".repeat(10000);
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 333444,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
|
|
||||||
updated_at: "2024-01-06T00:00:00Z",
|
|
||||||
body: longBody,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 333444,
|
|
||||||
body: longBody,
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 333444,
|
|
||||||
body: longBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 333444,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
|
|
||||||
updated_at: "2024-01-06T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle markdown formatting in body", async () => {
|
|
||||||
const markdownBody = `
|
|
||||||
# Header
|
|
||||||
- List item 1
|
|
||||||
- List item 2
|
|
||||||
|
|
||||||
\`\`\`typescript
|
|
||||||
const code = "example";
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
[Link](https://example.com)
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 555666,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
|
|
||||||
updated_at: "2024-01-07T00:00:00Z",
|
|
||||||
body: markdownBody,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.issues.updateComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 555666,
|
|
||||||
body: markdownBody,
|
|
||||||
isPullRequestReviewComment: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
comment_id: 555666,
|
|
||||||
body: markdownBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 555666,
|
|
||||||
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
|
|
||||||
updated_at: "2024-01-07T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle different response data fields", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: 777888,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
|
|
||||||
updated_at: "2024-01-08T12:30:45Z",
|
|
||||||
body: "Updated",
|
|
||||||
// Additional fields that might be in the response
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
user: { login: "bot" },
|
|
||||||
node_id: "MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDc3Nzg4OA==",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
||||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const params: UpdateClaudeCommentParams = {
|
|
||||||
owner: "testowner",
|
|
||||||
repo: "testrepo",
|
|
||||||
commentId: 777888,
|
|
||||||
body: "Updated",
|
|
||||||
isPullRequestReviewComment: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(mockOctokit, params);
|
|
||||||
|
|
||||||
// Should only return the specific fields we care about
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 777888,
|
|
||||||
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
|
|
||||||
updated_at: "2024-01-08T12:30:45Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user