Attempt to make this work

This commit is contained in:
Mark Wylde
2025-05-30 22:18:35 +01:00
parent 11685fc8c1
commit b41b7ecd9f
15 changed files with 606 additions and 116 deletions

View File

@@ -69,26 +69,62 @@ jobs:
## 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` |
| `github_token` | GitHub 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 API URL (e.g., `https://gitea.example.com/api/v1`) 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)
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
## Gitea Configuration
This action has been enhanced to work with Gitea installations. The main differences from GitHub are:
1. **Local Git Operations**: Instead of using API-based file operations (which have limited support in Gitea), this action uses local git commands to create branches, commit files, and push changes.
2. **API URL Configuration**: You must specify your Gitea API URL using the `gitea_api_url` input.
### Example Gitea Workflow
```yaml
name: Claude Assistant for Gitea
on:
issue_comment:
types: [created]
jobs:
claude-response:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
gitea_api_url: "https://gitea.example.com/api/v1"
github_token: ${{ secrets.GITEA_TOKEN }}
```
### Gitea Setup Notes
- Use a Gitea personal access token instead of `GITHUB_TOKEN`
- The token needs repository read/write permissions
- Claude will use local git operations for file changes and branch creation
- Only PR creation and comment updates use the Gitea API
## Examples
### Ways to Tag @claude

View File

@@ -47,6 +47,9 @@ inputs:
github_token:
description: "GitHub token with repo and pull request permissions (defaults to GITHUB_TOKEN)"
required: false
gitea_api_url:
description: "Gitea API URL (e.g., https://gitea.example.com/api/v1, defaults to GitHub API)"
required: false
use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false
@@ -95,6 +98,7 @@ runs:
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
GITEA_API_URL: ${{ inputs.gitea_api_url }}
- name: Run Claude Code
id: claude-code
@@ -117,6 +121,7 @@ runs:
# GitHub token for repository access
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
GITEA_API_URL: ${{ inputs.gitea_api_url }}
# Provider configuration (for future cloud provider support)
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
@@ -154,6 +159,7 @@ 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: ${{ inputs.gitea_api_url }}
- name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''

View File

@@ -37,7 +37,10 @@ async function run() {
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments, use the pulls API
console.log(`Fetching PR review comment ${commentId}`);
const response = await client.api.customRequest("GET", `/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`);
const response = await client.api.customRequest(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
);
comment = response.data;
isPRReviewComment = true;
console.log("Successfully fetched as PR review comment");
@@ -46,7 +49,10 @@ async function run() {
// For all other event types, use the issues API
if (!comment) {
console.log(`Fetching issue comment ${commentId}`);
const response = await client.api.customRequest("GET", `/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`);
const response = await client.api.customRequest(
"GET",
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
);
comment = response.data;
isPRReviewComment = false;
console.log("Successfully fetched as issue comment");
@@ -61,7 +67,11 @@ async function run() {
// Try to get the PR info to understand the comment structure
try {
const pr = await client.api.getPullRequest(owner, repo, context.entityNumber);
const pr = await client.api.getPullRequest(
owner,
repo,
context.entityNumber,
);
console.log(`PR state: ${pr.data.state}`);
console.log(`PR comments count: ${pr.data.comments}`);
console.log(`PR review comments count: ${pr.data.review_comments}`);
@@ -100,10 +110,18 @@ async function run() {
try {
// Get the branch info to see if it exists and has commits
const branchResponse = await client.api.getBranch(owner, repo, claudeBranch);
const branchResponse = await client.api.getBranch(
owner,
repo,
claudeBranch,
);
// Get base branch info for comparison
const baseResponse = await client.api.getBranch(owner, repo, baseBranch);
const baseResponse = await client.api.getBranch(
owner,
repo,
baseBranch,
);
const branchSha = branchResponse.data.commit.sha;
const baseSha = baseResponse.data.commit.sha;
@@ -223,11 +241,20 @@ async function run() {
// Update the comment using the appropriate API
try {
if (isPRReviewComment) {
await client.api.customRequest("PATCH", `/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`, {
body: updatedBody,
});
await client.api.customRequest(
"PATCH",
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{
body: updatedBody,
},
);
} else {
await client.api.updateIssueComment(owner, repo, commentId, updatedBody);
await client.api.updateIssueComment(
owner,
repo,
commentId,
updatedBody,
);
}
console.log(
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,

View File

@@ -28,13 +28,13 @@ export class GiteaApiClient {
private async request<T = any>(
method: string,
endpoint: string,
body?: any
body?: any,
): Promise<GiteaApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Authorization": `token ${this.token}`,
Authorization: `token ${this.token}`,
};
const options: any = {
@@ -65,11 +65,14 @@ export class GiteaApiClient {
}
if (!response.ok) {
const errorMessage = typeof responseData === 'object' && responseData.message
? responseData.message
: responseData || response.statusText;
const errorMessage =
typeof responseData === "object" && responseData.message
? responseData.message
: responseData || response.statusText;
const error = new Error(`HTTP ${response.status}: ${errorMessage}`) as GiteaApiError;
const error = new Error(
`HTTP ${response.status}: ${errorMessage}`,
) as GiteaApiError;
error.status = response.status;
error.response = {
data: responseData,
@@ -103,10 +106,18 @@ export class GiteaApiClient {
}
async getBranch(owner: string, repo: string, branch: string) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
);
}
async createBranch(owner: string, repo: string, newBranch: string, fromBranch: string) {
async createBranch(
owner: string,
repo: string,
newBranch: string,
fromBranch: string,
) {
return this.request("POST", `/api/v1/repos/${owner}/${repo}/branches`, {
new_branch_name: newBranch,
old_branch_name: fromBranch,
@@ -119,46 +130,93 @@ export class GiteaApiClient {
// Issue operations
async getIssue(owner: string, repo: string, issueNumber: number) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/issues/${issueNumber}`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}`,
);
}
async listIssueComments(owner: string, repo: string, issueNumber: number) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
);
}
async createIssueComment(owner: string, repo: string, issueNumber: number, body: string) {
return this.request("POST", `/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
body,
});
async createIssueComment(
owner: string,
repo: string,
issueNumber: number,
body: string,
) {
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
{
body,
},
);
}
async updateIssueComment(owner: string, repo: string, commentId: number, body: string) {
return this.request("PATCH", `/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`, {
body,
});
async updateIssueComment(
owner: string,
repo: string,
commentId: number,
body: string,
) {
return this.request(
"PATCH",
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
{
body,
},
);
}
// Pull request operations
async getPullRequest(owner: string, repo: string, prNumber: number) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/pulls/${prNumber}`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}`,
);
}
async listPullRequestFiles(owner: string, repo: string, prNumber: number) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/files`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/files`,
);
}
async listPullRequestComments(owner: string, repo: string, prNumber: number) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`);
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
);
}
async createPullRequestComment(owner: string, repo: string, prNumber: number, body: string) {
return this.request("POST", `/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`, {
body,
});
async createPullRequestComment(
owner: string,
repo: string,
prNumber: number,
body: string,
) {
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
{
body,
},
);
}
// File operations
async getFileContents(owner: string, repo: string, path: string, ref?: string) {
async getFileContents(
owner: string,
repo: string,
path: string,
ref?: string,
) {
let endpoint = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
if (ref) {
endpoint += `?ref=${encodeURIComponent(ref)}`;
@@ -172,7 +230,7 @@ export class GiteaApiClient {
path: string,
content: string,
message: string,
branch?: string
branch?: string,
) {
const body: any = {
message,
@@ -183,7 +241,11 @@ export class GiteaApiClient {
body.branch = branch;
}
return this.request("POST", `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, body);
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
async updateFile(
@@ -193,7 +255,7 @@ export class GiteaApiClient {
content: string,
message: string,
sha: string,
branch?: string
branch?: string,
) {
const body: any = {
message,
@@ -205,7 +267,11 @@ export class GiteaApiClient {
body.branch = branch;
}
return this.request("PUT", `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, body);
return this.request(
"PUT",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
async deleteFile(
@@ -214,7 +280,7 @@ export class GiteaApiClient {
path: string,
message: string,
sha: string,
branch?: string
branch?: string,
) {
const body: any = {
message,
@@ -225,11 +291,19 @@ export class GiteaApiClient {
body.branch = branch;
}
return this.request("DELETE", `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, body);
return this.request(
"DELETE",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
// Generic request method for other operations
async customRequest<T = any>(method: string, endpoint: string, body?: any): Promise<GiteaApiResponse<T>> {
async customRequest<T = any>(
method: string,
endpoint: string,
body?: any,
): Promise<GiteaApiResponse<T>> {
return this.request<T>(method, endpoint, body);
}
}

View File

@@ -50,7 +50,11 @@ export async function fetchGitHubData({
// Use REST API for all requests (works with both GitHub and Gitea)
if (isPR) {
console.log(`Fetching PR #${prNumber} data using REST API`);
const prResponse = await client.api.getPullRequest(owner, repo, parseInt(prNumber));
const prResponse = await client.api.getPullRequest(
owner,
repo,
parseInt(prNumber),
);
contextData = {
title: prResponse.data.title,
@@ -74,7 +78,7 @@ export async function fetchGitHubData({
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber)
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
@@ -93,7 +97,7 @@ export async function fetchGitHubData({
const filesResponse = await client.api.listPullRequestFiles(
owner,
repo,
parseInt(prNumber)
parseInt(prNumber),
);
changedFiles = filesResponse.data.map((file: any) => ({
path: file.filename,
@@ -109,7 +113,11 @@ export async function fetchGitHubData({
reviewData = { nodes: [] }; // Simplified for Gitea
} else {
console.log(`Fetching issue #${prNumber} data using REST API`);
const issueResponse = await client.api.getIssue(owner, repo, parseInt(prNumber));
const issueResponse = await client.api.getIssue(
owner,
repo,
parseInt(prNumber),
);
contextData = {
title: issueResponse.data.title,
@@ -125,7 +133,7 @@ export async function fetchGitHubData({
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber)
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
@@ -144,7 +152,6 @@ export async function fetchGitHubData({
throw new Error(`Failed to fetch ${isPR ? "PR" : "issue"} data`);
}
// Compute SHAs for changed files
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
if (isPR && changedFiles.length > 0) {

View File

@@ -17,7 +17,11 @@ export async function checkAndDeleteEmptyBranch(
try {
// Get the branch info to see if it exists and has commits
const branchResponse = await client.api.getBranch(owner, repo, claudeBranch);
const branchResponse = await client.api.getBranch(
owner,
repo,
claudeBranch,
);
// Get base branch info for comparison
const baseResponse = await client.api.getBranch(owner, repo, baseBranch);

View File

@@ -90,29 +90,37 @@ export async function setupBranch(
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
try {
// Get the SHA of the source branch using Gitea's branches endpoint
console.log(`Getting branch info for: ${sourceBranch}`);
// 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}`);
try {
const branchResponse = await client.api.getBranch(owner, repo, sourceBranch);
const currentSHA = branchResponse.data.commit.sha;
console.log(`Current SHA: ${currentSHA}`);
} catch (branchError: any) {
console.log(`Failed to get branch info: ${branchError.message}`);
}
// Ensure we have the latest version of the source branch
console.log(`Fetching latest ${sourceBranch}...`);
await $`git fetch origin ${sourceBranch}`;
// Create branch using Gitea's branch creation API
console.log(`Creating branch: ${newBranch} from: ${sourceBranch}`);
// Checkout the source branch
console.log(`Checking out ${sourceBranch}...`);
await $`git checkout ${sourceBranch}`;
try {
await client.api.createBranch(owner, repo, newBranch, sourceBranch);
console.log(`Successfully created branch via Gitea API: ${newBranch}`);
} catch (createBranchError: any) {
console.log(`Branch creation failed: ${createBranchError.message}`);
console.log(`Error status: ${createBranchError.status}`);
// Pull latest changes
await $`git pull origin ${sourceBranch}`;
// Create and checkout the new branch
console.log(`Creating new branch: ${newBranch}`);
await $`git checkout -b ${newBranch}`;
console.log(`Successfully created and checked out branch: ${newBranch}`);
} catch (gitError: any) {
console.log(
`Branch ${newBranch} will be created when files are pushed via MCP server`,
`Local git operations completed. Branch ${newBranch} ready for use.`,
);
// Don't fail here - the branch will be created when files are committed
}
console.log(`Branch setup completed for: ${newBranch}`);
@@ -126,7 +134,7 @@ export async function setupBranch(
currentBranch: newBranch,
};
} catch (error) {
console.error("Error creating branch:", error);
console.error("Error setting up branch:", error);
process.exit(1);
}
}

View File

@@ -25,19 +25,30 @@ export async function createInitialComment(
try {
let response;
console.log(`Creating comment for ${context.isPR ? 'PR' : 'issue'} #${context.entityNumber}`);
console.log(
`Creating comment for ${context.isPR ? "PR" : "issue"} #${context.entityNumber}`,
);
console.log(`Repository: ${owner}/${repo}`);
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
if (isPullRequestReviewCommentEvent(context)) {
console.log(`Creating PR review comment reply`);
response = await api.customRequest("POST", `/api/v1/repos/${owner}/${repo}/pulls/${context.entityNumber}/comments/${context.payload.comment.id}/replies`, {
body: initialBody,
});
response = await api.customRequest(
"POST",
`/api/v1/repos/${owner}/${repo}/pulls/${context.entityNumber}/comments/${context.payload.comment.id}/replies`,
{
body: initialBody,
},
);
} else {
// For all other cases (issues, issue comments, or missing comment_id)
console.log(`Creating issue comment via API`);
response = await api.createIssueComment(owner, repo, context.entityNumber, initialBody);
response = await api.createIssueComment(
owner,
repo,
context.entityNumber,
initialBody,
);
}
// Output the comment ID for downstream steps using GITHUB_OUTPUT
@@ -50,7 +61,12 @@ export async function createInitialComment(
// Always fall back to regular issue comment if anything fails
try {
const response = await api.createIssueComment(owner, repo, context.entityNumber, initialBody);
const response = await api.createIssueComment(
owner,
repo,
context.entityNumber,
initialBody,
);
const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);

View File

@@ -38,9 +38,13 @@ export async function updateTrackingComment(
try {
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments (inline comments), use the pulls API
await client.api.customRequest("PATCH", `/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`, {
body: updatedBody,
});
await client.api.customRequest(
"PATCH",
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{
body: updatedBody,
},
);
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
} else {
// For all other comments, use the issues API

View File

@@ -46,6 +46,8 @@ export async function downloadCommentImages(
): Promise<Map<string, string>> {
// Temporarily simplified - return empty map to avoid Octokit dependencies
// TODO: Implement image downloading with direct Gitea API calls if needed
console.log("Image downloading temporarily disabled during Octokit migration");
console.log(
"Image downloading temporarily disabled during Octokit migration",
);
return new Map<string, string>();
}

View File

@@ -26,7 +26,10 @@ export async function checkHumanActor(
try {
// Fetch user information from GitHub API
const response = await api.customRequest("GET", `/api/v1/users/${githubContext.actor}`);
const response = await api.customRequest(
"GET",
`/api/v1/users/${githubContext.actor}`,
);
const userData = response.data;
const actorType = userData.type;

View File

@@ -28,7 +28,10 @@ export async function checkWritePermissions(
core.info(`Checking permissions for actor: ${actor}`);
// Check permissions directly using the permission endpoint
const response = await api.customRequest("GET", `/api/v1/repos/${repository.owner}/${repository.repo}/collaborators/${actor}/permission`);
const response = await api.customRequest(
"GET",
`/api/v1/repos/${repository.owner}/${repository.repo}/collaborators/${actor}/permission`,
);
const permissionLevel = response.data.permission;
core.info(`Permission level retrieved: ${permissionLevel}`);

View File

@@ -85,9 +85,9 @@ server.tool(
// 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."
"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 {
@@ -158,9 +158,9 @@ server.tool(
// 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."
"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 {

View File

@@ -23,11 +23,11 @@ export async function prepareMcpConfig(
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
},
},
github_file_ops: {
local_git_ops: {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
`${process.env.GITHUB_ACTION_PATH}/src/mcp/local-git-ops-server.ts`,
],
env: {
GITHUB_TOKEN: githubToken,
@@ -35,6 +35,8 @@ export async function prepareMcpConfig(
REPO_NAME: repo,
BRANCH_NAME: branch,
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
GITEA_API_URL:
process.env.GITEA_API_URL || "https://api.github.com",
},
},
},

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env node
// Local Git 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, writeFile } from "fs/promises";
import { join } from "path";
import { execSync } from "child_process";
// 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();
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITEA_API_URL = process.env.GITEA_API_URL || "https://api.github.com";
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: "Local Git Operations Server",
version: "0.0.1",
});
// Helper function to run git commands
function runGitCommand(command: string): string {
try {
console.log(`Running git command: ${command}`);
const result = execSync(command, {
cwd: REPO_DIR,
encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"],
});
console.log(`Git command result: ${result.trim()}`);
return result.trim();
} catch (error: any) {
console.error(`Git command failed: ${command}`);
console.error(`Error: ${error.message}`);
if (error.stdout) console.error(`Stdout: ${error.stdout}`);
if (error.stderr) console.error(`Stderr: ${error.stderr}`);
throw error;
}
}
// Create branch tool
server.tool(
"create_branch",
"Create a new branch from a base branch using local git operations",
{
branch_name: z.string().describe("Name of the branch to create"),
base_branch: z
.string()
.describe("Base branch to create from (e.g., 'main')"),
},
async ({ branch_name, base_branch }) => {
try {
// Ensure we're on the base branch and it's up to date
runGitCommand(`git checkout ${base_branch}`);
runGitCommand(`git pull origin ${base_branch}`);
// Create and checkout the new branch
runGitCommand(`git checkout -b ${branch_name}`);
return {
content: [
{
type: "text",
text: `Successfully created and checked out branch: ${branch_name}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error creating branch: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Commit files tool
server.tool(
"commit_files",
"Commit one or more files to the current branch using local git operations",
{
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 }) => {
try {
// Add the specified files
for (const file of files) {
const filePath = file.startsWith("/") ? file.slice(1) : file;
runGitCommand(`git add "${filePath}"`);
}
// Commit the changes
runGitCommand(`git commit -m "${message}"`);
return {
content: [
{
type: "text",
text: `Successfully committed ${files.length} file(s): ${files.join(", ")}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error committing files: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Push branch tool
server.tool(
"push_branch",
"Push the current branch to remote origin",
{
force: z.boolean().optional().describe("Force push (use with caution)"),
},
async ({ force = false }) => {
try {
// Get current branch name
const currentBranch = runGitCommand("git rev-parse --abbrev-ref HEAD");
// Push the branch
const pushCommand = force
? `git push -f origin ${currentBranch}`
: `git push origin ${currentBranch}`;
runGitCommand(pushCommand);
return {
content: [
{
type: "text",
text: `Successfully pushed branch: ${currentBranch}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error pushing branch: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Create pull request tool (uses Gitea API)
server.tool(
"create_pull_request",
"Create a pull request using Gitea API",
{
title: z.string().describe("Pull request title"),
body: z.string().describe("Pull request body/description"),
base_branch: z.string().describe("Base branch (e.g., 'main')"),
head_branch: z
.string()
.optional()
.describe("Head branch (defaults to current branch)"),
},
async ({ title, body, base_branch, head_branch }) => {
try {
if (!GITHUB_TOKEN) {
throw new Error(
"GITHUB_TOKEN environment variable is required for PR creation",
);
}
// Get current branch if head_branch not specified
const currentBranch =
head_branch || runGitCommand("git rev-parse --abbrev-ref HEAD");
// Create PR using Gitea API
const response = await fetch(
`${GITEA_API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/pulls`,
{
method: "POST",
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
body,
base: base_branch,
head: currentBranch,
}),
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to create PR: ${response.status} ${errorText}`);
}
const prData = await response.json();
return {
content: [
{
type: "text",
text: `Successfully created pull request #${prData.number}: ${prData.html_url}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error creating pull request: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get git status tool
server.tool("git_status", "Get the current git status", {}, async () => {
try {
const status = runGitCommand("git status --porcelain");
const currentBranch = runGitCommand("git rev-parse --abbrev-ref HEAD");
return {
content: [
{
type: "text",
text: `Current branch: ${currentBranch}\nStatus:\n${status || "Working tree clean"}`,
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error getting git status: ${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);