mirror of
https://github.com/markwylde/claude-code-gitea-action.git
synced 2026-02-20 02:22:49 +08:00
Attempt to make this work
This commit is contained in:
38
README.md
38
README.md
@@ -70,11 +70,12 @@ 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 | - |
|
||||
| `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` |
|
||||
@@ -89,6 +90,41 @@ jobs:
|
||||
|
||||
> **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
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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}`, {
|
||||
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`,
|
||||
|
||||
@@ -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
|
||||
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`, {
|
||||
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}`, {
|
||||
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`, {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
// Create branch using Gitea's branch creation API
|
||||
console.log(`Creating branch: ${newBranch} from: ${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}`);
|
||||
// Use local git operations instead of API since Gitea's API is unreliable
|
||||
console.log(
|
||||
`Branch ${newBranch} will be created when files are pushed via MCP server`,
|
||||
`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 {
|
||||
// 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(
|
||||
`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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`, {
|
||||
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`);
|
||||
|
||||
@@ -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}`, {
|
||||
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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -87,7 +87,7 @@ server.tool(
|
||||
"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."
|
||||
"Please commit files individually using the contents API.",
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -160,7 +160,7 @@ server.tool(
|
||||
"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."
|
||||
"Please delete files individually using the contents API.",
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
298
src/mcp/local-git-ops-server.ts
Normal file
298
src/mcp/local-git-ops-server.ts
Normal 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);
|
||||
Reference in New Issue
Block a user