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 ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | | --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | ---------- |
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | | `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 | - | | `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` | | `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 | - | | `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 | - | | `gitea_api_url` | Gitea API URL (e.g., `https://gitea.example.com/api/v1`) for Gitea installations. Leave empty for GitHub. | No | GitHub API |
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` | | `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` | | `use_bedrock` | Use Amazon Bedrock 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 | "" | | `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
| `disallowed_tools` | Tools that Claude should never use | No | "" | | `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | | `disallowed_tools` | Tools that Claude should never use | No | "" |
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | | `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | | `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) \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration. > **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
## 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 ## Examples
### Ways to Tag @claude ### Ways to Tag @claude

View File

@@ -47,6 +47,9 @@ inputs:
github_token: github_token:
description: "GitHub token with repo and pull request permissions (defaults to GITHUB_TOKEN)" description: "GitHub token with repo and pull request permissions (defaults to GITHUB_TOKEN)"
required: false 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: use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API" description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false required: false
@@ -95,6 +98,7 @@ runs:
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
GITEA_API_URL: ${{ inputs.gitea_api_url }}
- name: Run Claude Code - name: Run Claude Code
id: claude-code id: claude-code
@@ -117,6 +121,7 @@ runs:
# GitHub token for repository access # GitHub token for repository access
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
GITEA_API_URL: ${{ inputs.gitea_api_url }}
# Provider configuration (for future cloud provider support) # Provider configuration (for future cloud provider support)
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }} 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 || '' }} 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_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
GITEA_API_URL: ${{ inputs.gitea_api_url }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' 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)) { if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments, use the pulls API // For PR review comments, use the pulls API
console.log(`Fetching PR review comment ${commentId}`); 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; comment = response.data;
isPRReviewComment = true; isPRReviewComment = true;
console.log("Successfully fetched as PR review comment"); console.log("Successfully fetched as PR review comment");
@@ -46,7 +49,10 @@ async function run() {
// For all other event types, use the issues API // For all other event types, use the issues API
if (!comment) { if (!comment) {
console.log(`Fetching issue comment ${commentId}`); 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; comment = response.data;
isPRReviewComment = false; isPRReviewComment = false;
console.log("Successfully fetched as issue comment"); 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 to get the PR info to understand the comment structure
try { 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 state: ${pr.data.state}`);
console.log(`PR comments count: ${pr.data.comments}`); console.log(`PR comments count: ${pr.data.comments}`);
console.log(`PR review comments count: ${pr.data.review_comments}`); console.log(`PR review comments count: ${pr.data.review_comments}`);
@@ -100,10 +110,18 @@ async function run() {
try { try {
// Get the branch info to see if it exists and has commits // 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 // 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 branchSha = branchResponse.data.commit.sha;
const baseSha = baseResponse.data.commit.sha; const baseSha = baseResponse.data.commit.sha;
@@ -223,11 +241,20 @@ async function run() {
// Update the comment using the appropriate API // Update the comment using the appropriate API
try { try {
if (isPRReviewComment) { if (isPRReviewComment) {
await client.api.customRequest("PATCH", `/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`, { await client.api.customRequest(
body: updatedBody, "PATCH",
}); `/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{
body: updatedBody,
},
);
} else { } else {
await client.api.updateIssueComment(owner, repo, commentId, updatedBody); await client.api.updateIssueComment(
owner,
repo,
commentId,
updatedBody,
);
} }
console.log( console.log(
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`, `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,

View File

@@ -28,13 +28,13 @@ export class GiteaApiClient {
private async request<T = any>( private async request<T = any>(
method: string, method: string,
endpoint: string, endpoint: string,
body?: any body?: any,
): Promise<GiteaApiResponse<T>> { ): Promise<GiteaApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `token ${this.token}`, Authorization: `token ${this.token}`,
}; };
const options: any = { const options: any = {
@@ -48,10 +48,10 @@ export class GiteaApiClient {
try { try {
const response = await fetch(url, options); const response = await fetch(url, options);
let responseData: any = null; let responseData: any = null;
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
// Only try to parse JSON if the response has JSON content type // Only try to parse JSON if the response has JSON content type
if (contentType && contentType.includes("application/json")) { if (contentType && contentType.includes("application/json")) {
try { try {
@@ -65,11 +65,14 @@ export class GiteaApiClient {
} }
if (!response.ok) { if (!response.ok) {
const errorMessage = typeof responseData === 'object' && responseData.message const errorMessage =
? responseData.message typeof responseData === "object" && responseData.message
: responseData || response.statusText; ? 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.status = response.status;
error.response = { error.response = {
data: responseData, data: responseData,
@@ -103,10 +106,18 @@ export class GiteaApiClient {
} }
async getBranch(owner: string, repo: string, branch: string) { 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`, { return this.request("POST", `/api/v1/repos/${owner}/${repo}/branches`, {
new_branch_name: newBranch, new_branch_name: newBranch,
old_branch_name: fromBranch, old_branch_name: fromBranch,
@@ -119,46 +130,93 @@ export class GiteaApiClient {
// Issue operations // Issue operations
async getIssue(owner: string, repo: string, issueNumber: number) { 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) { 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) { async createIssueComment(
return this.request("POST", `/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { owner: string,
body, 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) { async updateIssueComment(
return this.request("PATCH", `/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`, { owner: string,
body, repo: string,
}); commentId: number,
body: string,
) {
return this.request(
"PATCH",
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
{
body,
},
);
} }
// Pull request operations // Pull request operations
async getPullRequest(owner: string, repo: string, prNumber: number) { 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) { 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) { 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) { async createPullRequestComment(
return this.request("POST", `/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`, { owner: string,
body, repo: string,
}); prNumber: number,
body: string,
) {
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
{
body,
},
);
} }
// File operations // 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)}`; let endpoint = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
if (ref) { if (ref) {
endpoint += `?ref=${encodeURIComponent(ref)}`; endpoint += `?ref=${encodeURIComponent(ref)}`;
@@ -167,23 +225,27 @@ export class GiteaApiClient {
} }
async createFile( async createFile(
owner: string, owner: string,
repo: string, repo: string,
path: string, path: string,
content: string, content: string,
message: string, message: string,
branch?: string branch?: string,
) { ) {
const body: any = { const body: any = {
message, message,
content: Buffer.from(content).toString("base64"), content: Buffer.from(content).toString("base64"),
}; };
if (branch) { if (branch) {
body.branch = 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( async updateFile(
@@ -193,19 +255,23 @@ export class GiteaApiClient {
content: string, content: string,
message: string, message: string,
sha: string, sha: string,
branch?: string branch?: string,
) { ) {
const body: any = { const body: any = {
message, message,
content: Buffer.from(content).toString("base64"), content: Buffer.from(content).toString("base64"),
sha, sha,
}; };
if (branch) { if (branch) {
body.branch = 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( async deleteFile(
@@ -214,26 +280,34 @@ export class GiteaApiClient {
path: string, path: string,
message: string, message: string,
sha: string, sha: string,
branch?: string branch?: string,
) { ) {
const body: any = { const body: any = {
message, message,
sha, sha,
}; };
if (branch) { if (branch) {
body.branch = 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 // 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); return this.request<T>(method, endpoint, body);
} }
} }
export function createGiteaClient(token: string): GiteaApiClient { export function createGiteaClient(token: string): GiteaApiClient {
return new GiteaApiClient(token); return new GiteaApiClient(token);
} }

View File

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

View File

@@ -17,7 +17,11 @@ export async function checkAndDeleteEmptyBranch(
try { try {
// Get the branch info to see if it exists and has commits // 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 // Get base branch info for comparison
const baseResponse = await client.api.getBranch(owner, repo, baseBranch); 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}`; const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
try { try {
// Get the SHA of the source branch using Gitea's branches endpoint // Use local git operations instead of API since Gitea's API is unreliable
console.log(`Getting branch info for: ${sourceBranch}`); console.log(
`Setting up local git branch: ${newBranch} from: ${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}`);
}
// Create branch using Gitea's branch creation API // Ensure we're in the repository directory
console.log(`Creating branch: ${newBranch} from: ${sourceBranch}`); const repoDir = process.env.GITHUB_WORKSPACE || process.cwd();
console.log(`Working in directory: ${repoDir}`);
try { try {
await client.api.createBranch(owner, repo, newBranch, sourceBranch); // Ensure we have the latest version of the source branch
console.log(`Successfully created branch via Gitea API: ${newBranch}`); console.log(`Fetching latest ${sourceBranch}...`);
} catch (createBranchError: any) { await $`git fetch origin ${sourceBranch}`;
console.log(`Branch creation failed: ${createBranchError.message}`);
console.log(`Error status: ${createBranchError.status}`); // 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( 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}`); console.log(`Branch setup completed for: ${newBranch}`);
@@ -126,7 +134,7 @@ export async function setupBranch(
currentBranch: newBranch, currentBranch: newBranch,
}; };
} catch (error) { } catch (error) {
console.error("Error creating branch:", error); console.error("Error setting up branch:", error);
process.exit(1); process.exit(1);
} }
} }

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ export async function downloadCommentImages(
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
// Temporarily simplified - return empty map to avoid Octokit dependencies // Temporarily simplified - return empty map to avoid Octokit dependencies
// TODO: Implement image downloading with direct Gitea API calls if needed // 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>(); return new Map<string, string>();
} }

View File

@@ -26,7 +26,10 @@ export async function checkHumanActor(
try { try {
// Fetch user information from GitHub API // 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 userData = response.data;
const actorType = userData.type; const actorType = userData.type;

View File

@@ -28,7 +28,10 @@ export async function checkWritePermissions(
core.info(`Checking permissions for actor: ${actor}`); core.info(`Checking permissions for actor: ${actor}`);
// Check permissions directly using the permission endpoint // 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; const permissionLevel = response.data.permission;
core.info(`Permission level retrieved: ${permissionLevel}`); 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. // For now, throw an error indicating this functionality is not available.
throw new Error( throw new Error(
"Multi-file commits are not supported with Gitea. " + "Multi-file commits are not supported with Gitea. " +
"Gitea does not provide the low-level git API operations (trees, commits) " + "Gitea does not provide the low-level git API operations (trees, commits) " +
"that are required for atomic multi-file commits. " + "that are required for atomic multi-file commits. " +
"Please commit files individually using the contents API." "Please commit files individually using the contents API.",
); );
return { return {
@@ -158,9 +158,9 @@ server.tool(
// For now, throw an error indicating this functionality is not available. // For now, throw an error indicating this functionality is not available.
throw new Error( throw new Error(
"Multi-file deletions are not supported with Gitea. " + "Multi-file deletions are not supported with Gitea. " +
"Gitea does not provide the low-level git API operations (trees, commits) " + "Gitea does not provide the low-level git API operations (trees, commits) " +
"that are required for atomic multi-file operations. " + "that are required for atomic multi-file operations. " +
"Please delete files individually using the contents API." "Please delete files individually using the contents API.",
); );
return { return {

View File

@@ -23,11 +23,11 @@ export async function prepareMcpConfig(
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken, GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
}, },
}, },
github_file_ops: { local_git_ops: {
command: "bun", command: "bun",
args: [ args: [
"run", "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: { env: {
GITHUB_TOKEN: githubToken, GITHUB_TOKEN: githubToken,
@@ -35,6 +35,8 @@ export async function prepareMcpConfig(
REPO_NAME: repo, REPO_NAME: repo,
BRANCH_NAME: branch, BRANCH_NAME: branch,
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(), 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);