mirror of
https://github.com/markwylde/claude-code-gitea-action.git
synced 2026-02-20 02:22:49 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c923b4bd | ||
|
|
dea3e23b34 | ||
|
|
45ee2dca55 | ||
|
|
e2f737a753 | ||
|
|
1c309c8d10 | ||
|
|
5171232878 | ||
|
|
ea134ca929 | ||
|
|
72aa15ac4f | ||
|
|
87a39e8fbc | ||
|
|
44d513b712 | ||
|
|
3afac506b2 | ||
|
|
436046a0ff | ||
|
|
12940797c7 | ||
|
|
59a6b568a6 | ||
|
|
ed04634119 | ||
|
|
8de76049e1 | ||
|
|
bbf8371776 | ||
|
|
e1be245c51 | ||
|
|
0bb118b1a2 | ||
|
|
4b69e8485a | ||
|
|
4b1c3d000d | ||
|
|
b41b7ecd9f | ||
|
|
11685fc8c1 | ||
|
|
87c1a97c6e | ||
|
|
7018095f9a | ||
|
|
e079f18247 | ||
|
|
c0d1a3fc4c | ||
|
|
c77bb0e4b3 | ||
|
|
01602be052 | ||
|
|
f2f966c77e | ||
|
|
80886e1c8e | ||
|
|
e2d102aadd | ||
|
|
c004bcdb83 | ||
|
|
2f36d061b3 | ||
|
|
9d64c62a2e | ||
|
|
828076e411 | ||
|
|
9986f4d1a3 | ||
|
|
406208cf7a | ||
|
|
6410e33591 | ||
|
|
f598608bb4 | ||
|
|
e474962b0d |
@@ -56,12 +56,3 @@ src/
|
||||
- The action creates branches for issues and pushes to PR branches directly
|
||||
- All actions create OIDC tokens for secure authentication
|
||||
- Progress is tracked through dynamic comment updates with checkboxes
|
||||
|
||||
## MCP Tool Development
|
||||
|
||||
When adding new MCP tools:
|
||||
|
||||
1. **Add to MCP Server**: Implement the tool in the appropriate MCP server file (e.g., `src/mcp/local-git-ops-server.ts`)
|
||||
2. **Expose to Claude**: Add the tool name to `BASE_ALLOWED_TOOLS` array in `src/create-prompt/index.ts`
|
||||
3. **Tool Naming**: Follow the pattern `mcp__server_name__tool_name` (e.g., `mcp__local_git_ops__checkout_branch`)
|
||||
4. **Documentation**: Update the prompt's "What You CAN Do" section if the tool adds new capabilities
|
||||
|
||||
4
FAQ.md
4
FAQ.md
@@ -6,7 +6,7 @@ This FAQ addresses common questions and gotchas when using the Claude Code GitHu
|
||||
|
||||
### Why doesn't tagging @claude from my automated workflow work?
|
||||
|
||||
The `github-actions` user cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user, or use a separate app token of your own. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
|
||||
The `github-actions` user (and other GitHub Apps/bots) cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
|
||||
|
||||
### Why does Claude say I don't have permission to trigger it?
|
||||
|
||||
@@ -22,7 +22,7 @@ permissions:
|
||||
id-token: write # Required for OIDC authentication
|
||||
```
|
||||
|
||||
The OIDC token is required in order for the Claude GitHub app to function. If you wish to not use the GitHub app, you can instead provide a `gitea_token` input to the action for Claude to operate with. See the [Claude Code permissions documentation][perms] for more.
|
||||
The OIDC token is required in order for the Claude GitHub app to function. If you wish to not use the GitHub app, you can instead provide a `github_token` input to the action for Claude to operate with. See the [Claude Code permissions documentation][perms] for more.
|
||||
|
||||
## Claude's Capabilities and Limitations
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Now required to explicitly provide a GitHub token:
|
||||
# After (required)
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- name: Run Claude Assistant
|
||||
uses: ./ # Adjust path as needed for your Gitea setup
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
|
||||
41
README.md
41
README.md
@@ -1,7 +1,5 @@
|
||||
# Claude Code Action (Gitea Fork)
|
||||
|
||||

|
||||
|
||||
A fork of the [Claude Code Action](https://github.com/anthropics/claude-code-action) that adds support for Gitea alongside GitHub. This action provides a general-purpose [Claude Code](https://claude.ai/code) assistant for PRs and issues that can answer questions and implement code changes. It listens for a trigger phrase in comments and activates Claude to act on the request. Supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||
|
||||
> **Note**: This is an unofficial fork that extends the original action to work with Gitea installations. The core functionality remains the same, with additional support for Gitea APIs and local git operations.
|
||||
@@ -60,29 +58,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: markwylde/claude-code-gitea-action@v1.0.3
|
||||
- uses: markwylde/claude-code-gitea-action@v1.0.0
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }}
|
||||
github_token: ${{ secrets.GITEA_TOKEN }}
|
||||
gitea_api_url: https://gitea.example.com
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `gitea_token` | Gitea token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| Input | Description | Required | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | ---------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `gitea_api_url` | Gitea server URL (e.g., `https://gitea.example.com`) for Gitea installations. Leave empty for GitHub. | No | GitHub API |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
@@ -112,7 +112,8 @@ jobs:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }}
|
||||
gitea_api_url: "https://gitea.example.com"
|
||||
github_token: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
### Gitea Setup Notes
|
||||
|
||||
33
action.yml
33
action.yml
@@ -42,13 +42,13 @@ inputs:
|
||||
|
||||
# Auth configuration
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex). Set to 'use-oauth' when using claude_credentials"
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||
required: false
|
||||
claude_credentials:
|
||||
description: "Claude OAuth credentials JSON for Claude AI Max subscription authentication"
|
||||
github_token:
|
||||
description: "GitHub token with repo and pull request permissions (defaults to GITHUB_TOKEN)"
|
||||
required: false
|
||||
gitea_token:
|
||||
description: "Gitea token with repo and pull request permissions (defaults to GITHUB_TOKEN)"
|
||||
gitea_api_url:
|
||||
description: "Gitea server URL (e.g., https://gitea.example.com, defaults to GitHub API)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
@@ -95,12 +95,10 @@ runs:
|
||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.gitea_token }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CREDENTIALS: ${{ inputs.claude_credentials }}
|
||||
GITEA_API_URL: ${{ inputs.gitea_api_url }}
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
@@ -128,11 +126,10 @@ runs:
|
||||
USE_BEDROCK: ${{ inputs.use_bedrock }}
|
||||
USE_VERTEX: ${{ inputs.use_vertex }}
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CREDENTIALS: ${{ inputs.claude_credentials }}
|
||||
|
||||
# GitHub token for repository access
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
GITEA_API_URL: ${{ inputs.gitea_api_url }}
|
||||
|
||||
# Provider configuration (for future cloud provider support)
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
@@ -170,17 +167,13 @@ runs:
|
||||
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
||||
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
GITEA_API_URL: ${{ inputs.gitea_api_url }}
|
||||
|
||||
- name: Display Claude Code Report
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f "${{ steps.claude-code.outputs.execution_file }}" ]; then
|
||||
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "⚠️ Claude Code execution completed but no report file was generated" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
@@ -31,6 +31,6 @@ jobs:
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Run Claude Assistant
|
||||
uses: ./ # Use local action (adjust path as needed)
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }} # Use standard workflow token
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }} # Use standard workflow token
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
trigger_phrase: "@claude"
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
interface OAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ClaudeCredentialsInput {
|
||||
claudeAiOauth: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scopes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupOAuthCredentials(credentialsJson: string) {
|
||||
try {
|
||||
// Parse the credentials JSON
|
||||
const parsedCredentials: ClaudeCredentialsInput = JSON.parse(credentialsJson);
|
||||
|
||||
if (!parsedCredentials.claudeAiOauth) {
|
||||
throw new Error("Invalid credentials format: missing claudeAiOauth");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, expiresAt } = parsedCredentials.claudeAiOauth;
|
||||
|
||||
if (!accessToken || !refreshToken || !expiresAt) {
|
||||
throw new Error("Invalid credentials format: missing required OAuth fields");
|
||||
}
|
||||
|
||||
const claudeDir = join(homedir(), ".claude");
|
||||
const credentialsPath = join(claudeDir, ".credentials.json");
|
||||
|
||||
// Create the .claude directory if it doesn't exist
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Create the credentials JSON structure
|
||||
const credentialsData = {
|
||||
claudeAiOauth: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
scopes: ["user:inference", "user:profile"],
|
||||
},
|
||||
};
|
||||
|
||||
// Write the credentials file
|
||||
await writeFile(credentialsPath, JSON.stringify(credentialsData, null, 2));
|
||||
|
||||
console.log(`OAuth credentials written to ${credentialsPath}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to setup OAuth credentials: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,6 @@ const BASE_ALLOWED_TOOLS = [
|
||||
"mcp__local_git_ops__delete_files",
|
||||
"mcp__local_git_ops__push_branch",
|
||||
"mcp__local_git_ops__create_pull_request",
|
||||
"mcp__local_git_ops__checkout_branch",
|
||||
"mcp__local_git_ops__create_branch",
|
||||
"mcp__local_git_ops__git_status",
|
||||
];
|
||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||
@@ -219,6 +217,8 @@ export function prepareContext(
|
||||
...(baseBranch && { baseBranch }),
|
||||
};
|
||||
break;
|
||||
} else if (!claudeBranch) {
|
||||
throw new Error("CLAUDE_BRANCH is required for issue_comment event");
|
||||
} else if (!baseBranch) {
|
||||
throw new Error("BASE_BRANCH is required for issue_comment event");
|
||||
} else if (!issueNumber) {
|
||||
@@ -231,10 +231,10 @@ export function prepareContext(
|
||||
eventName: "issue_comment",
|
||||
commentId,
|
||||
isPR: false,
|
||||
claudeBranch: claudeBranch,
|
||||
baseBranch,
|
||||
issueNumber,
|
||||
commentBody,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -251,6 +251,9 @@ export function prepareContext(
|
||||
if (!baseBranch) {
|
||||
throw new Error("BASE_BRANCH is required for issues event");
|
||||
}
|
||||
if (!claudeBranch) {
|
||||
throw new Error("CLAUDE_BRANCH is required for issues event");
|
||||
}
|
||||
|
||||
if (eventAction === "assigned") {
|
||||
if (!assigneeTrigger) {
|
||||
@@ -264,8 +267,8 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
assigneeTrigger,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
} else if (eventAction === "opened") {
|
||||
eventData = {
|
||||
@@ -274,7 +277,7 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
claudeBranch,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported issue action: ${eventAction}`);
|
||||
@@ -509,20 +512,7 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
||||
- For implementation requests, assess if they are straightforward or complex.
|
||||
- Mark this todo as complete by checking the box.
|
||||
|
||||
${
|
||||
!eventData.isPR || !eventData.claudeBranch
|
||||
? `
|
||||
4. Check for Existing Branch (for issues and closed PRs):
|
||||
- Before implementing changes, check if there's already a claude branch for this ${eventData.isPR ? "PR" : "issue"}.
|
||||
- Use Bash to run \`git branch -r | grep "claude/${eventData.isPR ? "pr" : "issue"}-${eventData.isPR ? eventData.prNumber : eventData.issueNumber}"\` to search for existing branches.
|
||||
- If found, use mcp__local_git_ops__checkout_branch to switch to the existing branch (set fetch_remote=true).
|
||||
- If not found, you'll create a new branch when making changes (see Execute Actions section).
|
||||
- Mark this todo as complete by checking the box.
|
||||
|
||||
5. Execute Actions:`
|
||||
: `
|
||||
4. Execute Actions:`
|
||||
}
|
||||
4. Execute Actions:
|
||||
- Continually update your todo list as you discover new requirements or realize tasks can be broken down.
|
||||
|
||||
A. For Answering Questions and Code Reviews:
|
||||
@@ -547,14 +537,15 @@ ${
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
- When pushing changes with this tool and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.`
|
||||
: eventData.claudeBranch
|
||||
? `
|
||||
- You are already on the correct branch (${eventData.claudeBranch}). Do not create a new branch.
|
||||
: `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Commit changes using mcp__local_git_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
- When pushing changes and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.
|
||||
- Provide a URL to create a PR manually in this format:
|
||||
${
|
||||
eventData.claudeBranch
|
||||
? `- Provide a URL to create a PR manually in this format:
|
||||
[Create a PR](${GITEA_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
- IMPORTANT: Use THREE dots (...) between branch names, not two (..)
|
||||
Example: ${GITEA_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
|
||||
@@ -568,34 +559,8 @@ ${
|
||||
- Reference to the original ${eventData.isPR ? "PR" : "issue"}
|
||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||
: `
|
||||
- IMPORTANT: You are currently on the base branch (${eventData.baseBranch}). Before making changes, you should first check if there's already an existing claude branch for this ${eventData.isPR ? "PR" : "issue"}.
|
||||
- FIRST: Use Bash to run \`git branch -r | grep "claude/${eventData.isPR ? "pr" : "issue"}-${eventData.isPR ? eventData.prNumber : eventData.issueNumber}"\` to check for existing branches.
|
||||
- If an existing claude branch is found:
|
||||
- Use mcp__local_git_ops__checkout_branch to switch to the existing branch (set fetch_remote=true)
|
||||
- Continue working on that branch rather than creating a new one
|
||||
- If NO existing claude branch is found:
|
||||
- Create a new branch using mcp__local_git_ops__create_branch
|
||||
- Use a descriptive branch name following the pattern: claude/${eventData.isPR ? "pr" : "issue"}-${eventData.isPR ? eventData.prNumber : eventData.issueNumber}-<short-description>
|
||||
- Example: claude/issue-123-fix-login-bug or claude/issue-456-add-user-profile
|
||||
- After being on the correct branch (existing or new), commit changes using mcp__local_git_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
- When pushing changes and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.
|
||||
- Provide a URL to create a PR manually in this format:
|
||||
[Create a PR](${GITEA_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
- IMPORTANT: Use THREE dots (...) between branch names, not two (..)
|
||||
Example: ${GITEA_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
|
||||
NOT: ${GITEA_SERVER_URL}/${context.repository}/compare/main..feature-branch (incorrect)
|
||||
- IMPORTANT: Ensure all URL parameters are properly encoded - spaces should be encoded as %20, not left as spaces
|
||||
Example: Instead of "fix: update welcome message", use "fix%3A%20update%20welcome%20message"
|
||||
- The target-branch should be '${eventData.baseBranch}'.
|
||||
- The branch-name is your created branch name
|
||||
- The body should include:
|
||||
- A clear description of the changes
|
||||
- Reference to the original ${eventData.isPR ? "PR" : "issue"}
|
||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
|
||||
C. For Complex Changes:
|
||||
@@ -607,12 +572,12 @@ ${
|
||||
- Follow the same pushing strategy as for straightforward changes (see section B above).
|
||||
- Or explain why it's too complex: mark todo as completed in checklist with explanation.
|
||||
|
||||
${!eventData.isPR || !eventData.claudeBranch ? `6. Final Update:` : `5. Final Update:`}
|
||||
5. Final Update:
|
||||
- Always update the GitHub comment to reflect the current todo state.
|
||||
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
|
||||
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
|
||||
- If you changed any files locally, you must commit them using mcp__local_git_ops__commit_files AND push the branch using mcp__local_git_ops__push_branch before saying that you're done.
|
||||
${!eventData.isPR || !eventData.claudeBranch ? `- If you created a branch and made changes, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
||||
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
||||
|
||||
Important Notes:
|
||||
- All communication must happen through GitHub PR comments.
|
||||
@@ -620,7 +585,7 @@ Important Notes:
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github__update_issue_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
|
||||
- You communicate exclusively by editing your single comment - not through any other means.
|
||||
- Use this spinner HTML when work is in progress: <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;" />
|
||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : eventData.claudeBranch ? `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch}). Do not create additional branches.` : `- IMPORTANT: You are currently on the base branch (${eventData.baseBranch}). First check for existing claude branches for this ${eventData.isPR ? "PR" : "issue"} and use them if found, otherwise create a new branch using mcp__local_git_ops__create_branch.`}
|
||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
|
||||
- Use mcp__local_git_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__local_git_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||
Tool usage examples:
|
||||
- mcp__local_git_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
||||
@@ -641,10 +606,9 @@ What You CAN Do:
|
||||
- Implement code changes (simple to moderate complexity) when explicitly requested
|
||||
- Create pull requests for changes to human-authored code
|
||||
- Smart branch handling:
|
||||
- When triggered on an issue: Create a new branch using mcp__local_git_ops__create_branch
|
||||
- When triggered on an open PR: Push directly to the existing PR branch
|
||||
- When triggered on a closed PR: Create a new branch using mcp__local_git_ops__create_branch
|
||||
- Create new branches when needed using the create_branch tool
|
||||
- When triggered on an issue: Always create a new branch
|
||||
- When triggered on an open PR: Always push directly to the existing PR branch
|
||||
- When triggered on a closed PR: Create a new branch
|
||||
|
||||
What You CANNOT Do:
|
||||
- Submit formal GitHub PR reviews
|
||||
@@ -652,7 +616,7 @@ What You CANNOT Do:
|
||||
- Post multiple comments (you only update your initial comment)
|
||||
- Execute commands outside the repository context
|
||||
- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)
|
||||
- Perform advanced branch operations (cannot merge branches, rebase, or perform other complex git operations beyond creating, checking out, and pushing branches)
|
||||
- Perform branch operations (cannot merge branches, rebase, or perform other git operations beyond pushing commits)
|
||||
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
|
||||
- View CI/CD results or workflow run outputs (cannot access GitHub Actions logs or test results)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ type IssueCommentEvent = {
|
||||
issueNumber: string;
|
||||
isPR: false;
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
claudeBranch: string;
|
||||
commentBody: string;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ type IssueOpenedEvent = {
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
claudeBranch: string;
|
||||
};
|
||||
|
||||
type IssueAssignedEvent = {
|
||||
@@ -64,7 +64,7 @@ type IssueAssignedEvent = {
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
claudeBranch: string;
|
||||
assigneeTrigger: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,27 +18,17 @@ import { createPrompt } from "../create-prompt";
|
||||
import { createClient } from "../github/api/client";
|
||||
import { fetchGitHubData } from "../github/data/fetcher";
|
||||
import { parseGitHubContext } from "../github/context";
|
||||
import { setupOAuthCredentials } from "../claude/oauth-setup";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Step 1: Setup OAuth credentials if provided
|
||||
const claudeCredentials = process.env.CLAUDE_CREDENTIALS;
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (claudeCredentials && anthropicApiKey === "use-oauth") {
|
||||
await setupOAuthCredentials(claudeCredentials);
|
||||
console.log("OAuth credentials configured for Claude AI Max subscription");
|
||||
}
|
||||
|
||||
// Step 2: Setup GitHub token
|
||||
// Step 1: Setup GitHub token
|
||||
const githubToken = await setupGitHubToken();
|
||||
const client = createClient(githubToken);
|
||||
|
||||
// Step 3: Parse GitHub context (once for all operations)
|
||||
// Step 2: Parse GitHub context (once for all operations)
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// Step 4: Check write permissions
|
||||
// Step 3: Check write permissions
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
client.api,
|
||||
context,
|
||||
@@ -49,7 +39,7 @@ async function run() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Check trigger conditions
|
||||
// Step 4: Check trigger conditions
|
||||
const containsTrigger = await checkTriggerAction(context);
|
||||
|
||||
// Set outputs that are always needed
|
||||
@@ -61,14 +51,14 @@ async function run() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Check if actor is human
|
||||
// Step 5: Check if actor is human
|
||||
await checkHumanActor(client.api, context);
|
||||
|
||||
// Step 7: Create initial tracking comment
|
||||
// Step 6: Create initial tracking comment
|
||||
const commentId = await createInitialComment(client.api, context);
|
||||
core.setOutput("claude_comment_id", commentId.toString());
|
||||
|
||||
// Step 8: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
const githubData = await fetchGitHubData({
|
||||
client: client,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
@@ -76,14 +66,14 @@ async function run() {
|
||||
isPR: context.isPR,
|
||||
});
|
||||
|
||||
// Step 9: Setup branch
|
||||
// Step 8: Setup branch
|
||||
const branchInfo = await setupBranch(client, githubData, context);
|
||||
core.setOutput("BASE_BRANCH", branchInfo.baseBranch);
|
||||
if (branchInfo.claudeBranch) {
|
||||
core.setOutput("CLAUDE_BRANCH", branchInfo.claudeBranch);
|
||||
}
|
||||
|
||||
// Step 10: Update initial comment with branch link (only if a claude branch was created)
|
||||
// Step 9: Update initial comment with branch link (only for issues that created a new branch)
|
||||
if (branchInfo.claudeBranch) {
|
||||
await updateTrackingComment(
|
||||
client,
|
||||
@@ -93,7 +83,7 @@ async function run() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 11: Create prompt file
|
||||
// Step 10: Create prompt file
|
||||
await createPrompt(
|
||||
commentId,
|
||||
branchInfo.baseBranch,
|
||||
@@ -102,7 +92,7 @@ async function run() {
|
||||
context,
|
||||
);
|
||||
|
||||
// Step 12: Get MCP configuration
|
||||
// Step 11: Get MCP configuration
|
||||
const mcpConfig = await prepareMcpConfig(
|
||||
githubToken,
|
||||
context.repository.owner,
|
||||
|
||||
@@ -32,7 +32,7 @@ async function run() {
|
||||
const client = createClient(githubToken);
|
||||
|
||||
const serverUrl = GITEA_SERVER_URL;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_NUMBER}`;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
||||
|
||||
let comment;
|
||||
let isPRReviewComment = false;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Derive API URL from server URL for Gitea instances
|
||||
function deriveApiUrl(serverUrl: string): string {
|
||||
if (serverUrl.includes("github.com")) {
|
||||
return "https://api.github.com";
|
||||
export const GITEA_API_URL =
|
||||
process.env.GITEA_API_URL || "https://api.github.com";
|
||||
|
||||
// Derive server URL from API URL for Gitea instances
|
||||
function deriveServerUrl(apiUrl: string): string {
|
||||
if (apiUrl.includes("api.github.com")) {
|
||||
return "https://github.com";
|
||||
}
|
||||
// For Gitea, add /api/v1 to the server URL to get the API URL
|
||||
return `${serverUrl}/api/v1`;
|
||||
// For Gitea, remove /api/v1 from the API URL to get the server URL
|
||||
return apiUrl.replace(/\/api\/v1\/?$/, "");
|
||||
}
|
||||
|
||||
export const GITEA_SERVER_URL =
|
||||
process.env.GITHUB_SERVER_URL || "https://github.com";
|
||||
|
||||
export const GITEA_API_URL =
|
||||
process.env.GITEA_API_URL || deriveApiUrl(GITEA_SERVER_URL);
|
||||
process.env.GITEA_SERVER_URL || deriveServerUrl(GITEA_API_URL);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
const context = github.context;
|
||||
|
||||
const commonFields = {
|
||||
runId: process.env.GITHUB_RUN_NUMBER!,
|
||||
runId: process.env.GITHUB_RUN_ID!,
|
||||
eventName: context.eventName,
|
||||
eventAction: context.payload.action,
|
||||
repository: {
|
||||
|
||||
@@ -29,18 +29,6 @@ export async function setupBranch(
|
||||
const { baseBranch } = context.inputs;
|
||||
const isPR = context.isPR;
|
||||
|
||||
// Determine base branch - use baseBranch if provided, otherwise fetch default
|
||||
let sourceBranch: string;
|
||||
|
||||
if (baseBranch) {
|
||||
// Use provided base branch for source
|
||||
sourceBranch = baseBranch;
|
||||
} else {
|
||||
// No base branch provided, fetch the default branch to use as source
|
||||
const repoResponse = await client.api.getRepo(owner, repo);
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
if (isPR) {
|
||||
const prData = githubData.contextData as GitHubPullRequest;
|
||||
const prState = prData.state;
|
||||
@@ -48,18 +36,9 @@ export async function setupBranch(
|
||||
// Check if PR is closed or merged
|
||||
if (prState === "CLOSED" || prState === "MERGED") {
|
||||
console.log(
|
||||
`PR #${entityNumber} is ${prState}, will let Claude create a new branch when needed`,
|
||||
`PR #${entityNumber} is ${prState}, creating new branch from source...`,
|
||||
);
|
||||
|
||||
// Check out the base branch and let Claude create branches as needed
|
||||
await $`git fetch origin ${sourceBranch}`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
await $`git pull origin ${sourceBranch}`;
|
||||
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
currentBranch: sourceBranch,
|
||||
};
|
||||
// Fall through to create a new branch like we do for issues
|
||||
} else {
|
||||
// Handle open PR: Checkout the PR branch
|
||||
console.log("This is an open PR, checking out PR branch...");
|
||||
@@ -83,54 +62,97 @@ export async function setupBranch(
|
||||
}
|
||||
}
|
||||
|
||||
// For issues, check out the base branch and let Claude create branches as needed
|
||||
// Determine source branch - use baseBranch if provided, otherwise fetch default
|
||||
let sourceBranch: string;
|
||||
|
||||
if (baseBranch) {
|
||||
// Use provided base branch for source
|
||||
sourceBranch = baseBranch;
|
||||
} else {
|
||||
// No base branch provided, fetch the default branch to use as source
|
||||
const repoResponse = await client.api.getRepo(owner, repo);
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
// Creating a new branch for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
console.log(
|
||||
`Setting up base branch ${sourceBranch} for issue #${entityNumber}, Claude will create branch when needed...`,
|
||||
`Creating new branch for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
||||
);
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("_");
|
||||
|
||||
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||
|
||||
try {
|
||||
// Use local git operations instead of API since Gitea's API is unreliable
|
||||
console.log(
|
||||
`Setting up local git branch: ${newBranch} from: ${sourceBranch}`,
|
||||
);
|
||||
|
||||
// Ensure we're in the repository directory
|
||||
const repoDir = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
console.log(`Working in directory: ${repoDir}`);
|
||||
|
||||
// Check if we're in a git repository
|
||||
console.log(`Checking if we're in a git repository...`);
|
||||
await $`git status`;
|
||||
try {
|
||||
// Check if we're in a git repository
|
||||
console.log(`Checking if we're in a git repository...`);
|
||||
await $`git status`;
|
||||
|
||||
// Ensure we have the latest version of the source branch
|
||||
console.log(`Fetching latest ${sourceBranch}...`);
|
||||
await $`git fetch origin ${sourceBranch}`;
|
||||
// Ensure we have the latest version of the source branch
|
||||
console.log(`Fetching latest ${sourceBranch}...`);
|
||||
await $`git fetch origin ${sourceBranch}`;
|
||||
|
||||
// Checkout the source branch
|
||||
console.log(`Checking out ${sourceBranch}...`);
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
// Checkout the source branch
|
||||
console.log(`Checking out ${sourceBranch}...`);
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
|
||||
// Pull latest changes
|
||||
console.log(`Pulling latest changes for ${sourceBranch}...`);
|
||||
await $`git pull origin ${sourceBranch}`;
|
||||
// Pull latest changes
|
||||
console.log(`Pulling latest changes for ${sourceBranch}...`);
|
||||
await $`git pull origin ${sourceBranch}`;
|
||||
|
||||
// Verify the branch was checked out
|
||||
const currentBranch = await $`git branch --show-current`;
|
||||
const branchName = currentBranch.text().trim();
|
||||
console.log(`Current branch: ${branchName}`);
|
||||
// Create and checkout the new branch
|
||||
console.log(`Creating new branch: ${newBranch}`);
|
||||
await $`git checkout -b ${newBranch}`;
|
||||
|
||||
if (branchName === sourceBranch) {
|
||||
console.log(`✅ Successfully checked out base branch: ${sourceBranch}`);
|
||||
} else {
|
||||
// Verify the branch was created
|
||||
const currentBranch = await $`git branch --show-current`;
|
||||
const branchName = currentBranch.text().trim();
|
||||
console.log(`Current branch after creation: ${branchName}`);
|
||||
|
||||
if (branchName === newBranch) {
|
||||
console.log(
|
||||
`✅ Successfully created and checked out branch: ${newBranch}`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Branch creation failed. Expected ${newBranch}, got ${branchName}`,
|
||||
);
|
||||
}
|
||||
} catch (gitError: any) {
|
||||
console.error(`❌ Git operations failed:`, gitError);
|
||||
console.error(`Error message: ${gitError.message || gitError}`);
|
||||
|
||||
// This is a critical failure - the branch MUST be created for Claude to work
|
||||
throw new Error(
|
||||
`Branch checkout failed. Expected ${sourceBranch}, got ${branchName}`,
|
||||
`Failed to create branch ${newBranch}: ${gitError.message || gitError}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Branch setup completed, ready for Claude to create branches as needed`,
|
||||
);
|
||||
console.log(`Branch setup completed for: ${newBranch}`);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
currentBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: newBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error setting up branch:", error);
|
||||
|
||||
@@ -23,11 +23,11 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No GitHub token available. Please provide a gitea_token input or ensure GITHUB_TOKEN is available in the workflow environment.",
|
||||
"No GitHub token available. Please provide a github_token input or ensure GITHUB_TOKEN is available in the workflow environment.",
|
||||
);
|
||||
} catch (error) {
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`gitea_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
|
||||
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`github_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
199
src/mcp/github-file-ops-server.ts
Normal file
199
src/mcp/github-file-ops-server.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
// GitHub File Operations MCP Server
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import fetch from "node-fetch";
|
||||
import { GITEA_API_URL } from "../github/api/config";
|
||||
|
||||
type GitHubRef = {
|
||||
object: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GitHubCommit = {
|
||||
tree: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GitHubTree = {
|
||||
sha: string;
|
||||
};
|
||||
|
||||
type GitHubNewCommit = {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const BRANCH_NAME = process.env.BRANCH_NAME;
|
||||
const REPO_DIR = process.env.REPO_DIR || process.cwd();
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !BRANCH_NAME) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and BRANCH_NAME environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "GitHub File Operations Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
"Commit one or more files to a repository in a single commit (this will commit them atomically in the remote repository)",
|
||||
{
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
async ({ files, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const branch = BRANCH_NAME;
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const processedFiles = files.map((filePath) => {
|
||||
if (filePath.startsWith("/")) {
|
||||
return filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// NOTE: Gitea does not support GitHub's low-level git API operations
|
||||
// (creating trees, commits, etc.). We need to use the contents API instead.
|
||||
// For now, throw an error indicating this functionality is not available.
|
||||
throw new Error(
|
||||
"Multi-file commits are not supported with Gitea. " +
|
||||
"Gitea does not provide the low-level git API operations (trees, commits) " +
|
||||
"that are required for atomic multi-file commits. " +
|
||||
"Please commit files individually using the contents API.",
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(simplifiedResult, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete files tool
|
||||
server.tool(
|
||||
"delete_files",
|
||||
"Delete one or more files from a repository in a single commit",
|
||||
{
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
async ({ paths, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const branch = BRANCH_NAME;
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
// Convert absolute paths to relative if they match CWD
|
||||
const cwd = process.cwd();
|
||||
const processedPaths = paths.map((filePath) => {
|
||||
if (filePath.startsWith("/")) {
|
||||
if (filePath.startsWith(cwd)) {
|
||||
// Strip CWD from absolute path
|
||||
return filePath.slice(cwd.length + 1);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Path '${filePath}' must be relative to repository root or within current working directory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// NOTE: Gitea does not support GitHub's low-level git API operations
|
||||
// (creating trees, commits, etc.). We need to use the contents API instead.
|
||||
// For now, throw an error indicating this functionality is not available.
|
||||
throw new Error(
|
||||
"Multi-file deletions are not supported with Gitea. " +
|
||||
"Gitea does not provide the low-level git API operations (trees, commits) " +
|
||||
"that are required for atomic multi-file operations. " +
|
||||
"Please delete files individually using the contents API.",
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(simplifiedResult, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -127,102 +127,6 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
// Checkout branch tool
|
||||
server.tool(
|
||||
"checkout_branch",
|
||||
"Checkout an existing branch using local git operations",
|
||||
{
|
||||
branch_name: z.string().describe("Name of the existing branch to checkout"),
|
||||
create_if_missing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Create branch if it doesn't exist locally (defaults to false)",
|
||||
),
|
||||
fetch_remote: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Fetch from remote if branch doesn't exist locally (defaults to true)",
|
||||
),
|
||||
},
|
||||
async ({ branch_name, create_if_missing = false, fetch_remote = true }) => {
|
||||
try {
|
||||
// Check if branch exists locally
|
||||
let branchExists = false;
|
||||
try {
|
||||
runGitCommand(`git rev-parse --verify ${branch_name}`);
|
||||
branchExists = true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist locally`,
|
||||
);
|
||||
}
|
||||
|
||||
// If branch doesn't exist locally, try to fetch from remote
|
||||
if (!branchExists && fetch_remote) {
|
||||
try {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Attempting to fetch ${branch_name} from remote`,
|
||||
);
|
||||
runGitCommand(`git fetch origin ${branch_name}:${branch_name}`);
|
||||
branchExists = true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist on remote`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If branch still doesn't exist and create_if_missing is true, create it
|
||||
if (!branchExists && create_if_missing) {
|
||||
console.log(`[LOCAL-GIT-MCP] Creating new branch ${branch_name}`);
|
||||
runGitCommand(`git checkout -b ${branch_name}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully created and checked out new branch: ${branch_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If branch doesn't exist and we can't/won't create it, throw error
|
||||
if (!branchExists) {
|
||||
throw new Error(
|
||||
`Branch '${branch_name}' does not exist locally or on remote. Use create_if_missing=true to create it.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Checkout the existing branch
|
||||
runGitCommand(`git checkout ${branch_name}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully checked out branch: ${branch_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error checking out branch: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
|
||||
@@ -317,7 +317,7 @@ describe("generatePrompt", () => {
|
||||
|
||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||
expect(prompt).toContain(
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.local>",
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -338,7 +338,7 @@ describe("generatePrompt", () => {
|
||||
|
||||
// Should contain PR-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
@@ -378,12 +378,12 @@ describe("generatePrompt", () => {
|
||||
);
|
||||
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",
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
|
||||
// Should NOT contain PR-specific instructions
|
||||
expect(prompt).not.toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).not.toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
@@ -449,10 +449,13 @@ describe("generatePrompt", () => {
|
||||
"The branch-name is the current branch: claude/pr-456-20240101_120000",
|
||||
);
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
expect(prompt).toContain(
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
|
||||
// Should NOT contain open PR instructions
|
||||
expect(prompt).not.toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -475,7 +478,7 @@ describe("generatePrompt", () => {
|
||||
|
||||
// Should contain open PR instructions
|
||||
expect(prompt).toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
@@ -540,6 +543,9 @@ describe("generatePrompt", () => {
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
expect(prompt).toContain(
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle pull_request event on closed PR with new branch", () => {
|
||||
@@ -632,8 +638,8 @@ describe("buildAllowedToolsString", () => {
|
||||
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__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should return PR comment tool for inline review comments", () => {
|
||||
@@ -656,8 +662,8 @@ describe("buildAllowedToolsString", () => {
|
||||
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");
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should append custom tools when provided", () => {
|
||||
|
||||
Reference in New Issue
Block a user