This commit is contained in:
Mark Wylde
2025-05-30 20:02:39 +01:00
parent 180a1b6680
commit fb6df649ed
33 changed files with 4709 additions and 901 deletions

View File

@@ -19,7 +19,7 @@ import {
} from "../github/context";
import type { ParsedGitHubContext } from "../github/context";
import type { CommonFields, PreparedContext, EventData } from "./types";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { GITEA_SERVER_URL } from "../github/api/config";
export type { CommonFields, PreparedContext } from "./types";
const BASE_ALLOWED_TOOLS = [
@@ -29,8 +29,11 @@ const BASE_ALLOWED_TOOLS = [
"LS",
"Read",
"Write",
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_files",
"mcp__local_git_ops__commit_files",
"mcp__local_git_ops__delete_files",
"mcp__local_git_ops__push_branch",
"mcp__local_git_ops__create_pull_request",
"mcp__local_git_ops__git_status",
];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
@@ -530,21 +533,23 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
${
eventData.isPR && !eventData.claudeBranch
? `
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes with this tool and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>" line in the commit message.`
- Commit changes using mcp__local_git_ops__commit_files to the existing branch (works for both new and existing files).
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
- When pushing changes with this tool and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.`
: `
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>" line in the commit message.
- Commit changes using mcp__local_git_ops__commit_files (works for both new and existing files)
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
- When pushing changes and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.
${
eventData.claudeBranch
? `- Provide a URL to create a PR manually in this format:
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
[Create a PR](${GITEA_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
- IMPORTANT: Use THREE dots (...) between branch names, not two (..)
Example: ${GITHUB_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
NOT: ${GITHUB_SERVER_URL}/${context.repository}/compare/main..feature-branch (incorrect)
Example: ${GITEA_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
NOT: ${GITEA_SERVER_URL}/${context.repository}/compare/main..feature-branch (incorrect)
- IMPORTANT: Ensure all URL parameters are properly encoded - spaces should be encoded as %20, not left as spaces
Example: Instead of "fix: update welcome message", use "fix%3A%20update%20welcome%20message"
- The target-branch should be '${eventData.baseBranch}'.
@@ -571,7 +576,7 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
- Always update the GitHub comment to reflect the current todo state.
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
- If you changed any files locally, you must update them in the remote branch via mcp__github_file_ops__commit_files before saying that you're done.
- If you changed any files locally, you must commit them using mcp__local_git_ops__commit_files AND push the branch using mcp__local_git_ops__push_branch before saying that you're done.
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
Important Notes:
@@ -579,12 +584,13 @@ Important Notes:
- Never create new comments. Only update the existing comment using ${eventData.eventName === "pull_request_review_comment" ? "mcp__github__update_pull_request_comment" : "mcp__github__update_issue_comment"} with comment_id: ${context.claudeCommentId}.
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github__update_issue_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
- You communicate exclusively by editing your single comment - not through any other means.
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
- Use this spinner HTML when work is in progress: <img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
- Use mcp__local_git_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__local_git_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
Tool usage examples:
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}
- mcp__local_git_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
- mcp__local_git_ops__push_branch: {"branch": "branch-name"} (REQUIRED after committing to push changes to remote)
- mcp__local_git_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
@@ -615,7 +621,7 @@ What You CANNOT Do:
- View CI/CD results or workflow run outputs (cannot access GitHub Actions logs or test results)
When users ask you to perform actions you cannot do, politely explain the limitation and, when applicable, direct them to the FAQ for more information and workarounds:
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/FAQ.md)."
"I'm unable to [specific action] due to [reason]. Please check the documentation for more information and potential workarounds."
If a user asks for something outside these capabilities (and you have no other tools provided), politely explain that you cannot perform that action and suggest an alternative approach if possible.

View File

@@ -15,7 +15,7 @@ import { setupBranch } from "../github/operations/branch";
import { updateTrackingComment } from "../github/operations/comments/update-with-branch";
import { prepareMcpConfig } from "../mcp/install-mcp-server";
import { createPrompt } from "../create-prompt";
import { createOctokit } from "../github/api/client";
import { createClient } from "../github/api/client";
import { fetchGitHubData } from "../github/data/fetcher";
import { parseGitHubContext } from "../github/context";
@@ -23,14 +23,14 @@ async function run() {
try {
// Step 1: Setup GitHub token
const githubToken = await setupGitHubToken();
const octokit = createOctokit(githubToken);
const client = createClient(githubToken);
// Step 2: Parse GitHub context (once for all operations)
const context = parseGitHubContext();
// Step 3: Check write permissions
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
client.api,
context,
);
if (!hasWritePermissions) {
@@ -42,32 +42,41 @@ async function run() {
// Step 4: Check trigger conditions
const containsTrigger = await checkTriggerAction(context);
// Set outputs that are always needed
core.setOutput("contains_trigger", containsTrigger.toString());
core.setOutput("GITHUB_TOKEN", githubToken);
if (!containsTrigger) {
console.log("No trigger found, skipping remaining steps");
return;
}
// Step 5: Check if actor is human
await checkHumanActor(octokit.rest, context);
await checkHumanActor(client.api, context);
// Step 6: Create initial tracking comment
const commentId = await createInitialComment(octokit.rest, context);
const commentId = await createInitialComment(client.api, context);
core.setOutput("claude_comment_id", commentId.toString());
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
const githubData = await fetchGitHubData({
octokits: octokit,
client: client,
repository: `${context.repository.owner}/${context.repository.repo}`,
prNumber: context.entityNumber.toString(),
isPR: context.isPR,
});
// Step 8: Setup branch
const branchInfo = await setupBranch(octokit, githubData, context);
const branchInfo = await setupBranch(client, githubData, context);
core.setOutput("BASE_BRANCH", branchInfo.baseBranch);
if (branchInfo.claudeBranch) {
core.setOutput("CLAUDE_BRANCH", branchInfo.claudeBranch);
}
// Step 9: Update initial comment with branch link (only for issues that created a new branch)
if (branchInfo.claudeBranch) {
await updateTrackingComment(
octokit,
client,
context,
commentId,
branchInfo.claudeBranch,

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { createOctokit } from "../github/api/client";
import { createClient } from "../github/api/client";
import * as fs from "fs/promises";
import {
updateCommentBody,
@@ -10,8 +10,14 @@ import {
parseGitHubContext,
isPullRequestReviewCommentEvent,
} from "../github/context";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { GITEA_SERVER_URL } from "../github/api/config";
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
import {
branchHasChanges,
fetchBranch,
branchExists,
remoteBranchExists,
} from "../github/utils/local-git";
async function run() {
try {
@@ -23,9 +29,9 @@ async function run() {
const context = parseGitHubContext();
const { owner, repo } = context.repository;
const octokit = createOctokit(githubToken);
const client = createClient(githubToken);
const serverUrl = GITHUB_SERVER_URL;
const serverUrl = GITEA_SERVER_URL;
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
let comment;
@@ -37,12 +43,11 @@ async function run() {
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments, use the pulls API
console.log(`Fetching PR review comment ${commentId}`);
const { data: prComment } = await octokit.rest.pulls.getReviewComment({
owner,
repo,
comment_id: commentId,
});
comment = prComment;
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");
}
@@ -50,12 +55,11 @@ async function run() {
// For all other event types, use the issues API
if (!comment) {
console.log(`Fetching issue comment ${commentId}`);
const { data: issueComment } = await octokit.rest.issues.getComment({
owner,
repo,
comment_id: commentId,
});
comment = issueComment;
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");
}
@@ -69,14 +73,14 @@ async function run() {
// Try to get the PR info to understand the comment structure
try {
const { data: pr } = await octokit.rest.pulls.get({
const pr = await client.api.getPullRequest(
owner,
repo,
pull_number: context.entityNumber,
});
console.log(`PR state: ${pr.state}`);
console.log(`PR comments count: ${pr.comments}`);
console.log(`PR review comments count: ${pr.review_comments}`);
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}`);
} catch {
console.error("Could not fetch PR info for debugging");
}
@@ -88,7 +92,7 @@ async function run() {
// Check if we need to add branch link for new branches
const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch(
octokit,
client,
owner,
repo,
claudeBranch,
@@ -107,20 +111,79 @@ async function run() {
const containsPRUrl = currentBody.match(prUrlPattern);
if (!containsPRUrl) {
// Check if there are changes to the branch compared to the default branch
try {
const { data: comparison } =
await octokit.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseBranch}...${claudeBranch}`,
});
// Check if we're using Gitea or GitHub
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
const isGitea =
giteaApiUrl &&
giteaApiUrl !== "" &&
!giteaApiUrl.includes("api.github.com") &&
!giteaApiUrl.includes("github.com");
// If there are changes (commits or file changes), add the PR URL
if (
comparison.total_commits > 0 ||
(comparison.files && comparison.files.length > 0)
) {
if (isGitea) {
// Use local git commands for Gitea
console.log(
"Using local git commands for PR link check (Gitea mode)",
);
try {
// Fetch latest changes from remote
await fetchBranch(claudeBranch);
await fetchBranch(baseBranch);
// Check if branch exists and has changes
const { hasChanges, branchSha, baseSha } = await branchHasChanges(
claudeBranch,
baseBranch,
);
if (branchSha && baseSha) {
if (hasChanges) {
console.log(
`Branch ${claudeBranch} appears to have changes (different SHA from base)`,
);
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
);
const prBody = encodeURIComponent(
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
);
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, no PR link needed`,
);
}
} else {
// If we can't get SHAs, check if branch exists at all
const localExists = await branchExists(claudeBranch);
const remoteExists = await remoteBranchExists(claudeBranch);
if (localExists || remoteExists) {
console.log(
`Branch ${claudeBranch} exists but SHA comparison failed, adding PR link to be safe`,
);
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
);
const prBody = encodeURIComponent(
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
);
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`;
} else {
console.log(
`Branch ${claudeBranch} does not exist yet - no PR link needed`,
);
prLink = "";
}
}
} catch (error: any) {
console.error("Error checking branch with git commands:", error);
// For errors, add PR link to be safe
console.log("Adding PR link as fallback due to git command error");
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
@@ -131,9 +194,71 @@ async function run() {
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`;
}
} catch (error) {
console.error("Error checking for changes in branch:", error);
// Don't fail the entire update if we can't check for changes
} else {
// Use API calls for GitHub
console.log("Using API calls for PR link check (GitHub mode)");
try {
// Get the branch info to see if it exists and has commits
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 branchSha = branchResponse.data.commit.sha;
const baseSha = baseResponse.data.commit.sha;
// If SHAs are different, assume there are changes and add PR link
if (branchSha !== baseSha) {
console.log(
`Branch ${claudeBranch} appears to have changes (different SHA from base)`,
);
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
);
const prBody = encodeURIComponent(
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
);
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, no PR link needed`,
);
}
} catch (error: any) {
console.error("Error checking branch:", error);
// Handle 404 specifically - branch doesn't exist
if (error.status === 404) {
console.log(
`Branch ${claudeBranch} does not exist yet - no PR link needed`,
);
// Don't add PR link since branch doesn't exist
prLink = "";
} else {
// For other errors, add PR link to be safe
console.log("Adding PR link as fallback due to non-404 error");
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
);
const prBody = encodeURIComponent(
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
);
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`;
}
}
}
}
}
@@ -207,19 +332,20 @@ async function run() {
// Update the comment using the appropriate API
try {
if (isPRReviewComment) {
await octokit.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
await client.api.customRequest(
"PATCH",
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{
body: updatedBody,
},
);
} else {
await octokit.rest.issues.updateComment({
await client.api.updateIssueComment(
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
commentId,
updatedBody,
);
}
console.log(
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,

View File

@@ -1,20 +1,17 @@
import { Octokit } from "@octokit/rest";
import { graphql } from "@octokit/graphql";
import { GITHUB_API_URL } from "./config";
import { GiteaApiClient, createGiteaClient } from "./gitea-client";
export type Octokits = {
rest: Octokit;
graphql: typeof graphql;
export type GitHubClient = {
api: GiteaApiClient;
};
export function createOctokit(token: string): Octokits {
export function createClient(token: string): GitHubClient {
// Use the GITEA_API_URL environment variable if provided
const apiUrl = process.env.GITEA_API_URL;
console.log(
`Creating client with API URL: ${apiUrl || "default (https://api.github.com)"}`,
);
return {
rest: new Octokit({ auth: token }),
graphql: graphql.defaults({
baseUrl: GITHUB_API_URL,
headers: {
authorization: `token ${token}`,
},
}),
api: apiUrl ? new GiteaApiClient(token, apiUrl) : createGiteaClient(token),
};
}

View File

@@ -1,4 +1,14 @@
export const GITHUB_API_URL =
process.env.GITHUB_API_URL || "https://api.github.com";
export const GITHUB_SERVER_URL =
process.env.GITHUB_SERVER_URL || "https://github.com";
export const GITEA_API_URL =
process.env.GITEA_API_URL || "https://api.github.com";
// Derive server URL from API URL for Gitea instances
function deriveServerUrl(apiUrl: string): string {
if (apiUrl.includes("api.github.com")) {
return "https://github.com";
}
// For Gitea, remove /api/v1 from the API URL to get the server URL
return apiUrl.replace(/\/api\/v1\/?$/, "");
}
export const GITEA_SERVER_URL =
process.env.GITEA_SERVER_URL || deriveServerUrl(GITEA_API_URL);

View File

@@ -0,0 +1,318 @@
import fetch from "node-fetch";
import { GITEA_API_URL } from "./config";
export interface GiteaApiResponse<T = any> {
status: number;
data: T;
headers: Record<string, string>;
}
export interface GiteaApiError extends Error {
status: number;
response?: {
data: any;
status: number;
headers: Record<string, string>;
};
}
export class GiteaApiClient {
private baseUrl: string;
private token: string;
constructor(token: string, baseUrl: string = GITEA_API_URL) {
this.token = token;
this.baseUrl = baseUrl.replace(/\/+$/, ""); // Remove trailing slashes
}
getBaseUrl(): string {
return this.baseUrl;
}
private async request<T = any>(
method: string,
endpoint: string,
body?: any,
): Promise<GiteaApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
console.log(`Making ${method} request to: ${url}`);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `token ${this.token}`,
};
const options: any = {
method,
headers,
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
options.body = JSON.stringify(body);
}
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 {
responseData = await response.json();
} catch (parseError) {
console.warn(`Failed to parse JSON response: ${parseError}`);
responseData = await response.text();
}
} else {
responseData = await response.text();
}
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;
error.status = response.status;
error.response = {
data: responseData,
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
};
throw error;
}
return {
status: response.status,
data: responseData as T,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) {
if (error instanceof Error && "status" in error) {
throw error;
}
throw new Error(`Request failed: ${error}`);
}
}
// Repository operations
async getRepo(owner: string, repo: string) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}`);
}
// Simple test endpoint to verify API connectivity
async testConnection() {
return this.request("GET", "/api/v1/version");
}
async getBranch(owner: string, repo: string, branch: string) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
);
}
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,
});
}
async listBranches(owner: string, repo: string) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/branches`);
}
// Issue operations
async getIssue(owner: string, repo: string, issueNumber: number) {
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`,
);
}
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,
},
);
}
// Pull request operations
async getPullRequest(owner: string, repo: string, prNumber: number) {
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`,
);
}
async listPullRequestComments(owner: string, repo: string, prNumber: number) {
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,
},
);
}
// File operations
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)}`;
}
return this.request("GET", endpoint);
}
async createFile(
owner: string,
repo: string,
path: string,
content: string,
message: 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,
);
}
async updateFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
sha: 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,
);
}
async deleteFile(
owner: string,
repo: string,
path: string,
message: string,
sha: 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,
);
}
// Generic request method for other operations
async customRequest<T = any>(
method: string,
endpoint: string,
body?: any,
): Promise<GiteaApiResponse<T>> {
return this.request<T>(method, endpoint, body);
}
}
export function createGiteaClient(token: string): GiteaApiClient {
return new GiteaApiClient(token);
}

View File

@@ -5,16 +5,13 @@ import type {
GitHubComment,
GitHubFile,
GitHubReview,
PullRequestQueryResponse,
IssueQueryResponse,
} from "../types";
import { PR_QUERY, ISSUE_QUERY } from "../api/queries/github";
import type { Octokits } from "../api/client";
import type { GitHubClient } from "../api/client";
import { downloadCommentImages } from "../utils/image-downloader";
import type { CommentWithImages } from "../utils/image-downloader";
type FetchDataParams = {
octokits: Octokits;
client: GitHubClient;
repository: string;
prNumber: string;
isPR: boolean;
@@ -34,7 +31,7 @@ export type FetchDataResult = {
};
export async function fetchGitHubData({
octokits,
client,
repository,
prNumber,
isPR,
@@ -50,46 +47,104 @@ export async function fetchGitHubData({
let reviewData: { nodes: GitHubReview[] } | null = null;
try {
// Use REST API for all requests (works with both GitHub and Gitea)
if (isPR) {
// Fetch PR data with all comments and file information
const prResult = await octokits.graphql<PullRequestQueryResponse>(
PR_QUERY,
{
owner,
repo,
number: parseInt(prNumber),
},
console.log(`Fetching PR #${prNumber} data using REST API`);
const prResponse = await client.api.getPullRequest(
owner,
repo,
parseInt(prNumber),
);
if (prResult.repository.pullRequest) {
const pullRequest = prResult.repository.pullRequest;
contextData = pullRequest;
changedFiles = pullRequest.files.nodes || [];
comments = pullRequest.comments?.nodes || [];
reviewData = pullRequest.reviews || [];
contextData = {
title: prResponse.data.title,
body: prResponse.data.body || "",
author: { login: prResponse.data.user?.login || "" },
baseRefName: prResponse.data.base.ref,
headRefName: prResponse.data.head.ref,
headRefOid: prResponse.data.head.sha,
createdAt: prResponse.data.created_at,
additions: prResponse.data.additions || 0,
deletions: prResponse.data.deletions || 0,
state: prResponse.data.state.toUpperCase(),
commits: { totalCount: 0, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
};
console.log(`Successfully fetched PR #${prNumber} data`);
} else {
throw new Error(`PR #${prNumber} not found`);
// Fetch comments separately
try {
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
databaseId: comment.id.toString(),
body: comment.body || "",
author: { login: comment.user?.login || "" },
createdAt: comment.created_at,
}));
} catch (error) {
console.warn("Failed to fetch PR comments:", error);
comments = []; // Ensure we have an empty array
}
} else {
// Fetch issue data
const issueResult = await octokits.graphql<IssueQueryResponse>(
ISSUE_QUERY,
{
// Try to fetch files
try {
const filesResponse = await client.api.listPullRequestFiles(
owner,
repo,
number: parseInt(prNumber),
},
parseInt(prNumber),
);
changedFiles = filesResponse.data.map((file: any) => ({
path: file.filename,
additions: file.additions || 0,
deletions: file.deletions || 0,
changeType: file.status || "modified",
}));
} catch (error) {
console.warn("Failed to fetch PR files:", error);
changedFiles = []; // Ensure we have an empty array
}
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),
);
if (issueResult.repository.issue) {
contextData = issueResult.repository.issue;
comments = contextData?.comments?.nodes || [];
contextData = {
title: issueResponse.data.title,
body: issueResponse.data.body || "",
author: { login: issueResponse.data.user?.login || "" },
createdAt: issueResponse.data.created_at,
state: issueResponse.data.state.toUpperCase(),
comments: { nodes: [] },
};
console.log(`Successfully fetched issue #${prNumber} data`);
} else {
throw new Error(`Issue #${prNumber} not found`);
// Fetch comments
try {
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
databaseId: comment.id.toString(),
body: comment.body || "",
author: { login: comment.user?.login || "" },
createdAt: comment.created_at,
}));
} catch (error) {
console.warn("Failed to fetch issue comments:", error);
comments = []; // Ensure we have an empty array
}
}
} catch (error) {
@@ -177,7 +232,7 @@ export async function fetchGitHubData({
];
const imageUrlMap = await downloadCommentImages(
octokits,
client,
owner,
repo,
allComments,

View File

@@ -1,8 +1,14 @@
import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config";
import type { GitHubClient } from "../api/client";
import { GITEA_SERVER_URL } from "../api/config";
import {
branchHasChanges,
fetchBranch,
branchExists,
remoteBranchExists,
} from "../utils/local-git";
export async function checkAndDeleteEmptyBranch(
octokit: Octokits,
client: GitHubClient,
owner: string,
repo: string,
claudeBranch: string | undefined,
@@ -12,47 +18,128 @@ export async function checkAndDeleteEmptyBranch(
let shouldDeleteBranch = false;
if (claudeBranch) {
// Check if Claude made any commits to the branch
try {
const { data: comparison } =
await octokit.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseBranch}...${claudeBranch}`,
});
// Check if we're using Gitea or GitHub
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
const isGitea =
giteaApiUrl &&
giteaApiUrl !== "" &&
!giteaApiUrl.includes("api.github.com") &&
!giteaApiUrl.includes("github.com");
// If there are no commits, mark branch for deletion
if (comparison.total_commits === 0) {
console.log(
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
if (isGitea) {
// Use local git operations for Gitea
console.log("Using local git commands for branch check (Gitea mode)");
try {
// Fetch latest changes from remote
await fetchBranch(claudeBranch);
await fetchBranch(baseBranch);
// Check if branch exists and has changes
const { hasChanges, branchSha, baseSha } = await branchHasChanges(
claudeBranch,
baseBranch,
);
shouldDeleteBranch = true;
} else {
// Only add branch link if there are commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
if (branchSha && baseSha) {
if (hasChanges) {
console.log(
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
);
shouldDeleteBranch = true;
}
} else {
// If we can't get SHAs, check if branch exists at all
const localExists = await branchExists(claudeBranch);
const remoteExists = await remoteBranchExists(claudeBranch);
if (localExists || remoteExists) {
console.log(
`Branch ${claudeBranch} exists but SHA comparison failed, assuming it has commits`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
);
branchLink = "";
}
}
} catch (error: any) {
console.error("Error checking branch with git commands:", error);
// For errors, assume the branch has commits to be safe
console.log("Assuming branch exists due to git command error");
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}
} catch (error) {
console.error("Error checking for commits on Claude branch:", error);
// If we can't check, assume the branch has commits to be safe
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
// Use API calls for GitHub
console.log("Using API calls for branch check (GitHub mode)");
try {
// Get the branch info to see if it exists and has commits
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 branchSha = branchResponse.data.commit.sha;
const baseSha = baseResponse.data.commit.sha;
// If SHAs are different, assume there are commits
if (branchSha !== baseSha) {
console.log(
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
);
shouldDeleteBranch = true;
}
} catch (error: any) {
console.error("Error checking branch:", error);
// Handle 404 specifically - branch doesn't exist
if (error.status === 404) {
console.log(
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
);
// Don't add branch link since branch doesn't exist
branchLink = "";
} else {
// For other errors, assume the branch has commits to be safe
console.log("Assuming branch exists due to non-404 error");
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}
}
}
}
// Delete the branch if it has no commits
if (shouldDeleteBranch && claudeBranch) {
try {
await octokit.rest.git.deleteRef({
owner,
repo,
ref: `heads/${claudeBranch}`,
});
console.log(`✅ Deleted empty branch: ${claudeBranch}`);
} catch (deleteError) {
console.error(`Failed to delete branch ${claudeBranch}:`, deleteError);
// Continue even if deletion fails
}
console.log(
`Skipping branch deletion - not reliably supported across all Git platforms: ${claudeBranch}`,
);
// Skip deletion to avoid compatibility issues
}
return { shouldDeleteBranch, branchLink };

View File

@@ -10,7 +10,7 @@ import { $ } from "bun";
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client";
import type { GitHubClient } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
export type BranchInfo = {
@@ -20,7 +20,7 @@ export type BranchInfo = {
};
export async function setupBranch(
octokits: Octokits,
client: GitHubClient,
githubData: FetchDataResult,
context: ParsedGitHubContext,
): Promise<BranchInfo> {
@@ -70,10 +70,7 @@ export async function setupBranch(
sourceBranch = baseBranch;
} else {
// No base branch provided, fetch the default branch to use as source
const repoResponse = await octokits.rest.repos.get({
owner,
repo,
});
const repoResponse = await client.api.getRepo(owner, repo);
sourceBranch = repoResponse.data.default_branch;
}
@@ -93,33 +90,62 @@ export async function setupBranch(
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
try {
// Get the SHA of the source branch
const sourceBranchRef = await octokits.rest.git.getRef({
owner,
repo,
ref: `heads/${sourceBranch}`,
});
const currentSHA = sourceBranchRef.data.object.sha;
console.log(`Current SHA: ${currentSHA}`);
// Create branch using GitHub API
await octokits.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${newBranch}`,
sha: currentSHA,
});
// Checkout the new branch (shallow fetch for performance)
await $`git fetch origin --depth=1 ${newBranch}`;
await $`git checkout ${newBranch}`;
// Use local git operations instead of API since Gitea's API is unreliable
console.log(
`Successfully created and checked out new branch: ${newBranch}`,
`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 {
// Check if we're in a git repository
console.log(`Checking if we're in a git repository...`);
await $`git status`;
// Ensure we have the latest version of the source branch
console.log(`Fetching latest ${sourceBranch}...`);
await $`git fetch origin ${sourceBranch}`;
// Checkout the source branch
console.log(`Checking out ${sourceBranch}...`);
await $`git checkout ${sourceBranch}`;
// Pull latest changes
console.log(`Pulling latest changes for ${sourceBranch}...`);
await $`git pull origin ${sourceBranch}`;
// Create and checkout the new branch
console.log(`Creating new branch: ${newBranch}`);
await $`git checkout -b ${newBranch}`;
// Verify the branch was created
const currentBranch = await $`git branch --show-current`;
const branchName = currentBranch.text().trim();
console.log(`Current branch after creation: ${branchName}`);
if (branchName === newBranch) {
console.log(
`✅ Successfully created and checked out branch: ${newBranch}`,
);
} else {
throw new Error(
`Branch creation failed. Expected ${newBranch}, got ${branchName}`,
);
}
} catch (gitError: any) {
console.error(`❌ Git operations failed:`, gitError);
console.error(`Error message: ${gitError.message || gitError}`);
// This is a critical failure - the branch MUST be created for Claude to work
throw new Error(
`Failed to create branch ${newBranch}: ${gitError.message || gitError}`,
);
}
console.log(`Branch setup completed for: ${newBranch}`);
// Set outputs for GitHub Actions
core.setOutput("CLAUDE_BRANCH", newBranch);
core.setOutput("BASE_BRANCH", sourceBranch);
@@ -129,7 +155,7 @@ export async function setupBranch(
currentBranch: newBranch,
};
} catch (error) {
console.error("Error creating branch:", error);
console.error("Error setting up branch:", error);
process.exit(1);
}
}

View File

@@ -1,4 +1,4 @@
import { GITHUB_SERVER_URL } from "../api/config";
import { GITEA_SERVER_URL } from "../api/config";
export type ExecutionDetails = {
cost_usd?: number;
@@ -160,7 +160,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
// Extract owner/repo from jobUrl
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
if (repoMatch) {
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
branchUrl = `${GITEA_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/src/branch/${finalBranchName}`;
}
}

View File

@@ -1,14 +1,19 @@
import { GITHUB_SERVER_URL } from "../../api/config";
import { GITEA_SERVER_URL } from "../../api/config";
import { readFileSync } from "fs";
import { join } from "path";
export const SPINNER_HTML =
'<img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />';
function getSpinnerHtml(): string {
return `<img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
}
export const SPINNER_HTML = getSpinnerHtml();
export function createJobRunLink(
owner: string,
repo: string,
runId: string,
): string {
const jobRunUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
const jobRunUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
return `[View job run](${jobRunUrl})`;
}
@@ -17,7 +22,7 @@ export function createBranchLink(
repo: string,
branchName: string,
): string {
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`;
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${branchName}/`;
return `\n[View branch](${branchUrl})`;
}

View File

@@ -11,10 +11,10 @@ import {
isPullRequestReviewCommentEvent,
type ParsedGitHubContext,
} from "../../context";
import type { Octokit } from "@octokit/rest";
import type { GiteaApiClient } from "../../api/gitea-client";
export async function createInitialComment(
octokit: Octokit,
api: GiteaApiClient,
context: ParsedGitHubContext,
) {
const { owner, repo } = context.repository;
@@ -25,23 +25,30 @@ export async function createInitialComment(
try {
let response;
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)) {
response = await octokit.rest.pulls.createReplyForReviewComment({
owner,
repo,
pull_number: context.entityNumber,
comment_id: context.payload.comment.id,
body: initialBody,
});
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,
},
);
} else {
// For all other cases (issues, issue comments, or missing comment_id)
response = await octokit.rest.issues.createComment({
console.log(`Creating issue comment via API`);
response = await api.createIssueComment(
owner,
repo,
issue_number: context.entityNumber,
body: initialBody,
});
context.entityNumber,
initialBody,
);
}
// Output the comment ID for downstream steps using GITHUB_OUTPUT
@@ -54,12 +61,12 @@ export async function createInitialComment(
// Always fall back to regular issue comment if anything fails
try {
const response = await octokit.rest.issues.createComment({
const response = await api.createIssueComment(
owner,
repo,
issue_number: context.entityNumber,
body: initialBody,
});
context.entityNumber,
initialBody,
);
const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);

View File

@@ -10,14 +10,14 @@ import {
createBranchLink,
createCommentBody,
} from "./common";
import { type Octokits } from "../../api/client";
import { type GitHubClient } from "../../api/client";
import {
isPullRequestReviewCommentEvent,
type ParsedGitHubContext,
} from "../../context";
export async function updateTrackingComment(
octokit: Octokits,
client: GitHubClient,
context: ParsedGitHubContext,
commentId: number,
branch?: string,
@@ -38,21 +38,17 @@ export async function updateTrackingComment(
try {
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments (inline comments), use the pulls API
await octokit.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: 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
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
await client.api.updateIssueComment(owner, repo, commentId, updatedBody);
console.log(`✅ Updated issue comment ${commentId} with branch link`);
}
} catch (error) {

View File

@@ -2,96 +2,6 @@
import * as core from "@actions/core";
type RetryOptions = {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
};
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 5000,
maxDelayMs = 20000,
backoffFactor = 2,
} = options;
let delayMs = initialDelayMs;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed:`, lastError.message);
if (attempt < maxAttempts) {
console.log(`Retrying in ${delayMs / 1000} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
}
}
}
console.error(`Operation failed after ${maxAttempts} attempts`);
throw lastError;
}
async function getOidcToken(): Promise<string> {
try {
const oidcToken = await core.getIDToken("claude-code-github-action");
return oidcToken;
} catch (error) {
console.error("Failed to get OIDC token:", error);
throw new Error(
"Could not fetch an OIDC token. Did you remember to add `id-token: write` to your workflow permissions?",
);
}
}
async function exchangeForAppToken(oidcToken: string): Promise<string> {
const response = await fetch(
"https://api.anthropic.com/api/github/github-app-token-exchange",
{
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
},
},
);
if (!response.ok) {
const responseJson = (await response.json()) as {
error?: {
message?: string;
};
};
console.error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
);
throw new Error(`${responseJson?.error?.message ?? "Unknown error"}`);
}
const appTokenData = (await response.json()) as {
token?: string;
app_token?: string;
};
const appToken = appTokenData.token || appTokenData.app_token;
if (!appToken) {
throw new Error("App token not found in response");
}
return appToken;
}
export async function setupGitHubToken(): Promise<string> {
try {
// Check if GitHub token was provided as override
@@ -103,22 +13,21 @@ export async function setupGitHubToken(): Promise<string> {
return providedToken;
}
console.log("Requesting OIDC token...");
const oidcToken = await retryWithBackoff(() => getOidcToken());
console.log("OIDC token successfully obtained");
// Use the standard GITHUB_TOKEN from the workflow environment
const workflowToken = process.env.GITHUB_TOKEN;
console.log("Exchanging OIDC token for app token...");
const appToken = await retryWithBackoff(() =>
exchangeForAppToken(oidcToken),
if (workflowToken) {
console.log("Using workflow GITHUB_TOKEN for authentication");
core.setOutput("GITHUB_TOKEN", workflowToken);
return workflowToken;
}
throw new Error(
"No GitHub token available. Please provide a github_token input or ensure GITHUB_TOKEN is available in the workflow environment.",
);
console.log("App token successfully obtained");
console.log("Using GITHUB_TOKEN from OIDC");
core.setOutput("GITHUB_TOKEN", appToken);
return appToken;
} catch (error) {
core.setFailed(
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`github_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
);
process.exit(1);
}

View File

@@ -1,12 +1,4 @@
import fs from "fs/promises";
import path from "path";
import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config";
const IMAGE_REGEX = new RegExp(
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
"g",
);
import type { GitHubClient } from "../api/client";
type IssueComment = {
type: "issue_comment";
@@ -47,186 +39,15 @@ export type CommentWithImages =
| PullRequestBody;
export async function downloadCommentImages(
octokits: Octokits,
owner: string,
repo: string,
comments: CommentWithImages[],
_client: GitHubClient,
_owner: string,
_repo: string,
_comments: CommentWithImages[],
): Promise<Map<string, string>> {
const urlToPathMap = new Map<string, string>();
const downloadsDir = "/tmp/github-images";
await fs.mkdir(downloadsDir, { recursive: true });
const commentsWithImages: Array<{
comment: CommentWithImages;
urls: string[];
}> = [];
for (const comment of comments) {
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
const urls = imageMatches.map((match) => match[1] as string);
if (urls.length > 0) {
commentsWithImages.push({ comment, urls });
const id =
comment.type === "issue_body"
? comment.issueNumber
: comment.type === "pr_body"
? comment.pullNumber
: comment.id;
console.log(`Found ${urls.length} image(s) in ${comment.type} ${id}`);
}
}
// Process each comment with images
for (const { comment, urls } of commentsWithImages) {
try {
let bodyHtml: string | undefined;
// Get the HTML version based on comment type
switch (comment.type) {
case "issue_comment": {
const response = await octokits.rest.issues.getComment({
owner,
repo,
comment_id: parseInt(comment.id),
mediaType: {
format: "full+json",
},
});
bodyHtml = response.data.body_html;
break;
}
case "review_comment": {
const response = await octokits.rest.pulls.getReviewComment({
owner,
repo,
comment_id: parseInt(comment.id),
mediaType: {
format: "full+json",
},
});
bodyHtml = response.data.body_html;
break;
}
case "review_body": {
const response = await octokits.rest.pulls.getReview({
owner,
repo,
pull_number: parseInt(comment.pullNumber),
review_id: parseInt(comment.id),
mediaType: {
format: "full+json",
},
});
bodyHtml = response.data.body_html;
break;
}
case "issue_body": {
const response = await octokits.rest.issues.get({
owner,
repo,
issue_number: parseInt(comment.issueNumber),
mediaType: {
format: "full+json",
},
});
bodyHtml = response.data.body_html;
break;
}
case "pr_body": {
const response = await octokits.rest.pulls.get({
owner,
repo,
pull_number: parseInt(comment.pullNumber),
mediaType: {
format: "full+json",
},
});
// Type here seems to be wrong
bodyHtml = (response.data as any).body_html;
break;
}
}
if (!bodyHtml) {
const id =
comment.type === "issue_body"
? comment.issueNumber
: comment.type === "pr_body"
? comment.pullNumber
: comment.id;
console.warn(`No HTML body found for ${comment.type} ${id}`);
continue;
}
// Extract signed URLs from HTML
const signedUrlRegex =
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
const signedUrls = bodyHtml.match(signedUrlRegex) || [];
// Download each image
for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) {
const signedUrl = signedUrls[i];
const originalUrl = urls[i];
if (!signedUrl || !originalUrl) {
continue;
}
// Check if we've already downloaded this URL
if (urlToPathMap.has(originalUrl)) {
continue;
}
const fileExtension = getImageExtension(originalUrl);
const filename = `image-${Date.now()}-${i}${fileExtension}`;
const localPath = path.join(downloadsDir, filename);
try {
console.log(`Downloading ${originalUrl}...`);
const imageResponse = await fetch(signedUrl);
if (!imageResponse.ok) {
throw new Error(
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
);
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.writeFile(localPath, buffer);
console.log(`✓ Saved: ${localPath}`);
urlToPathMap.set(originalUrl, localPath);
} catch (error) {
console.error(`✗ Failed to download ${originalUrl}:`, error);
}
}
} catch (error) {
const id =
comment.type === "issue_body"
? comment.issueNumber
: comment.type === "pr_body"
? comment.pullNumber
: comment.id;
console.error(
`Failed to process images for ${comment.type} ${id}:`,
error,
);
}
}
return urlToPathMap;
}
function getImageExtension(url: string): string {
const urlParts = url.split("/");
const filename = urlParts[urlParts.length - 1];
if (!filename) {
throw new Error("Invalid URL: No filename found");
}
const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i);
return match ? match[0] : ".png";
// 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",
);
return new Map<string, string>();
}

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bun
import { $ } from "bun";
/**
* Check if a branch exists locally using git commands
*/
export async function branchExists(branchName: string): Promise<boolean> {
try {
await $`git show-ref --verify --quiet refs/heads/${branchName}`;
return true;
} catch {
return false;
}
}
/**
* Check if a remote branch exists using git commands
*/
export async function remoteBranchExists(branchName: string): Promise<boolean> {
try {
await $`git show-ref --verify --quiet refs/remotes/origin/${branchName}`;
return true;
} catch {
return false;
}
}
/**
* Get the SHA of a branch using git commands
*/
export async function getBranchSha(branchName: string): Promise<string | null> {
try {
// Try local branch first
if (await branchExists(branchName)) {
const result = await $`git rev-parse refs/heads/${branchName}`;
return result.text().trim();
}
// Try remote branch if local doesn't exist
if (await remoteBranchExists(branchName)) {
const result = await $`git rev-parse refs/remotes/origin/${branchName}`;
return result.text().trim();
}
return null;
} catch (error) {
console.error(`Error getting SHA for branch ${branchName}:`, error);
return null;
}
}
/**
* Check if a branch has commits different from base branch
*/
export async function branchHasChanges(
branchName: string,
baseBranch: string,
): Promise<{
hasChanges: boolean;
branchSha: string | null;
baseSha: string | null;
}> {
try {
const branchSha = await getBranchSha(branchName);
const baseSha = await getBranchSha(baseBranch);
if (!branchSha || !baseSha) {
return { hasChanges: false, branchSha, baseSha };
}
const hasChanges = branchSha !== baseSha;
return { hasChanges, branchSha, baseSha };
} catch (error) {
console.error(
`Error comparing branches ${branchName} and ${baseBranch}:`,
error,
);
return { hasChanges: false, branchSha: null, baseSha: null };
}
}
/**
* Fetch latest changes from remote to ensure we have up-to-date branch info
*/
export async function fetchBranch(branchName: string): Promise<boolean> {
try {
await $`git fetch origin ${branchName}`;
return true;
} catch (error) {
console.log(
`Could not fetch branch ${branchName} from remote (may not exist yet)`,
);
return false;
}
}

View File

@@ -5,27 +5,53 @@
* Prevents automated tools or bots from triggering Claude
*/
import type { Octokit } from "@octokit/rest";
import type { GiteaApiClient } from "../api/gitea-client";
import type { ParsedGitHubContext } from "../context";
export async function checkHumanActor(
octokit: Octokit,
api: GiteaApiClient,
githubContext: ParsedGitHubContext,
) {
// Fetch user information from GitHub API
const { data: userData } = await octokit.users.getByUsername({
username: githubContext.actor,
});
// Check if we're in a Gitea environment
const isGitea =
process.env.GITEA_API_URL &&
!process.env.GITEA_API_URL.includes("api.github.com");
const actorType = userData.type;
console.log(`Actor type: ${actorType}`);
if (actorType !== "User") {
throw new Error(
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
if (isGitea) {
console.log(
`Detected Gitea environment, skipping actor type validation for: ${githubContext.actor}`,
);
return;
}
console.log(`Verified human actor: ${githubContext.actor}`);
try {
// Fetch user information from GitHub API
const response = await api.customRequest(
"GET",
`/api/v1/users/${githubContext.actor}`,
);
const userData = response.data;
const actorType = userData.type;
console.log(`Actor type: ${actorType}`);
if (actorType !== "User") {
throw new Error(
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
);
}
console.log(`Verified human actor: ${githubContext.actor}`);
} catch (error) {
console.warn(
`Failed to check actor type for ${githubContext.actor}:`,
error,
);
// For compatibility, assume human actor if API call fails
console.log(
`Assuming human actor due to API failure: ${githubContext.actor}`,
);
}
}

View File

@@ -1,28 +1,71 @@
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context";
import type { Octokit } from "@octokit/rest";
import type { GiteaApiClient } from "../api/gitea-client";
/**
* Check if the actor has write permissions to the repository
* @param octokit - The Octokit REST client
* @param api - The Gitea API client
* @param context - The GitHub context
* @returns true if the actor has write permissions, false otherwise
*/
export async function checkWritePermissions(
octokit: Octokit,
api: GiteaApiClient,
context: ParsedGitHubContext,
): Promise<boolean> {
const { repository, actor } = context;
try {
core.info(`Checking permissions for actor: ${actor}`);
core.info(
`Environment check - GITEA_API_URL: ${process.env.GITEA_API_URL || "undefined"}`,
);
core.info(`API client base URL: ${api.getBaseUrl?.() || "undefined"}`);
// For Gitea compatibility, check if we're in a non-GitHub environment
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
const isGitea =
giteaApiUrl &&
giteaApiUrl !== "" &&
!giteaApiUrl.includes("api.github.com") &&
!giteaApiUrl.includes("github.com");
if (isGitea) {
core.info(
`Detected Gitea environment (${giteaApiUrl}), assuming actor has permissions`,
);
return true;
}
// Also check if the API client base URL suggests we're using Gitea
const apiUrl = api.getBaseUrl?.() || "";
if (
apiUrl &&
!apiUrl.includes("api.github.com") &&
!apiUrl.includes("github.com")
) {
core.info(
`Detected non-GitHub API URL (${apiUrl}), assuming actor has permissions`,
);
return true;
}
// If we're still here, we might be using GitHub's API, so attempt the permissions check
core.info(
`Proceeding with GitHub-style permission check for actor: ${actor}`,
);
// However, if the API client is clearly pointing to a non-GitHub URL, skip the check
if (apiUrl && apiUrl !== "https://api.github.com") {
core.info(
`API URL ${apiUrl} doesn't look like GitHub, assuming permissions and skipping check`,
);
return true;
}
try {
// Check permissions directly using the permission endpoint
const response = await octokit.repos.getCollaboratorPermissionLevel({
owner: repository.owner,
repo: repository.repo,
username: actor,
});
const response = await api.customRequest(
"GET",
`/api/v1/repos/${repository.owner}/${repository.repo}/collaborators/${actor}/permission`,
);
const permissionLevel = response.data.permission;
core.info(`Permission level retrieved: ${permissionLevel}`);

View File

@@ -15,6 +15,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
} = context;
console.log(
`Checking trigger: event=${context.eventName}, action=${context.eventAction}, phrase='${triggerPhrase}', assignee='${assigneeTrigger}', direct='${directPrompt}'`,
);
// If direct prompt is provided, always trigger
if (directPrompt) {
console.log(`Direct prompt provided, triggering action`);
@@ -24,9 +28,13 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
// Check for assignee trigger
if (isIssuesEvent(context) && context.eventAction === "assigned") {
// Remove @ symbol from assignee_trigger if present
let triggerUser = assigneeTrigger.replace(/^@/, "");
let triggerUser = assigneeTrigger?.replace(/^@/, "") || "";
const assigneeUsername = context.payload.issue.assignee?.login || "";
console.log(
`Checking assignee trigger: user='${triggerUser}', assignee='${assigneeUsername}'`,
);
if (triggerUser && assigneeUsername === triggerUser) {
console.log(`Issue assigned to trigger user '${triggerUser}'`);
return true;

777
src/mcp/gitea-mcp-server.ts Normal file
View File

@@ -0,0 +1,777 @@
#!/usr/bin/env node
// Gitea API Operations MCP Server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fetch from "node-fetch";
// Get configuration 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 GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITEA_API_URL = process.env.GITEA_API_URL || "https://api.github.com";
console.log(`[GITEA-MCP] Starting Gitea API Operations MCP Server`);
console.log(`[GITEA-MCP] REPO_OWNER: ${REPO_OWNER}`);
console.log(`[GITEA-MCP] REPO_NAME: ${REPO_NAME}`);
console.log(`[GITEA-MCP] BRANCH_NAME: ${BRANCH_NAME}`);
console.log(`[GITEA-MCP] GITEA_API_URL: ${GITEA_API_URL}`);
console.log(`[GITEA-MCP] GITHUB_TOKEN: ${GITHUB_TOKEN ? "***" : "undefined"}`);
if (!REPO_OWNER || !REPO_NAME || !GITHUB_TOKEN) {
console.error(
"[GITEA-MCP] Error: REPO_OWNER, REPO_NAME, and GITHUB_TOKEN environment variables are required",
);
process.exit(1);
}
const server = new McpServer({
name: "Gitea API Operations Server",
version: "0.0.1",
});
// Helper function to make authenticated requests to Gitea API
async function giteaRequest(
endpoint: string,
method: string = "GET",
body?: any,
): Promise<any> {
const url = `${GITEA_API_URL}${endpoint}`;
console.log(`[GITEA-MCP] Making ${method} request to: ${url}`);
const headers: Record<string, string> = {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: "application/json",
};
if (body) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const responseText = await response.text();
console.log(`[GITEA-MCP] Response status: ${response.status}`);
console.log(`[GITEA-MCP] Response: ${responseText.substring(0, 500)}...`);
if (!response.ok) {
throw new Error(
`Gitea API request failed: ${response.status} ${responseText}`,
);
}
return responseText ? JSON.parse(responseText) : null;
}
// Get issue details
server.tool(
"get_issue",
"Get details of a specific issue",
{
issue_number: z.number().describe("The issue number to fetch"),
},
async ({ issue_number }) => {
try {
const issue = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_number}`,
);
return {
content: [
{
type: "text",
text: JSON.stringify(issue, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error getting issue: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error getting issue: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get issue comments
server.tool(
"get_issue_comments",
"Get all comments for a specific issue",
{
issue_number: z.number().describe("The issue number to fetch comments for"),
since: z
.string()
.optional()
.describe("Only show comments updated after this time (ISO 8601 format)"),
before: z
.string()
.optional()
.describe(
"Only show comments updated before this time (ISO 8601 format)",
),
},
async ({ issue_number, since, before }) => {
try {
let endpoint = `/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_number}/comments`;
const params = new URLSearchParams();
if (since) params.append("since", since);
if (before) params.append("before", before);
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const comments = await giteaRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(comments, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[GITEA-MCP] Error getting issue comments: ${errorMessage}`,
);
return {
content: [
{
type: "text",
text: `Error getting issue comments: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Add a comment to an issue
server.tool(
"add_issue_comment",
"Add a new comment to an issue",
{
issue_number: z.number().describe("The issue number to comment on"),
body: z.string().describe("The comment body content"),
},
async ({ issue_number, body }) => {
try {
const comment = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_number}/comments`,
"POST",
{ body },
);
return {
content: [
{
type: "text",
text: JSON.stringify(comment, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error adding issue comment: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error adding issue comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Update (edit) an issue comment
server.tool(
"update_issue_comment",
"Update an existing issue comment",
{
comment_id: z.number().describe("The comment ID to update"),
body: z.string().describe("The new comment body content"),
},
async ({ comment_id, body }) => {
try {
const comment = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/comments/${comment_id}`,
"PATCH",
{ body },
);
return {
content: [
{
type: "text",
text: JSON.stringify(comment, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[GITEA-MCP] Error updating issue comment: ${errorMessage}`,
);
return {
content: [
{
type: "text",
text: `Error updating issue comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Delete an issue comment
server.tool(
"delete_issue_comment",
"Delete an issue comment",
{
comment_id: z.number().describe("The comment ID to delete"),
},
async ({ comment_id }) => {
try {
await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/comments/${comment_id}`,
"DELETE",
);
return {
content: [
{
type: "text",
text: `Successfully deleted comment ${comment_id}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[GITEA-MCP] Error deleting issue comment: ${errorMessage}`,
);
return {
content: [
{
type: "text",
text: `Error deleting issue comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get a specific comment
server.tool(
"get_comment",
"Get details of a specific comment",
{
comment_id: z.number().describe("The comment ID to fetch"),
},
async ({ comment_id }) => {
try {
const comment = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/comments/${comment_id}`,
);
return {
content: [
{
type: "text",
text: JSON.stringify(comment, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error getting comment: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error getting comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// List issues
server.tool(
"list_issues",
"List issues in the repository",
{
state: z
.enum(["open", "closed", "all"])
.optional()
.describe("Issue state filter"),
labels: z
.string()
.optional()
.describe("Comma-separated list of label names"),
milestone: z.string().optional().describe("Milestone title to filter by"),
assignee: z
.string()
.optional()
.describe("Username to filter issues assigned to"),
creator: z
.string()
.optional()
.describe("Username to filter issues created by"),
mentioned: z
.string()
.optional()
.describe("Username to filter issues that mention"),
page: z.number().optional().describe("Page number for pagination"),
limit: z.number().optional().describe("Number of items per page"),
},
async ({
state,
labels,
milestone,
assignee,
creator,
mentioned,
page,
limit,
}) => {
try {
let endpoint = `/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues`;
const params = new URLSearchParams();
if (state) params.append("state", state);
if (labels) params.append("labels", labels);
if (milestone) params.append("milestone", milestone);
if (assignee) params.append("assignee", assignee);
if (creator) params.append("creator", creator);
if (mentioned) params.append("mentioned", mentioned);
if (page) params.append("page", page.toString());
if (limit) params.append("limit", limit.toString());
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const issues = await giteaRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(issues, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error listing issues: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error listing issues: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Create an issue
server.tool(
"create_issue",
"Create a new issue",
{
title: z.string().describe("Issue title"),
body: z.string().optional().describe("Issue body content"),
assignee: z.string().optional().describe("Username to assign the issue to"),
assignees: z
.array(z.string())
.optional()
.describe("Array of usernames to assign the issue to"),
milestone: z
.number()
.optional()
.describe("Milestone ID to associate with the issue"),
labels: z
.array(z.string())
.optional()
.describe("Array of label names to apply to the issue"),
},
async ({ title, body, assignee, assignees, milestone, labels }) => {
try {
const issueData: any = { title };
if (body) issueData.body = body;
if (assignee) issueData.assignee = assignee;
if (assignees) issueData.assignees = assignees;
if (milestone) issueData.milestone = milestone;
if (labels) issueData.labels = labels;
const issue = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues`,
"POST",
issueData,
);
return {
content: [
{
type: "text",
text: JSON.stringify(issue, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error creating issue: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error creating issue: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Update an issue
server.tool(
"update_issue",
"Update an existing issue",
{
issue_number: z.number().describe("The issue number to update"),
title: z.string().optional().describe("New issue title"),
body: z.string().optional().describe("New issue body content"),
assignee: z.string().optional().describe("Username to assign the issue to"),
assignees: z
.array(z.string())
.optional()
.describe("Array of usernames to assign the issue to"),
milestone: z
.number()
.optional()
.describe("Milestone ID to associate with the issue"),
labels: z
.array(z.string())
.optional()
.describe("Array of label names to apply to the issue"),
state: z.enum(["open", "closed"]).optional().describe("Issue state"),
},
async ({
issue_number,
title,
body,
assignee,
assignees,
milestone,
labels,
state,
}) => {
try {
const updateData: any = {};
if (title) updateData.title = title;
if (body !== undefined) updateData.body = body;
if (assignee) updateData.assignee = assignee;
if (assignees) updateData.assignees = assignees;
if (milestone) updateData.milestone = milestone;
if (labels) updateData.labels = labels;
if (state) updateData.state = state;
const issue = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_number}`,
"PATCH",
updateData,
);
return {
content: [
{
type: "text",
text: JSON.stringify(issue, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error updating issue: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error updating issue: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get repository information
server.tool("get_repository", "Get repository information", {}, async () => {
try {
const repo = await giteaRequest(`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}`);
return {
content: [
{
type: "text",
text: JSON.stringify(repo, null, 2),
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error getting repository: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error getting repository: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
});
// Get pull requests
server.tool(
"list_pull_requests",
"List pull requests in the repository",
{
state: z
.enum(["open", "closed", "all"])
.optional()
.describe("Pull request state filter"),
head: z.string().optional().describe("Head branch name"),
base: z.string().optional().describe("Base branch name"),
page: z.number().optional().describe("Page number for pagination"),
limit: z.number().optional().describe("Number of items per page"),
},
async ({ state, head, base, page, limit }) => {
try {
let endpoint = `/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls`;
const params = new URLSearchParams();
if (state) params.append("state", state);
if (head) params.append("head", head);
if (base) params.append("base", base);
if (page) params.append("page", page.toString());
if (limit) params.append("limit", limit.toString());
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const pulls = await giteaRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(pulls, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error listing pull requests: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error listing pull requests: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get a specific pull request
server.tool(
"get_pull_request",
"Get details of a specific pull request",
{
pull_number: z.number().describe("The pull request number to fetch"),
},
async ({ pull_number }) => {
try {
const pull = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${pull_number}`,
);
return {
content: [
{
type: "text",
text: JSON.stringify(pull, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error getting pull request: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error getting pull request: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Create a pull request
server.tool(
"create_pull_request",
"Create a new pull request",
{
title: z.string().describe("Pull request title"),
body: z.string().optional().describe("Pull request body/description"),
head: z.string().describe("Head branch name"),
base: z.string().describe("Base branch name"),
assignee: z
.string()
.optional()
.describe("Username to assign the pull request to"),
assignees: z
.array(z.string())
.optional()
.describe("Array of usernames to assign the pull request to"),
milestone: z
.number()
.optional()
.describe("Milestone ID to associate with the pull request"),
labels: z
.array(z.string())
.optional()
.describe("Array of label names to apply to the pull request"),
},
async ({
title,
body,
head,
base,
assignee,
assignees,
milestone,
labels,
}) => {
try {
const pullData: any = { title, head, base };
if (body) pullData.body = body;
if (assignee) pullData.assignee = assignee;
if (assignees) pullData.assignees = assignees;
if (milestone) pullData.milestone = milestone;
if (labels) pullData.labels = labels;
const pull = await giteaRequest(
`/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls`,
"POST",
pullData,
);
return {
content: [
{
type: "text",
text: JSON.stringify(pull, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[GITEA-MCP] Error creating pull request: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error creating pull request: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
async function runServer() {
console.log(`[GITEA-MCP] Starting MCP server transport...`);
const transport = new StdioServerTransport();
console.log(`[GITEA-MCP] Connecting to transport...`);
await server.connect(transport);
console.log(`[GITEA-MCP] Gitea MCP server connected and ready!`);
process.on("exit", () => {
console.log(`[GITEA-MCP] Server shutting down...`);
server.close();
});
}
console.log(`[GITEA-MCP] Calling runServer()...`);
runServer().catch((error) => {
console.error(`[GITEA-MCP] Server startup failed:`, error);
process.exit(1);
});

View File

@@ -6,7 +6,7 @@ import { z } from "zod";
import { readFile } from "fs/promises";
import { join } from "path";
import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config";
import { GITEA_API_URL } from "../github/api/config";
type GitHubRef = {
object: {
@@ -80,146 +80,16 @@ server.tool(
return filePath;
});
// 1. Get the branch reference
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const refResponse = await fetch(refUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!refResponse.ok) {
throw new Error(
`Failed to get branch reference: ${refResponse.status}`,
);
}
const refData = (await refResponse.json()) as GitHubRef;
const baseSha = refData.object.sha;
// 2. Get the base commit
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
const commitResponse = await fetch(commitUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!commitResponse.ok) {
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
}
const commitData = (await commitResponse.json()) as GitHubCommit;
const baseTreeSha = commitData.tree.sha;
// 3. Create tree entries for all files
const treeEntries = await Promise.all(
processedFiles.map(async (filePath) => {
const fullPath = filePath.startsWith("/")
? filePath
: join(REPO_DIR, filePath);
const content = await readFile(fullPath, "utf-8");
return {
path: filePath,
mode: "100644",
type: "blob",
content: content,
};
}),
// NOTE: Gitea does not support GitHub's low-level git API operations
// (creating trees, commits, etc.). We need to use the contents API instead.
// For now, throw an error indicating this functionality is not available.
throw new Error(
"Multi-file commits are not supported with Gitea. " +
"Gitea does not provide the low-level git API operations (trees, commits) " +
"that are required for atomic multi-file commits. " +
"Please commit files individually using the contents API.",
);
// 4. Create a new tree
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
const treeResponse = await fetch(treeUrl, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
base_tree: baseTreeSha,
tree: treeEntries,
}),
});
if (!treeResponse.ok) {
const errorText = await treeResponse.text();
throw new Error(
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
);
}
const treeData = (await treeResponse.json()) as GitHubTree;
// 5. Create a new commit
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
const newCommitResponse = await fetch(newCommitUrl, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
message: message,
tree: treeData.sha,
parents: [baseSha],
}),
});
if (!newCommitResponse.ok) {
const errorText = await newCommitResponse.text();
throw new Error(
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
);
}
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
// 6. Update the reference to point to the new commit
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text();
throw new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
);
}
const simplifiedResult = {
commit: {
sha: newCommitData.sha,
message: newCommitData.message,
author: newCommitData.author.name,
date: newCommitData.author.date,
},
files: processedFiles.map((path) => ({ path })),
tree: {
sha: treeData.sha,
},
};
return {
content: [
{
@@ -283,136 +153,15 @@ server.tool(
return filePath;
});
// 1. Get the branch reference
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const refResponse = await fetch(refUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!refResponse.ok) {
throw new Error(
`Failed to get branch reference: ${refResponse.status}`,
);
}
const refData = (await refResponse.json()) as GitHubRef;
const baseSha = refData.object.sha;
// 2. Get the base commit
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
const commitResponse = await fetch(commitUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!commitResponse.ok) {
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
}
const commitData = (await commitResponse.json()) as GitHubCommit;
const baseTreeSha = commitData.tree.sha;
// 3. Create tree entries for file deletions (setting SHA to null)
const treeEntries = processedPaths.map((path) => ({
path: path,
mode: "100644",
type: "blob" as const,
sha: null,
}));
// 4. Create a new tree with deletions
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
const treeResponse = await fetch(treeUrl, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
base_tree: baseTreeSha,
tree: treeEntries,
}),
});
if (!treeResponse.ok) {
const errorText = await treeResponse.text();
throw new Error(
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
);
}
const treeData = (await treeResponse.json()) as GitHubTree;
// 5. Create a new commit
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
const newCommitResponse = await fetch(newCommitUrl, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
message: message,
tree: treeData.sha,
parents: [baseSha],
}),
});
if (!newCommitResponse.ok) {
const errorText = await newCommitResponse.text();
throw new Error(
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
);
}
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
// 6. Update the reference to point to the new commit
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text();
throw new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
);
}
const simplifiedResult = {
commit: {
sha: newCommitData.sha,
message: newCommitData.message,
author: newCommitData.author.name,
date: newCommitData.author.date,
},
deletedFiles: processedPaths.map((path) => ({ path })),
tree: {
sha: treeData.sha,
},
};
// NOTE: Gitea does not support GitHub's low-level git API operations
// (creating trees, commits, etc.). We need to use the contents API instead.
// For now, throw an error indicating this functionality is not available.
throw new Error(
"Multi-file deletions are not supported with Gitea. " +
"Gitea does not provide the low-level git API operations (trees, commits) " +
"that are required for atomic multi-file operations. " +
"Please delete files individually using the contents API.",
);
return {
content: [

View File

@@ -6,28 +6,28 @@ export async function prepareMcpConfig(
repo: string,
branch: string,
): Promise<string> {
console.log("[MCP-INSTALL] Preparing MCP configuration...");
console.log(`[MCP-INSTALL] Owner: ${owner}`);
console.log(`[MCP-INSTALL] Repo: ${repo}`);
console.log(`[MCP-INSTALL] Branch: ${branch}`);
console.log(
`[MCP-INSTALL] GitHub token: ${githubToken ? "***" : "undefined"}`,
);
console.log(
`[MCP-INSTALL] GITHUB_ACTION_PATH: ${process.env.GITHUB_ACTION_PATH}`,
);
console.log(
`[MCP-INSTALL] GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`,
);
try {
const mcpConfig = {
mcpServers: {
github: {
command: "docker",
args: [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/anthropics/github-mcp-server:sha-7382253",
],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
},
},
github_file_ops: {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
`${process.env.GITHUB_ACTION_PATH}/src/mcp/gitea-mcp-server.ts`,
],
env: {
GITHUB_TOKEN: githubToken,
@@ -35,13 +35,37 @@ 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",
},
},
local_git_ops: {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/local-git-ops-server.ts`,
],
env: {
GITHUB_TOKEN: githubToken,
REPO_OWNER: owner,
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",
},
},
},
};
return JSON.stringify(mcpConfig, null, 2);
const configString = JSON.stringify(mcpConfig, null, 2);
console.log("[MCP-INSTALL] Generated MCP configuration:");
console.log(configString);
console.log("[MCP-INSTALL] MCP config generation completed successfully");
return configString;
} catch (error) {
console.error("[MCP-INSTALL] MCP config generation failed:", error);
core.setFailed(`Install MCP server failed with error: ${error}`);
process.exit(1);
}

View File

@@ -0,0 +1,410 @@
#!/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";
console.log(`[LOCAL-GIT-MCP] Starting Local Git Operations MCP Server`);
console.log(`[LOCAL-GIT-MCP] REPO_OWNER: ${REPO_OWNER}`);
console.log(`[LOCAL-GIT-MCP] REPO_NAME: ${REPO_NAME}`);
console.log(`[LOCAL-GIT-MCP] BRANCH_NAME: ${BRANCH_NAME}`);
console.log(`[LOCAL-GIT-MCP] REPO_DIR: ${REPO_DIR}`);
console.log(`[LOCAL-GIT-MCP] GITEA_API_URL: ${GITEA_API_URL}`);
console.log(
`[LOCAL-GIT-MCP] GITHUB_TOKEN: ${GITHUB_TOKEN ? "***" : "undefined"}`,
);
if (!REPO_OWNER || !REPO_NAME || !BRANCH_NAME) {
console.error(
"[LOCAL-GIT-MCP] 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(`[LOCAL-GIT-MCP] Running git command: ${command}`);
console.log(`[LOCAL-GIT-MCP] Working directory: ${REPO_DIR}`);
const result = execSync(command, {
cwd: REPO_DIR,
encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"],
});
console.log(`[LOCAL-GIT-MCP] Git command result: ${result.trim()}`);
return result.trim();
} catch (error: any) {
console.error(`[LOCAL-GIT-MCP] Git command failed: ${command}`);
console.error(`[LOCAL-GIT-MCP] Error: ${error.message}`);
if (error.stdout) console.error(`[LOCAL-GIT-MCP] Stdout: ${error.stdout}`);
if (error.stderr) console.error(`[LOCAL-GIT-MCP] Stderr: ${error.stderr}`);
throw error;
}
}
// Helper function to ensure git user is configured
function ensureGitUserConfigured(): void {
try {
// Check if user.email is already configured
runGitCommand("git config user.email");
console.log(`[LOCAL-GIT-MCP] Git user.email already configured`);
} catch (error) {
console.log(
`[LOCAL-GIT-MCP] Git user.email not configured, setting default`,
);
runGitCommand('git config user.email "claude@anthropic.com"');
}
try {
// Check if user.name is already configured
runGitCommand("git config user.name");
console.log(`[LOCAL-GIT-MCP] Git user.name already configured`);
} catch (error) {
console.log(
`[LOCAL-GIT-MCP] Git user.name not configured, setting default`,
);
runGitCommand('git config user.name "Claude"');
}
}
// 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 }) => {
console.log(
`[LOCAL-GIT-MCP] commit_files called with files: ${JSON.stringify(files)}, message: ${message}`,
);
try {
// Ensure git user is configured before committing
ensureGitUserConfigured();
// Add the specified files
console.log(`[LOCAL-GIT-MCP] Adding ${files.length} files to git...`);
for (const file of files) {
const filePath = file.startsWith("/") ? file.slice(1) : file;
console.log(`[LOCAL-GIT-MCP] Adding file: ${filePath}`);
runGitCommand(`git add "${filePath}"`);
}
// Commit the changes
console.log(`[LOCAL-GIT-MCP] Committing with message: ${message}`);
runGitCommand(`git commit -m "${message}"`);
console.log(
`[LOCAL-GIT-MCP] Successfully committed ${files.length} files`,
);
return {
content: [
{
type: "text",
text: `Successfully committed ${files.length} file(s): ${files.join(", ")}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[LOCAL-GIT-MCP] Error committing files: ${errorMessage}`);
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,
};
}
},
);
// Delete files tool
server.tool(
"delete_files",
"Delete one or more files and commit the deletion using local git operations",
{
files: z
.array(z.string())
.describe(
'Array of file paths relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
),
message: z.string().describe("Commit message for the deletion"),
},
async ({ files, message }) => {
try {
// Remove the specified files
for (const file of files) {
const filePath = file.startsWith("/") ? file.slice(1) : file;
runGitCommand(`git rm "${filePath}"`);
}
// Commit the deletions
runGitCommand(`git commit -m "${message}"`);
return {
content: [
{
type: "text",
text: `Successfully deleted and committed ${files.length} file(s): ${files.join(", ")}`,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error deleting files: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
// Get git status tool
server.tool("git_status", "Get the current git status", {}, async () => {
console.log(`[LOCAL-GIT-MCP] git_status called`);
try {
const status = runGitCommand("git status --porcelain");
const currentBranch = runGitCommand("git rev-parse --abbrev-ref HEAD");
console.log(`[LOCAL-GIT-MCP] Current branch: ${currentBranch}`);
console.log(
`[LOCAL-GIT-MCP] Git status: ${status || "Working tree clean"}`,
);
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);
console.error(`[LOCAL-GIT-MCP] Error getting git status: ${errorMessage}`);
return {
content: [
{
type: "text",
text: `Error getting git status: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
});
async function runServer() {
console.log(`[LOCAL-GIT-MCP] Starting MCP server transport...`);
const transport = new StdioServerTransport();
console.log(`[LOCAL-GIT-MCP] Connecting to transport...`);
await server.connect(transport);
console.log(`[LOCAL-GIT-MCP] MCP server connected and ready!`);
process.on("exit", () => {
console.log(`[LOCAL-GIT-MCP] Server shutting down...`);
server.close();
});
}
console.log(`[LOCAL-GIT-MCP] Calling runServer()...`);
runServer().catch((error) => {
console.error(`[LOCAL-GIT-MCP] Server startup failed:`, error);
process.exit(1);
});