diff --git a/README.md b/README.md index d39edca..5315d74 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yml b/action.yml index dde0eeb..9815ae6 100644 --- a/action.yml +++ b/action.yml @@ -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 != '' diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 8d4e6b4..47d8245 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -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`, diff --git a/src/github/api/gitea-client.ts b/src/github/api/gitea-client.ts index f7c1393..822afc0 100644 --- a/src/github/api/gitea-client.ts +++ b/src/github/api/gitea-client.ts @@ -28,13 +28,13 @@ export class GiteaApiClient { private async request( method: string, endpoint: string, - body?: any + body?: any, ): Promise> { const url = `${this.baseUrl}${endpoint}`; - + const headers: Record = { "Content-Type": "application/json", - "Authorization": `token ${this.token}`, + Authorization: `token ${this.token}`, }; const options: any = { @@ -48,10 +48,10 @@ export class GiteaApiClient { try { const response = await fetch(url, options); - + let responseData: any = null; const contentType = response.headers.get("content-type"); - + // Only try to parse JSON if the response has JSON content type if (contentType && contentType.includes("application/json")) { try { @@ -65,11 +65,14 @@ export class GiteaApiClient { } if (!response.ok) { - const errorMessage = typeof responseData === 'object' && responseData.message - ? responseData.message - : responseData || response.statusText; - - const error = new Error(`HTTP ${response.status}: ${errorMessage}`) as GiteaApiError; + const errorMessage = + typeof responseData === "object" && responseData.message + ? responseData.message + : responseData || response.statusText; + + 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)}`; @@ -167,23 +225,27 @@ export class GiteaApiClient { } async createFile( - owner: string, - repo: string, - path: string, - content: string, + owner: string, + repo: string, + path: string, + content: string, message: string, - branch?: string + branch?: string, ) { const body: any = { message, content: Buffer.from(content).toString("base64"), }; - + if (branch) { 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,19 +255,23 @@ export class GiteaApiClient { content: string, message: string, sha: string, - branch?: string + branch?: string, ) { const body: any = { message, content: Buffer.from(content).toString("base64"), sha, }; - + if (branch) { 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,26 +280,34 @@ export class GiteaApiClient { path: string, message: string, sha: string, - branch?: string + branch?: string, ) { const body: any = { message, sha, }; - + if (branch) { 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(method: string, endpoint: string, body?: any): Promise> { + async customRequest( + method: string, + endpoint: string, + body?: any, + ): Promise> { return this.request(method, endpoint, body); } } export function createGiteaClient(token: string): GiteaApiClient { return new GiteaApiClient(token); -} \ No newline at end of file +} diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index 5954da5..061020c 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -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) { diff --git a/src/github/operations/branch-cleanup.ts b/src/github/operations/branch-cleanup.ts index c42790d..fc20989 100644 --- a/src/github/operations/branch-cleanup.ts +++ b/src/github/operations/branch-cleanup.ts @@ -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); diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 1364ab0..571d476 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -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}`); - - 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}`); - } + // Use local git operations instead of API since Gitea's API is unreliable + console.log( + `Setting up local git branch: ${newBranch} from: ${sourceBranch}`, + ); - // Create branch using Gitea's branch creation API - console.log(`Creating 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 { - 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}`); + // 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}`; + + // 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); } } diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index 5f74b7c..9802213 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -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`); diff --git a/src/github/operations/comments/update-with-branch.ts b/src/github/operations/comments/update-with-branch.ts index af3ca16..db5b00a 100644 --- a/src/github/operations/comments/update-with-branch.ts +++ b/src/github/operations/comments/update-with-branch.ts @@ -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 diff --git a/src/github/utils/image-downloader.ts b/src/github/utils/image-downloader.ts index a4a635c..f447360 100644 --- a/src/github/utils/image-downloader.ts +++ b/src/github/utils/image-downloader.ts @@ -46,6 +46,8 @@ export async function downloadCommentImages( ): Promise> { // 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(); } diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index d38c7c3..26e10ba 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -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; diff --git a/src/github/validation/permissions.ts b/src/github/validation/permissions.ts index e3652b2..1c0206c 100644 --- a/src/github/validation/permissions.ts +++ b/src/github/validation/permissions.ts @@ -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}`); diff --git a/src/mcp/github-file-ops-server.ts b/src/mcp/github-file-ops-server.ts index 4f74255..2b87ff7 100644 --- a/src/mcp/github-file-ops-server.ts +++ b/src/mcp/github-file-ops-server.ts @@ -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 { diff --git a/src/mcp/install-mcp-server.ts b/src/mcp/install-mcp-server.ts index 462967d..c3ba023 100644 --- a/src/mcp/install-mcp-server.ts +++ b/src/mcp/install-mcp-server.ts @@ -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", }, }, }, diff --git a/src/mcp/local-git-ops-server.ts b/src/mcp/local-git-ops-server.ts new file mode 100644 index 0000000..c361f49 --- /dev/null +++ b/src/mcp/local-git-ops-server.ts @@ -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);