mirror of
https://github.com/markwylde/claude-code-gitea-action.git
synced 2026-02-19 18:12:50 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225a4e6f3a | ||
|
|
ebd4882b3e | ||
|
|
5bcd15c520 | ||
|
|
3305a16297 |
3
.github/workflows/claude-review.yml
vendored
3
.github/workflows/claude-review.yml
vendored
@@ -29,5 +29,6 @@ jobs:
|
||||
- Documentation consistency: Verify that README.md and other documentation files are updated to reflect any code changes (especially new inputs, features, or configuration options)
|
||||
|
||||
Be constructive and specific in your feedback. Give inline comments where applicable.
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"
|
||||
|
||||
3
.github/workflows/claude.yml
vendored
3
.github/workflows/claude.yml
vendored
@@ -33,7 +33,8 @@ jobs:
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||
model: "claude-opus-4-20250514"
|
||||
|
||||
3
.github/workflows/issue-triage.yml
vendored
3
.github/workflows/issue-triage.yml
vendored
@@ -103,4 +103,5 @@ jobs:
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
mcp_config: /tmp/mcp-config/mcp-servers.json
|
||||
timeout_minutes: "5"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
|
||||
6
.github/workflows/test-base-action.yml
vendored
6
.github/workflows/test-base-action.yml
vendored
@@ -23,7 +23,8 @@ jobs:
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
allowed_tools: "LS,Read"
|
||||
timeout_minutes: "3"
|
||||
|
||||
@@ -81,7 +82,8 @@ jobs:
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt_file: "test-prompt.txt"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
allowed_tools: "LS,Read"
|
||||
timeout_minutes: "3"
|
||||
|
||||
|
||||
3
.github/workflows/test-claude-env.yml
vendored
3
.github/workflows/test-claude-env.yml
vendored
@@ -19,7 +19,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
Use the Bash tool to run: echo "VAR1: $VAR1" && echo "VAR2: $VAR2"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_env: |
|
||||
# This is a comment
|
||||
VAR1: value1
|
||||
|
||||
@@ -49,7 +49,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
List the files in the current directory starting with "package"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
path_to_claude_code_executable: /home/runner/.local/bin/claude
|
||||
path_to_bun_executable: /home/runner/.bun/bin/bun
|
||||
allowed_tools: "LS,Read"
|
||||
|
||||
6
.github/workflows/test-mcp-servers.yml
vendored
6
.github/workflows/test-mcp-servers.yml
vendored
@@ -28,7 +28,8 @@ jobs:
|
||||
id: claude-test
|
||||
with:
|
||||
prompt: "List all available tools"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
env:
|
||||
# Change to test directory so it finds .mcp.json
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
|
||||
@@ -109,7 +110,8 @@ jobs:
|
||||
id: claude-config-test
|
||||
with:
|
||||
prompt: "List all available tools"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}'
|
||||
env:
|
||||
# Change to test directory so bun can find the MCP server script
|
||||
|
||||
12
.github/workflows/test-settings.yml
vendored
12
.github/workflows/test-settings.yml
vendored
@@ -19,7 +19,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "Hello from settings test"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
@@ -69,7 +70,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
@@ -112,7 +114,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "Hello from settings file test"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
settings: "test-settings.json"
|
||||
timeout_minutes: "2"
|
||||
|
||||
@@ -167,7 +170,8 @@ jobs:
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work from file"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY && secrets.ANTHROPIC_API_KEY || secrets.CLAUDE_CREDENTIALS }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
settings: "test-settings.json"
|
||||
timeout_minutes: "2"
|
||||
|
||||
|
||||
249
package-lock.json
generated
249
package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "claude-pr-action",
|
||||
"name": "@anthropic-ai/claude-code-action",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-pr-action",
|
||||
"name": "@anthropic-ai/claude-code-action",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"zod": "^3.24.4"
|
||||
@@ -209,6 +210,82 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.2.tgz",
|
||||
"integrity": "sha512-iz6KzZ7u95Fzy9Nt2L8cG88lGRMr/qy1Q36ih/XVzMIlPDMYwaNLE/ENhqmIzgPrlNWiYJkwmveEetvxAgFBJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/request": "^10.0.4",
|
||||
"@octokit/types": "^15.0.0",
|
||||
"universal-user-agent": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/@octokit/endpoint": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.1.tgz",
|
||||
"integrity": "sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0",
|
||||
"universal-user-agent": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": {
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz",
|
||||
"integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/@octokit/request": {
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.5.tgz",
|
||||
"integrity": "sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/endpoint": "^11.0.1",
|
||||
"@octokit/request-error": "^7.0.1",
|
||||
"@octokit/types": "^15.0.0",
|
||||
"fast-content-type-parse": "^3.0.0",
|
||||
"universal-user-agent": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/@octokit/request-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.1.tgz",
|
||||
"integrity": "sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/@octokit/types": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.0.tgz",
|
||||
"integrity": "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql/node_modules/universal-user-agent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
|
||||
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@octokit/openapi-types": {
|
||||
"version": "24.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz",
|
||||
@@ -304,6 +381,158 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz",
|
||||
"integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/core": "^7.0.2",
|
||||
"@octokit/plugin-paginate-rest": "^13.0.1",
|
||||
"@octokit/plugin-request-log": "^6.0.0",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/auth-token": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
|
||||
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/core": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.5.tgz",
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
"@octokit/request": "^10.0.4",
|
||||
"@octokit/request-error": "^7.0.1",
|
||||
"@octokit/types": "^15.0.0",
|
||||
"before-after-hook": "^4.0.0",
|
||||
"universal-user-agent": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/endpoint": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.1.tgz",
|
||||
"integrity": "sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0",
|
||||
"universal-user-agent": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/openapi-types": {
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz",
|
||||
"integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/plugin-paginate-rest": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.2.0.tgz",
|
||||
"integrity": "sha512-YuAlyjR8o5QoRSOvMHxSJzPtogkNMgeMv2mpccrvdUGeC3MKyfi/hS+KiFwyH/iRKIKyx+eIMsDjbt3p9r2GYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/plugin-request-log": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
|
||||
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/plugin-rest-endpoint-methods": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.1.0.tgz",
|
||||
"integrity": "sha512-nCsyiKoGRnhH5LkH8hJEZb9swpqOcsW+VXv1QoyUNQXJeVODG4+xM6UICEqyqe9XFr6LkL8BIiFCPev8zMDXPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/request": {
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.5.tgz",
|
||||
"integrity": "sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/endpoint": "^11.0.1",
|
||||
"@octokit/request-error": "^7.0.1",
|
||||
"@octokit/types": "^15.0.0",
|
||||
"fast-content-type-parse": "^3.0.0",
|
||||
"universal-user-agent": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/request-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.1.tgz",
|
||||
"integrity": "sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^15.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/@octokit/types": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.0.tgz",
|
||||
"integrity": "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/before-after-hook": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
|
||||
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@octokit/rest/node_modules/universal-user-agent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
|
||||
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@octokit/types": {
|
||||
"version": "13.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz",
|
||||
@@ -785,6 +1014,22 @@
|
||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-content-type-parse": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
|
||||
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"zod": "^3.24.4"
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "../github/context";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||
import { GITEA_SERVER_URL } from "../github/api/config";
|
||||
import type { Mode, ModeContext } from "../modes/types";
|
||||
export type { CommonFields, PreparedContext } from "./types";
|
||||
|
||||
const BASE_ALLOWED_TOOLS = [
|
||||
@@ -62,37 +62,74 @@ const BASE_ALLOWED_TOOLS = [
|
||||
];
|
||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string[],
|
||||
): string {
|
||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||
const ACTIONS_ALLOWED_TOOLS = [
|
||||
"mcp__github_actions__get_ci_status",
|
||||
"mcp__github_actions__get_workflow_run_details",
|
||||
"mcp__github_actions__download_job_log",
|
||||
];
|
||||
|
||||
let allAllowedTools = baseTools.join(",");
|
||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||
const COMMIT_SIGNING_TOOLS = [
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
];
|
||||
|
||||
function normalizeToolList(input?: string | string[]): string[] {
|
||||
if (!input) {
|
||||
return [];
|
||||
}
|
||||
return allAllowedTools;
|
||||
|
||||
const tools = Array.isArray(input) ? input : input.split(",");
|
||||
return tools
|
||||
.map((tool) => tool.trim())
|
||||
.filter((tool): tool is string => tool.length > 0);
|
||||
}
|
||||
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string | string[],
|
||||
includeActionsReadTools = false,
|
||||
useCommitSigning = false,
|
||||
): string {
|
||||
const allowedTools = new Set<string>(BASE_ALLOWED_TOOLS);
|
||||
|
||||
if (includeActionsReadTools) {
|
||||
for (const tool of ACTIONS_ALLOWED_TOOLS) {
|
||||
allowedTools.add(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (useCommitSigning) {
|
||||
for (const tool of COMMIT_SIGNING_TOOLS) {
|
||||
allowedTools.add(tool);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of normalizeToolList(customAllowedTools)) {
|
||||
allowedTools.add(tool);
|
||||
}
|
||||
|
||||
return Array.from(allowedTools).join(",");
|
||||
}
|
||||
|
||||
export function buildDisallowedToolsString(
|
||||
customDisallowedTools?: string[],
|
||||
allowedTools?: string[],
|
||||
customDisallowedTools?: string | string[],
|
||||
allowedTools?: string | string[],
|
||||
): string {
|
||||
let disallowedTools = [...DISALLOWED_TOOLS];
|
||||
|
||||
// If user has explicitly allowed some hardcoded disallowed tools, remove them from disallowed list
|
||||
if (allowedTools && allowedTools.length > 0) {
|
||||
disallowedTools = disallowedTools.filter(
|
||||
(tool) => !allowedTools.includes(tool),
|
||||
);
|
||||
const allowedList = normalizeToolList(allowedTools);
|
||||
if (allowedList.length > 0) {
|
||||
disallowedTools = disallowedTools.filter((tool) => !allowedList.includes(tool));
|
||||
}
|
||||
|
||||
let allDisallowedTools = disallowedTools.join(",");
|
||||
if (customDisallowedTools && customDisallowedTools.length > 0) {
|
||||
const customList = normalizeToolList(customDisallowedTools);
|
||||
if (customList.length > 0) {
|
||||
if (allDisallowedTools) {
|
||||
allDisallowedTools = `${allDisallowedTools},${customDisallowedTools.join(",")}`;
|
||||
allDisallowedTools = `${allDisallowedTools},${customList.join(",")}`;
|
||||
} else {
|
||||
allDisallowedTools = customDisallowedTools.join(",");
|
||||
allDisallowedTools = customList.join(",");
|
||||
}
|
||||
}
|
||||
return allDisallowedTools;
|
||||
@@ -266,7 +303,6 @@ export function prepareContext(
|
||||
if (!baseBranch) {
|
||||
throw new Error("BASE_BRANCH is required for issues event");
|
||||
}
|
||||
|
||||
if (eventAction === "assigned") {
|
||||
if (!assigneeTrigger && !directPrompt) {
|
||||
throw new Error(
|
||||
@@ -279,7 +315,7 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
assigneeTrigger,
|
||||
...(assigneeTrigger && { assigneeTrigger }),
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
} else if (eventAction === "labeled") {
|
||||
@@ -292,7 +328,7 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
labelTrigger,
|
||||
};
|
||||
} else if (eventAction === "opened") {
|
||||
@@ -302,7 +338,7 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
claudeBranch,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported issue action: ${eventAction}`);
|
||||
@@ -393,64 +429,6 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitInstructions(
|
||||
eventData: EventData,
|
||||
githubData: FetchDataResult,
|
||||
context: PreparedContext,
|
||||
useCommitSigning: boolean,
|
||||
): string {
|
||||
const coAuthorLine =
|
||||
(githubData.triggerDisplayName ?? context.triggerUsername !== "Unknown")
|
||||
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
|
||||
: "";
|
||||
|
||||
if (useCommitSigning) {
|
||||
if (eventData.isPR && !eventData.claudeBranch) {
|
||||
return `
|
||||
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "${coAuthorLine}"`;
|
||||
} else {
|
||||
return `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "${coAuthorLine}"`;
|
||||
}
|
||||
} else {
|
||||
// Non-signing instructions
|
||||
if (eventData.isPR && !eventData.claudeBranch) {
|
||||
return `
|
||||
- Use git commands via the Bash tool to commit and push your changes:
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||
${
|
||||
coAuthorLine
|
||||
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin HEAD)`;
|
||||
} else {
|
||||
const branchName = eventData.claudeBranch || eventData.baseBranch;
|
||||
return `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Use git commands via the Bash tool to commit and push your changes:
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||
${
|
||||
coAuthorLine
|
||||
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin ${branchName})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function substitutePromptVariables(
|
||||
template: string,
|
||||
context: PreparedContext,
|
||||
@@ -517,7 +495,7 @@ function substitutePromptVariables(
|
||||
export function generatePrompt(
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
useCommitSigning: boolean,
|
||||
useCommitSigning = false,
|
||||
): string {
|
||||
if (context.overridePrompt) {
|
||||
return substitutePromptVariables(
|
||||
@@ -527,6 +505,8 @@ export function generatePrompt(
|
||||
);
|
||||
}
|
||||
|
||||
const triggerDisplayName = context.triggerUsername ?? "Unknown";
|
||||
|
||||
const {
|
||||
contextData,
|
||||
comments,
|
||||
@@ -594,7 +574,7 @@ ${
|
||||
}
|
||||
<claude_comment_id>${context.claudeCommentId}</claude_comment_id>
|
||||
<trigger_username>${context.triggerUsername ?? "Unknown"}</trigger_username>
|
||||
<trigger_display_name>${githubData.triggerDisplayName ?? context.triggerUsername ?? "Unknown"}</trigger_display_name>
|
||||
<trigger_display_name>${triggerDisplayName}</trigger_display_name>
|
||||
<trigger_phrase>${context.triggerPhrase}</trigger_phrase>
|
||||
${
|
||||
(eventData.eventName === "issue_comment" ||
|
||||
|
||||
@@ -66,7 +66,7 @@ type IssueAssignedEvent = {
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
assigneeTrigger: string;
|
||||
assigneeTrigger?: string;
|
||||
};
|
||||
|
||||
type IssueLabeledEvent = {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createPrompt } from "../create-prompt";
|
||||
import { createClient } from "../github/api/client";
|
||||
import { fetchGitHubData } from "../github/data/fetcher";
|
||||
import { parseGitHubContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -54,9 +55,14 @@ async function run() {
|
||||
// Step 5: Check if actor is human
|
||||
await checkHumanActor(client.api, context);
|
||||
|
||||
// Step 6: Create initial tracking comment
|
||||
const commentId = await createInitialComment(client.api, context);
|
||||
core.setOutput("claude_comment_id", commentId.toString());
|
||||
const mode = getMode(context.inputs.mode);
|
||||
|
||||
// Step 6: Create initial tracking comment (if required by mode)
|
||||
let commentId: number | undefined;
|
||||
if (mode.shouldCreateTrackingComment()) {
|
||||
commentId = await createInitialComment(client.api, context);
|
||||
core.setOutput("claude_comment_id", commentId!.toString());
|
||||
}
|
||||
|
||||
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
const githubData = await fetchGitHubData({
|
||||
@@ -74,7 +80,7 @@ async function run() {
|
||||
}
|
||||
|
||||
// Step 9: Update initial comment with branch link (only if a claude branch was created)
|
||||
if (branchInfo.claudeBranch) {
|
||||
if (commentId && branchInfo.claudeBranch) {
|
||||
await updateTrackingComment(
|
||||
client,
|
||||
context,
|
||||
@@ -84,21 +90,24 @@ async function run() {
|
||||
}
|
||||
|
||||
// Step 10: Create prompt file
|
||||
await createPrompt(
|
||||
const modeContext = mode.prepareContext(context, {
|
||||
commentId,
|
||||
branchInfo.baseBranch,
|
||||
branchInfo.claudeBranch,
|
||||
githubData,
|
||||
context,
|
||||
);
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
await createPrompt(mode, modeContext, githubData, context);
|
||||
|
||||
// Step 11: Get MCP configuration
|
||||
const mcpConfig = await prepareMcpConfig(
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
context.repository.owner,
|
||||
context.repository.repo,
|
||||
branchInfo.currentBranch,
|
||||
);
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
context,
|
||||
});
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -29,3 +29,7 @@ export const GITEA_SERVER_URL = getServerUrl();
|
||||
|
||||
export const GITEA_API_URL =
|
||||
process.env.GITEA_API_URL || deriveApiUrl(GITEA_SERVER_URL);
|
||||
|
||||
// Backwards-compatible aliases for legacy GitHub-specific naming
|
||||
export const GITHUB_SERVER_URL = GITEA_SERVER_URL;
|
||||
export const GITHUB_API_URL = GITEA_API_URL;
|
||||
|
||||
@@ -141,14 +141,16 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
|
||||
if (branchLink) {
|
||||
// Extract the branch URL from the link
|
||||
const urlMatch = branchLink.match(/\((https:\/\/.*)\)/);
|
||||
const urlMatch = branchLink.match(/\((https?:\/\/[^\)]+)\)/);
|
||||
if (urlMatch && urlMatch[1]) {
|
||||
branchUrl = urlMatch[1];
|
||||
}
|
||||
|
||||
// Extract branch name from link if not provided
|
||||
if (!finalBranchName) {
|
||||
const branchNameMatch = branchLink.match(/tree\/([^"'\)]+)/);
|
||||
const branchNameMatch = branchLink.match(
|
||||
/(?:tree|src\/branch)\/([^"'\)\s]+)/,
|
||||
);
|
||||
if (branchNameMatch) {
|
||||
finalBranchName = branchNameMatch[1];
|
||||
}
|
||||
@@ -157,10 +159,17 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
|
||||
// If we don't have a URL yet but have a branch name, construct it
|
||||
if (!branchUrl && finalBranchName) {
|
||||
// Extract owner/repo from jobUrl
|
||||
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
|
||||
if (repoMatch) {
|
||||
branchUrl = `${GITEA_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/src/branch/${finalBranchName}`;
|
||||
try {
|
||||
const parsedJobUrl = new URL(jobUrl);
|
||||
const segments = parsedJobUrl.pathname
|
||||
.split("/")
|
||||
.filter((segment) => segment);
|
||||
const [owner, repo] = segments;
|
||||
if (owner && repo) {
|
||||
branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${finalBranchName}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to derive branch URL from job URL: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { GITEA_SERVER_URL } from "../../api/config";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
function getSpinnerHtml(): string {
|
||||
return `<img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { $ } from "bun";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { GITEA_SERVER_URL } from "../api/config";
|
||||
|
||||
type GitUser = {
|
||||
login: string;
|
||||
@@ -22,7 +22,7 @@ export async function configureGitAuth(
|
||||
console.log("Configuring git authentication for non-signing mode");
|
||||
|
||||
// Determine the noreply email domain based on GITHUB_SERVER_URL
|
||||
const serverUrl = new URL(GITHUB_SERVER_URL);
|
||||
const serverUrl = new URL(GITEA_SERVER_URL);
|
||||
const noreplyDomain =
|
||||
serverUrl.hostname === "github.com"
|
||||
? "users.noreply.github.com"
|
||||
@@ -46,7 +46,7 @@ export async function configureGitAuth(
|
||||
// Remove the authorization header that actions/checkout sets
|
||||
console.log("Removing existing git authentication headers...");
|
||||
try {
|
||||
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
await $`git config --unset-all http.${GITEA_SERVER_URL}/.extraheader`;
|
||||
console.log("✓ Removed existing authentication headers");
|
||||
} catch (e) {
|
||||
console.log("No existing authentication headers to remove");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPullRequestReviewEvent,
|
||||
isPullRequestReviewCommentEvent,
|
||||
} from "../context";
|
||||
import type { IssuesLabeledEvent } from "@octokit/webhooks-types";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
@@ -41,6 +42,26 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue label trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "labeled") {
|
||||
const triggerLabel = context.inputs.labelTrigger?.trim();
|
||||
const appliedLabel = (context.payload as IssuesLabeledEvent).label?.name
|
||||
?.trim();
|
||||
|
||||
console.log(
|
||||
`Checking label trigger: expected='${triggerLabel}', applied='${appliedLabel}'`,
|
||||
);
|
||||
|
||||
if (
|
||||
triggerLabel &&
|
||||
appliedLabel &&
|
||||
triggerLabel.localeCompare(appliedLabel, undefined, { sensitivity: "accent" }) === 0
|
||||
) {
|
||||
console.log(`Issue labeled with trigger label '${triggerLabel}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue body and title trigger on issue creation
|
||||
if (isIssuesEvent(context) && context.eventAction === "opened") {
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
@@ -942,7 +942,7 @@ server.tool(
|
||||
endpoint += `?style=${style}`;
|
||||
}
|
||||
|
||||
const result = await giteaRequest(endpoint, "POST");
|
||||
await giteaRequest(endpoint, "POST");
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
|
||||
export async function prepareMcpConfig(
|
||||
githubToken: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<string> {
|
||||
export type PrepareMcpConfigOptions = {
|
||||
githubToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch?: string;
|
||||
allowedTools?: string[];
|
||||
context?: ParsedGitHubContext;
|
||||
overrideConfig?: string;
|
||||
additionalMcpConfig?: string;
|
||||
};
|
||||
|
||||
export async function prepareMcpConfig({
|
||||
githubToken,
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
}: PrepareMcpConfigOptions): Promise<string> {
|
||||
console.log("[MCP-INSTALL] Preparing MCP configuration...");
|
||||
console.log(`[MCP-INSTALL] Owner: ${owner}`);
|
||||
console.log(`[MCP-INSTALL] Repo: ${repo}`);
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
// Get repository information from environment variables
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
|
||||
import type { Octokits } from "../src/github/api/client";
|
||||
import type { GitHubClient } from "../src/github/api/client";
|
||||
import { GITEA_SERVER_URL } from "../src/github/api/config";
|
||||
|
||||
describe("checkAndDeleteEmptyBranch", () => {
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on console methods
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
||||
delete process.env.GITEA_API_URL; // ensure GitHub mode for predictable behaviour
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
const createMockOctokit = (
|
||||
compareResponse?: any,
|
||||
deleteRefError?: Error,
|
||||
): Octokits => {
|
||||
const createMockClient = (
|
||||
options: { branchSha?: string; baseSha?: string; error?: Error } = {},
|
||||
): GitHubClient => {
|
||||
const { branchSha = "branch-sha", baseSha = "base-sha", error } = options;
|
||||
return {
|
||||
rest: {
|
||||
repos: {
|
||||
compareCommitsWithBasehead: async () => ({
|
||||
data: compareResponse || { total_commits: 0 },
|
||||
}),
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => {
|
||||
if (deleteRefError) {
|
||||
throw deleteRefError;
|
||||
}
|
||||
return { data: {} };
|
||||
},
|
||||
api: {
|
||||
getBranch: async (_owner: string, _repo: string, branch: string) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
commit: {
|
||||
sha: branch.includes("claude/") ? branchSha : baseSha,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as any as Octokits;
|
||||
} as unknown as GitHubClient;
|
||||
};
|
||||
|
||||
test("should return no branch link and not delete when branch is undefined", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
test("returns defaults when no claude branch provided", async () => {
|
||||
const client = createMockClient();
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
client,
|
||||
"owner",
|
||||
"repo",
|
||||
undefined,
|
||||
@@ -56,94 +57,65 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should delete branch and return no link when branch has no commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 });
|
||||
test("marks branch for deletion when SHAs match", async () => {
|
||||
const client = createMockClient({ branchSha: "same", baseSha: "same" });
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
client,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123",
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
|
||||
"Branch claude/issue-123 has same SHA as base, marking for deletion",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
|
||||
"Skipping branch deletion - not reliably supported across all Git platforms: claude/issue-123",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not delete branch and return link when branch has commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 3 });
|
||||
test("returns branch link when branch has commits", async () => {
|
||||
const client = createMockClient({ branchSha: "feature", baseSha: "main" });
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
client,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123",
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`,
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
|
||||
);
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("has no commits"),
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123 appears to have commits (different SHA from base)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle branch comparison errors gracefully", async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
compareCommitsWithBasehead: async () => {
|
||||
throw new Error("API error");
|
||||
},
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => ({ data: {} }),
|
||||
},
|
||||
},
|
||||
} as any as Octokits;
|
||||
|
||||
test("falls back to branch link when API call fails", async () => {
|
||||
const client = createMockClient({ error: Object.assign(new Error("boom"), { status: 500 }) });
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
client,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123",
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`,
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error checking for commits on Claude branch:",
|
||||
"Error checking branch:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle branch deletion errors gracefully", async () => {
|
||||
const deleteError = new Error("Delete failed");
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
|
||||
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to delete branch claude/issue-123-20240101_123456:",
|
||||
deleteError,
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Assuming branch exists due to non-404 error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { updateCommentBody } from "../src/github/operations/comment-logic";
|
||||
|
||||
describe("updateCommentBody", () => {
|
||||
const GITEA_SERVER_URL = "https://gitea.example.com";
|
||||
const JOB_URL = `${GITEA_SERVER_URL}/owner/repo/actions/runs/123`;
|
||||
const BRANCH_BASE_URL = `${GITEA_SERVER_URL}/owner/repo/src/branch`;
|
||||
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.GITEA_SERVER_URL = GITEA_SERVER_URL;
|
||||
process.env.GITEA_API_URL = `${GITEA_SERVER_URL}/api/v1`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
const baseInput = {
|
||||
currentBody: "Initial comment body",
|
||||
actionFailed: false,
|
||||
executionDetails: null,
|
||||
jobUrl: "https://github.com/owner/repo/actions/runs/123",
|
||||
jobUrl: JOB_URL,
|
||||
branchName: undefined,
|
||||
triggerUsername: undefined,
|
||||
};
|
||||
@@ -105,20 +121,19 @@ describe("updateCommentBody", () => {
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
`• [\`claude/issue-123-20240101_120000\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts branch name from branchLink if branchName not provided", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
branchLink:
|
||||
"\n[View branch](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
branchLink: `\n[View branch](${BRANCH_BASE_URL}/branch-name)`,
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`branch-name`](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
`• [\`branch-name\`](${BRANCH_BASE_URL}/branch-name)`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -126,13 +141,13 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody:
|
||||
"Some comment with [View branch](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
`Some comment with [View branch](${BRANCH_BASE_URL}/branch-name)` ,
|
||||
branchName: "new-branch-name",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`new-branch-name`](https://github.com/owner/repo/src/branch/new-branch-name)",
|
||||
`• [\`new-branch-name\`](${BRANCH_BASE_URL}/new-branch-name)`,
|
||||
);
|
||||
expect(result).not.toContain("View branch");
|
||||
});
|
||||
@@ -142,12 +157,12 @@ describe("updateCommentBody", () => {
|
||||
it("adds PR link to header when provided", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
||||
prLink: "\n[Create a PR](https://gitea.example.com/owner/repo/pr-url)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,12 +170,12 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody:
|
||||
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url)",
|
||||
"Some comment with [Create a PR](https://gitea.example.com/owner/repo/pr-url)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url)",
|
||||
);
|
||||
// Original Create a PR link is removed from body
|
||||
expect(result).not.toContain("[Create a PR]");
|
||||
@@ -170,21 +185,21 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody:
|
||||
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url-from-body)",
|
||||
"Some comment with [Create a PR](https://gitea.example.com/owner/repo/pr-url-from-body)",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/pr-url-provided)",
|
||||
"\n[Create a PR](https://gitea.example.com/owner/repo/pr-url-provided)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
// Prefers the link found in content over the provided one
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/pr-url-from-body)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url-from-body)",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles complex PR URLs with encoded characters", () => {
|
||||
const complexUrl =
|
||||
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20important%20bug%20fix&body=Fixes%20%23123%0A%0A%23%23%20Description%0AThis%20PR%20fixes%20an%20important%20bug%20that%20was%20causing%20issues%20with%20the%20application.%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
|
||||
"https://gitea.example.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20important%20bug%20fix&body=Fixes%20%23123%0A%0A%23%23%20Description%0AThis%20PR%20fixes%20an%20important%20bug%20that%20was%20causing%20issues%20with%20the%20application.%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: `Some comment with [Create a PR](${complexUrl})`,
|
||||
@@ -198,7 +213,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
it("handles PR links with encoded URLs containing parentheses", () => {
|
||||
const complexUrl =
|
||||
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
|
||||
"https://gitea.example.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: `This PR was created.\n\n[Create a PR](${complexUrl})`,
|
||||
@@ -217,9 +232,9 @@ describe("updateCommentBody", () => {
|
||||
|
||||
it("handles PR links with unencoded spaces and special characters", () => {
|
||||
const unEncodedUrl =
|
||||
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)";
|
||||
"https://gitea.example.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)";
|
||||
const expectedEncodedUrl =
|
||||
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A+update+welcome+message&body=Generated+with+%5BClaude+Code%5D%28https%3A%2F%2Fclaude.ai%2Fcode%29";
|
||||
"https://gitea.example.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A+update+welcome+message&body=Generated+with+%5BClaude+Code%5D%28https%3A%2F%2Fclaude.ai%2Fcode%29";
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: `This PR was created.\n\n[Create a PR](${unEncodedUrl})`,
|
||||
@@ -235,7 +250,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
it("falls back to prLink parameter when PR link in content cannot be encoded", () => {
|
||||
const invalidUrl = "not-a-valid-url-at-all";
|
||||
const fallbackPrUrl = "https://github.com/owner/repo/pull/123";
|
||||
const fallbackPrUrl = "https://gitea.example.com/owner/repo/pull/123";
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: `This PR was created.\n\n[Create a PR](${invalidUrl})`,
|
||||
@@ -317,7 +332,7 @@ describe("updateCommentBody", () => {
|
||||
"Claude Code is working…\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer",
|
||||
actionFailed: false,
|
||||
branchName: "claude-branch-123",
|
||||
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
||||
prLink: "\n[Create a PR](https://gitea.example.com/owner/repo/pr-url)",
|
||||
executionDetails: {
|
||||
cost_usd: 0.01,
|
||||
duration_ms: 65000, // 1 minute 5 seconds
|
||||
@@ -333,7 +348,7 @@ describe("updateCommentBody", () => {
|
||||
);
|
||||
expect(result).toContain("—— [View job]");
|
||||
expect(result).toContain(
|
||||
"• [`claude-branch-123`](https://github.com/owner/repo/src/branch/claude-branch-123)",
|
||||
`• [\`claude-branch-123\`](${BRANCH_BASE_URL}/claude-branch-123)`,
|
||||
);
|
||||
expect(result).toContain("• [Create PR ➔]");
|
||||
|
||||
@@ -358,7 +373,7 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody:
|
||||
"Claude Code is working…\n\nI've made changes.\n[Create a PR](https://github.com/owner/repo/pr-url-in-content)\n\n@john-doe",
|
||||
"Claude Code is working…\n\nI've made changes.\n[Create a PR](https://gitea.example.com/owner/repo/pr-url-in-content)\n\n@john-doe",
|
||||
branchName: "feature-branch",
|
||||
triggerUsername: "john-doe",
|
||||
};
|
||||
@@ -367,7 +382,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
// PR link should be moved to header
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/pr-url-in-content)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url-in-content)",
|
||||
);
|
||||
// Original link should be removed from body
|
||||
expect(result).not.toContain("[Create a PR]");
|
||||
@@ -383,7 +398,7 @@ describe("updateCommentBody", () => {
|
||||
currentBody: "Claude Code is working… <img src='spinner.gif' />",
|
||||
branchName: "claude/pr-456-20240101_120000",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
"\n[Create a PR](https://gitea.example.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
triggerUsername: "jane-doe",
|
||||
};
|
||||
|
||||
@@ -391,7 +406,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
// Should include the PR link in the formatted style
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
);
|
||||
expect(result).toContain("**Claude finished @jane-doe's task**");
|
||||
});
|
||||
@@ -401,20 +416,19 @@ describe("updateCommentBody", () => {
|
||||
...baseInput,
|
||||
currentBody: "Claude Code is working…",
|
||||
branchName: "claude/issue-123-20240101_120000",
|
||||
branchLink:
|
||||
"\n[View branch](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
branchLink: `\n[View branch](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
"\n[Create a PR](https://gitea.example.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
|
||||
// Should include both links in formatted style
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
`• [\`claude/issue-123-20240101_120000\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
"• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
buildDisallowedToolsString,
|
||||
} from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import type { EventData } from "../src/create-prompt/types";
|
||||
|
||||
describe("generatePrompt", () => {
|
||||
const mockGitHubData = {
|
||||
@@ -134,7 +133,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
||||
@@ -162,7 +161,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -188,16 +187,14 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>new issue with '@claude' in body</trigger_context>",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"[Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
expect(prompt).toContain("The target-branch should be 'main'");
|
||||
expect(prompt).toContain("mcp__gitea__update_issue_comment");
|
||||
expect(prompt).toContain("mcp__gitea__list_branches");
|
||||
});
|
||||
|
||||
test("should generate prompt for issue assigned event", () => {
|
||||
@@ -216,15 +213,14 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>issue assigned to 'claude-bot'</trigger_context>",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"[Create a PR](https://github.com/owner/repo/compare/develop",
|
||||
);
|
||||
expect(prompt).toContain("mcp__gitea__list_branches");
|
||||
expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
|
||||
});
|
||||
|
||||
test("should include direct prompt when provided", () => {
|
||||
@@ -243,7 +239,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<direct_prompt>");
|
||||
expect(prompt).toContain("Fix the bug in the login form");
|
||||
@@ -266,7 +262,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -291,7 +287,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
|
||||
});
|
||||
@@ -313,11 +309,11 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||
expect(prompt).toContain(
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.local>",
|
||||
"<trigger_display_name>johndoe</trigger_display_name>",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -334,7 +330,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain PR-specific instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -367,19 +363,12 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain Issue-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain(
|
||||
"If you created a branch and made changes, your comment must include the PR URL",
|
||||
);
|
||||
expect(prompt).toContain("mcp__gitea__update_issue_comment");
|
||||
expect(prompt).toContain("mcp__gitea__list_branches");
|
||||
expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
|
||||
|
||||
// Should NOT contain PR-specific instructions
|
||||
expect(prompt).not.toContain(
|
||||
@@ -406,54 +395,11 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain the actual branch name with timestamp
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/issue-123-20240101_120000",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle closed PR with new branch", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "@claude please fix this",
|
||||
claudeBranch: "claude/pr-456-20240101_120000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain branch-specific instructions like issues
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-456-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/pr-456-20240101_120000",
|
||||
);
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
|
||||
// Should NOT contain open PR instructions
|
||||
expect(prompt).not.toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
);
|
||||
// Should surface the issue number and comment metadata
|
||||
expect(prompt).toContain("<issue_number>123</issue_number>");
|
||||
expect(prompt).toContain("<claude_comment_id>12345</claude_comment_id>");
|
||||
});
|
||||
|
||||
test("should handle open PR without new branch", () => {
|
||||
@@ -471,7 +417,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain open PR instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -488,84 +434,6 @@ describe("generatePrompt", () => {
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle PR review on closed PR with new branch", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_review",
|
||||
isPR: true,
|
||||
prNumber: "789",
|
||||
commentBody: "@claude please update this",
|
||||
claudeBranch: "claude/pr-789-20240101_123000",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-789-20240101_123000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/develop",
|
||||
);
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
});
|
||||
|
||||
test("should handle PR review comment on closed PR with new branch", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_review_comment",
|
||||
isPR: true,
|
||||
prNumber: "999",
|
||||
commentId: "review-comment-123",
|
||||
commentBody: "@claude fix this issue",
|
||||
claudeBranch: "claude/pr-999-20240101_140000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-999-20240101_140000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
});
|
||||
|
||||
test("should handle pull_request event on closed PR with new branch", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "closed",
|
||||
isPR: true,
|
||||
prNumber: "555",
|
||||
claudeBranch: "claude/pr-555-20240101_150000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-555-20240101_150000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventTypeAndContext", () => {
|
||||
@@ -612,81 +480,36 @@ describe("getEventTypeAndContext", () => {
|
||||
});
|
||||
|
||||
describe("buildAllowedToolsString", () => {
|
||||
test("should return issue comment tool for regular events", () => {
|
||||
const mockEventData: EventData = {
|
||||
eventName: "issue_comment",
|
||||
commentId: "123",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test comment",
|
||||
};
|
||||
test("should include base tools", () => {
|
||||
const result = buildAllowedToolsString();
|
||||
|
||||
const result = buildAllowedToolsString(mockEventData);
|
||||
|
||||
// The base tools should be in the result
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
expect(result).toContain("mcp__github__update_issue_comment");
|
||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__local_git_ops__commit_files");
|
||||
expect(result).toContain("mcp__local_git_ops__delete_files");
|
||||
expect(result).toContain("mcp__gitea__update_issue_comment");
|
||||
expect(result).toContain("mcp__gitea__update_pull_request_comment");
|
||||
});
|
||||
|
||||
test("should return PR comment tool for inline review comments", () => {
|
||||
const mockEventData: EventData = {
|
||||
eventName: "pull_request_review_comment",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test review comment",
|
||||
commentId: "789",
|
||||
};
|
||||
test("should include commit signing tools when enabled", () => {
|
||||
const result = buildAllowedToolsString(undefined, false, true);
|
||||
|
||||
const result = buildAllowedToolsString(mockEventData);
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
// The base tools should be in the result
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
||||
expect(result).toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__local_git_ops__commit_files");
|
||||
expect(result).toContain("mcp__local_git_ops__delete_files");
|
||||
test("should include actions tools when actions read permission granted", () => {
|
||||
const result = buildAllowedToolsString([], true, false);
|
||||
|
||||
expect(result).toContain("mcp__github_actions__get_ci_status");
|
||||
expect(result).toContain("mcp__github_actions__download_job_log");
|
||||
});
|
||||
|
||||
test("should append custom tools when provided", () => {
|
||||
const mockEventData: EventData = {
|
||||
eventName: "issue_comment",
|
||||
commentId: "123",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test comment",
|
||||
};
|
||||
|
||||
const customTools = "Tool1,Tool2,Tool3";
|
||||
const result = buildAllowedToolsString(mockEventData, customTools);
|
||||
const result = buildAllowedToolsString(customTools);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
|
||||
// Custom tools should be appended
|
||||
expect(result).toContain("Tool1");
|
||||
expect(result).toContain("Tool2");
|
||||
expect(result).toContain("Tool3");
|
||||
|
||||
// Verify format with comma separation
|
||||
const basePlusCustom = result.split(",");
|
||||
expect(basePlusCustom.length).toBeGreaterThan(10); // At least the base tools plus custom
|
||||
expect(basePlusCustom).toContain("Tool1");
|
||||
expect(basePlusCustom).toContain("Tool2");
|
||||
expect(basePlusCustom).toContain("Tool3");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,665 +1,48 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
spyOn,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
jest,
|
||||
setSystemTime,
|
||||
} from "bun:test";
|
||||
import fs from "fs/promises";
|
||||
import { downloadCommentImages } from "../src/github/utils/image-downloader";
|
||||
import type { CommentWithImages } from "../src/github/utils/image-downloader";
|
||||
import type { Octokits } from "../src/github/api/client";
|
||||
downloadCommentImages,
|
||||
type CommentWithImages,
|
||||
} from "../src/github/utils/image-downloader";
|
||||
|
||||
const noopClient = { api: {} } as any;
|
||||
|
||||
describe("downloadCommentImages", () => {
|
||||
let consoleLogSpy: any;
|
||||
let consoleWarnSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
let fsMkdirSpy: any;
|
||||
let fsWriteFileSpy: any;
|
||||
let fetchSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on console methods
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Spy on fs methods
|
||||
fsMkdirSpy = spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
fsWriteFileSpy = spyOn(fs, "writeFile").mockResolvedValue(undefined);
|
||||
|
||||
// Set fake system time for consistent filenames
|
||||
setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
fsMkdirSpy.mockRestore();
|
||||
fsWriteFileSpy.mockRestore();
|
||||
if (fetchSpy) fetchSpy.mockRestore();
|
||||
setSystemTime(); // Reset to real time
|
||||
});
|
||||
|
||||
const createMockOctokit = (): Octokits => {
|
||||
return {
|
||||
rest: {
|
||||
issues: {
|
||||
getComment: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
pulls: {
|
||||
getReviewComment: jest.fn(),
|
||||
getReview: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
} as any as Octokits;
|
||||
};
|
||||
test("returns empty map and logs disabled message", async () => {
|
||||
const result = await downloadCommentImages(
|
||||
noopClient,
|
||||
"owner",
|
||||
"repo",
|
||||
[] as CommentWithImages[],
|
||||
);
|
||||
|
||||
test("should create download directory", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const comments: CommentWithImages[] = [];
|
||||
|
||||
await downloadCommentImages(mockOctokit, "owner", "repo", comments);
|
||||
|
||||
expect(fsMkdirSpy).toHaveBeenCalledWith("/tmp/github-images", {
|
||||
recursive: true,
|
||||
});
|
||||
expect(result.size).toBe(0);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Image downloading temporarily disabled during Octokit migration",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle comments without images", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
test("ignores provided comments while feature disabled", async () => {
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "123",
|
||||
body: "This is a comment without images",
|
||||
body: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
const result = await downloadCommentImages(noopClient, "owner", "repo", comments);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found"),
|
||||
);
|
||||
});
|
||||
|
||||
test("should detect and download images from issue comments", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/test-image.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/test.png?jwt=token";
|
||||
|
||||
// Mock octokit response
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock fetch for image download
|
||||
const mockArrayBuffer = new ArrayBuffer(8);
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => mockArrayBuffer,
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "123",
|
||||
body: `Here's an image: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
comment_id: 123,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
Buffer.from(mockArrayBuffer),
|
||||
);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 123",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle review comments", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/review-image.jpg";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/review.jpg?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "review_comment",
|
||||
id: "456",
|
||||
body: `Review comment with image: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
comment_id: 456,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle review bodies", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/review-body.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/body.png?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "review_body",
|
||||
id: "789",
|
||||
pullNumber: "100",
|
||||
body: `Review body: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
pull_number: 100,
|
||||
review_id: 789,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle issue bodies", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/issue-body.gif";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/issue.gif?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_body",
|
||||
issueNumber: "200",
|
||||
body: `Issue description: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
issue_number: 200,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.gif",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_body 200",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle PR bodies", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/pr-body.webp";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/pr.webp?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "pr_body",
|
||||
pullNumber: "300",
|
||||
body: `PR description: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
pull_number: 300,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.webp",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in pr_body 300",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle multiple images in a single comment", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl1 = "https://github.com/user-attachments/assets/image1.png";
|
||||
const imageUrl2 = "https://github.com/user-attachments/assets/image2.jpg";
|
||||
const signedUrl1 =
|
||||
"https://private-user-images.githubusercontent.com/1.png?jwt=token1";
|
||||
const signedUrl2 =
|
||||
"https://private-user-images.githubusercontent.com/2.jpg?jwt=token2";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "999",
|
||||
body: `Two images:  and `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(imageUrl1)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-1.jpg",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 2 image(s) in issue_comment 999",
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip already downloaded images", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "111",
|
||||
body: `First: `,
|
||||
},
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "222",
|
||||
body: `Second: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle missing HTML body", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/missing.png";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: null,
|
||||
},
|
||||
});
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "333",
|
||||
body: `Missing HTML: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"No HTML body found for issue_comment 333",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle fetch errors", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/error.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/error.png?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "444",
|
||||
body: `Error image: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`✗ Failed to download ${imageUrl}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle API errors gracefully", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/api-error.png";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("API rate limit exceeded"));
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "555",
|
||||
body: `API error: `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to process images for issue_comment 555:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test("should extract correct file extensions", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const extensions = [
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.png",
|
||||
ext: ".png",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.jpg",
|
||||
ext: ".jpg",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.jpeg",
|
||||
ext: ".jpeg",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.gif",
|
||||
ext: ".gif",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.webp",
|
||||
ext: ".webp",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/user-attachments/assets/test.svg",
|
||||
ext: ".svg",
|
||||
},
|
||||
{
|
||||
// default
|
||||
url: "https://github.com/user-attachments/assets/no-extension",
|
||||
ext: ".png",
|
||||
},
|
||||
];
|
||||
|
||||
let callIndex = 0;
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="https://private-user-images.githubusercontent.com/test?jwt=token">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
for (const { url, ext } of extensions) {
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: `${1000 + callIndex}`,
|
||||
body: `Test: `,
|
||||
},
|
||||
];
|
||||
|
||||
setSystemTime(new Date(1704067200000 + callIndex));
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
expect(result.get(url)).toBe(
|
||||
`/tmp/github-images/image-${1704067200000 + callIndex}-0${ext}`,
|
||||
);
|
||||
|
||||
// Reset for next iteration
|
||||
fsWriteFileSpy.mockClear();
|
||||
callIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle mismatched signed URL count", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl1 = "https://github.com/user-attachments/assets/img1.png";
|
||||
const imageUrl2 = "https://github.com/user-attachments/assets/img2.png";
|
||||
const signedUrl1 =
|
||||
"https://private-user-images.githubusercontent.com/1.png?jwt=token";
|
||||
|
||||
// Only one signed URL for two images
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "666",
|
||||
body: `Two images:  `,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl1)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBeUndefined();
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,682 +1,59 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("prepareMcpConfig", () => {
|
||||
let consoleInfoSpy: any;
|
||||
let consoleWarningSpy: any;
|
||||
let setFailedSpy: any;
|
||||
let processExitSpy: any;
|
||||
|
||||
// Create a mock context for tests
|
||||
const mockContext: ParsedGitHubContext = {
|
||||
runId: "test-run-id",
|
||||
eventName: "issue_comment",
|
||||
eventAction: "created",
|
||||
repository: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
},
|
||||
actor: "test-actor",
|
||||
payload: {} as any,
|
||||
entityNumber: 123,
|
||||
isPR: false,
|
||||
inputs: {
|
||||
mode: "tag",
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
branchPrefix: "",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockPRContext: ParsedGitHubContext = {
|
||||
...mockContext,
|
||||
eventName: "pull_request",
|
||||
isPR: true,
|
||||
entityNumber: 456,
|
||||
};
|
||||
|
||||
const mockContextWithSigning: ParsedGitHubContext = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockPRContextWithSigning: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
// Set up required environment variables
|
||||
if (!process.env.GITHUB_ACTION_PATH) {
|
||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||
}
|
||||
process.env.GITHUB_ACTION_PATH = "/action/path";
|
||||
process.env.GITHUB_WORKSPACE = "/workspace";
|
||||
process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleWarningSpy.mockRestore();
|
||||
setFailedSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test("should return comment server when commit signing is disabled", async () => {
|
||||
test("returns base gitea and local git MCP servers", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
githubToken: "token",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
|
||||
});
|
||||
expect(Object.keys(parsed.mcpServers)).toEqual(["gitea", "local_git_ops"]);
|
||||
|
||||
test("should return file ops server when commit signing is enabled", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
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 include github MCP server when mcp__github__ tools are allowed", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not include github MCP server when only file_ops tools are allowed", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
],
|
||||
context: contextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["Edit", "Read", "Write"],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
});
|
||||
|
||||
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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: "",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: " \n\t ",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).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",
|
||||
},
|
||||
},
|
||||
expect(parsed.mcpServers.gitea).toEqual({
|
||||
command: "bun",
|
||||
args: ["run", "/action/path/src/mcp/gitea-mcp-server.ts"],
|
||||
env: {
|
||||
GITHUB_TOKEN: "token",
|
||||
REPO_OWNER: "owner",
|
||||
REPO_NAME: "repo",
|
||||
BRANCH_NAME: "branch",
|
||||
REPO_DIR: "/workspace",
|
||||
GITEA_API_URL: "https://gitea.example.com/api/v1",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
expect(parsed.mcpServers.local_git_ops.args[1]).toBe(
|
||||
"/action/path/src/mcp/local-git-ops-server.ts",
|
||||
);
|
||||
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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.customProperty).toBe("custom-value");
|
||||
expect(parsed.anotherProperty).toEqual({ nested: "value" });
|
||||
expect(parsed.mcpServers.github).not.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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: invalidJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
||||
);
|
||||
expect(parsed.mcpServers.github).not.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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nonObjectJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
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).not.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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nullJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
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).not.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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: arrayJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
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).not.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",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.server1).toBeDefined();
|
||||
expect(parsed.mcpServers.server2).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.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",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
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;
|
||||
test("falls back to process.cwd when workspace not provided", async () => {
|
||||
delete process.env.GITHUB_WORKSPACE;
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
githubToken: "token",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
|
||||
|
||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
||||
});
|
||||
|
||||
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
|
||||
const oldEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([["actions", "read"]]),
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldEnv;
|
||||
});
|
||||
|
||||
test("should not include github_ci server when context.isPR is false", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should not include github_ci server when actions:read permission is not granted", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockPRContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should parse additional_permissions with multiple lines correctly", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([
|
||||
["actions", "read"],
|
||||
["future", "permission"],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should warn when actions:read is requested but token lacks permission", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "invalid-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([["actions", "read"]]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"The github_ci MCP server requires 'actions: read' permission",
|
||||
),
|
||||
);
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
expect(parsed.mcpServers.gitea.env.REPO_DIR).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,169 +1,63 @@
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test";
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import * as core from "@actions/core";
|
||||
import { checkWritePermissions } from "../src/github/validation/permissions";
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
|
||||
const baseContext: ParsedGitHubContext = {
|
||||
runId: "123",
|
||||
eventName: "issue_comment",
|
||||
eventAction: "created",
|
||||
repository: {
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
full_name: "owner/repo",
|
||||
},
|
||||
actor: "tester",
|
||||
payload: {
|
||||
action: "created",
|
||||
issue: { number: 1, body: "", title: "", user: { login: "owner" } },
|
||||
comment: { id: 1, body: "@claude ping", user: { login: "tester" } },
|
||||
} as any,
|
||||
entityNumber: 1,
|
||||
isPR: false,
|
||||
inputs: {
|
||||
mode: "tag",
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe("checkWritePermissions", () => {
|
||||
let coreInfoSpy: any;
|
||||
let coreWarningSpy: any;
|
||||
let coreErrorSpy: any;
|
||||
let infoSpy: any;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on core methods
|
||||
coreInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
coreWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
coreErrorSpy = spyOn(core, "error").mockImplementation(() => {});
|
||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coreInfoSpy.mockRestore();
|
||||
coreWarningSpy.mockRestore();
|
||||
coreErrorSpy.mockRestore();
|
||||
infoSpy.mockRestore();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
const createMockOctokit = (permission: string) => {
|
||||
return {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => ({
|
||||
data: { permission },
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createContext = (): ParsedGitHubContext => ({
|
||||
runId: "1234567890",
|
||||
eventName: "issue_comment",
|
||||
eventAction: "created",
|
||||
repository: {
|
||||
full_name: "test-owner/test-repo",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
},
|
||||
actor: "test-user",
|
||||
payload: {
|
||||
action: "created",
|
||||
issue: {
|
||||
number: 1,
|
||||
title: "Test Issue",
|
||||
body: "Test body",
|
||||
user: { login: "test-user" },
|
||||
},
|
||||
comment: {
|
||||
id: 123,
|
||||
body: "@claude test",
|
||||
user: { login: "test-user" },
|
||||
html_url:
|
||||
"https://github.com/test-owner/test-repo/issues/1#issuecomment-123",
|
||||
},
|
||||
} as any,
|
||||
entityNumber: 1,
|
||||
isPR: false,
|
||||
inputs: {
|
||||
mode: "tag",
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
|
||||
test("should return true for admin permissions", async () => {
|
||||
const mockOctokit = createMockOctokit("admin");
|
||||
const context = createContext();
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
test("returns true immediately in Gitea environments", async () => {
|
||||
const client = { api: { getBaseUrl: () => "https://gitea.example.com/api/v1" } } as any;
|
||||
const result = await checkWritePermissions(client, baseContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith(
|
||||
"Checking permissions for actor: test-user",
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Detected Gitea environment (https://gitea.example.com/api/v1), assuming actor has permissions",
|
||||
);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith(
|
||||
"Permission level retrieved: admin",
|
||||
);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: admin");
|
||||
});
|
||||
|
||||
test("should return true for write permissions", async () => {
|
||||
const mockOctokit = createMockOctokit("write");
|
||||
const context = createContext();
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: write");
|
||||
});
|
||||
|
||||
test("should return false for read permissions", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createContext();
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"Actor has insufficient permissions: read",
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false for none permissions", async () => {
|
||||
const mockOctokit = createMockOctokit("none");
|
||||
const context = createContext();
|
||||
|
||||
const result = await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(coreWarningSpy).toHaveBeenCalledWith(
|
||||
"Actor has insufficient permissions: none",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when permission check fails", async () => {
|
||||
const error = new Error("API error");
|
||||
const mockOctokit = {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const context = createContext();
|
||||
|
||||
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
|
||||
"Failed to check permissions for test-user: Error: API error",
|
||||
);
|
||||
|
||||
expect(coreErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to check permissions: Error: API error",
|
||||
);
|
||||
});
|
||||
|
||||
test("should call API with correct parameters", async () => {
|
||||
let capturedParams: any;
|
||||
const mockOctokit = {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async (params: any) => {
|
||||
capturedParams = params;
|
||||
return { data: { permission: "write" } };
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const context = createContext();
|
||||
|
||||
await checkWritePermissions(mockOctokit, context);
|
||||
|
||||
expect(capturedParams).toEqual({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
username: "test-user",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,10 +69,19 @@ describe("parseEnvVarsWithContext", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when CLAUDE_BRANCH is missing", () => {
|
||||
expect(() =>
|
||||
prepareContext(mockIssueCommentContext, "12345", "main"),
|
||||
).toThrow("CLAUDE_BRANCH is required for issue_comment event");
|
||||
test("should allow missing CLAUDE_BRANCH and omit it from event data", () => {
|
||||
const result = prepareContext(
|
||||
mockIssueCommentContext,
|
||||
"12345",
|
||||
"main",
|
||||
);
|
||||
|
||||
if (
|
||||
result.eventData.eventName === "issue_comment" &&
|
||||
!result.eventData.isPR
|
||||
) {
|
||||
expect(result.eventData.claudeBranch).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when BASE_BRANCH is missing", () => {
|
||||
@@ -203,10 +212,12 @@ describe("parseEnvVarsWithContext", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when CLAUDE_BRANCH is missing for issues", () => {
|
||||
expect(() =>
|
||||
prepareContext(mockIssueOpenedContext, "12345", "main"),
|
||||
).toThrow("CLAUDE_BRANCH is required for issues event");
|
||||
test("should allow issues event without CLAUDE_BRANCH", () => {
|
||||
const result = prepareContext(mockIssueOpenedContext, "12345", "main");
|
||||
|
||||
if (result.eventData.eventName === "issues") {
|
||||
expect(result.eventData.claudeBranch).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when BASE_BRANCH is missing for issues", () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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;
|
||||
let mockOctokit: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOctokit = {
|
||||
@@ -18,7 +17,7 @@ describe("updateClaudeComment", () => {
|
||||
updateReviewComment: jest.fn(),
|
||||
},
|
||||
},
|
||||
} as any as Octokit;
|
||||
};
|
||||
});
|
||||
|
||||
test("should update issue comment successfully", async () => {
|
||||
@@ -31,7 +30,6 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.updateComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -70,7 +68,6 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.updateComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -109,7 +106,6 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -151,11 +147,9 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @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);
|
||||
@@ -195,7 +189,6 @@ describe("updateClaudeComment", () => {
|
||||
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);
|
||||
@@ -226,7 +219,6 @@ describe("updateClaudeComment", () => {
|
||||
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);
|
||||
@@ -261,7 +253,6 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.updateComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -294,7 +285,6 @@ describe("updateClaudeComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.updateComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -345,7 +335,6 @@ const code = "example";
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.updateComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
@@ -388,7 +377,6 @@ const code = "example";
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.pulls.updateReviewComment = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
Reference in New Issue
Block a user