v1.0.1
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
318
src/github/api/gitea-client.ts
Normal file
318
src/github/api/gitea-client.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
96
src/github/utils/local-git.ts
Normal file
96
src/github/utils/local-git.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
777
src/mcp/gitea-mcp-server.ts
Normal 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);
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
410
src/mcp/local-git-ops-server.ts
Normal file
410
src/mcp/local-git-ops-server.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user