From 376fb9ee00693d873dded93b6b88b68e47c1d56b Mon Sep 17 00:00:00 2001 From: WildDragon Deploy Date: Wed, 27 May 2026 23:33:42 -0400 Subject: [PATCH] feat: replace GitHub API with Forgejo REST API in agent routes --- server/routes/agent.js | 146 +++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 93 deletions(-) diff --git a/server/routes/agent.js b/server/routes/agent.js index 37a9ed2..e2bb5b7 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -9,7 +9,7 @@ import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; import { spawnGemini } from '../gemini-cli.js'; -import { Octokit } from '@octokit/rest'; +const FORGEJO_BASE_URL = process.env.FORGEJO_BASE_URL || 'https://forge.wilddragon.net'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { IS_PLATFORM } from '../constants/config.js'; import { normalizeProjectPath } from '../shared/utils.js'; @@ -99,36 +99,28 @@ async function getGitRemoteUrl(repoPath) { } /** - * Normalize GitHub URLs for comparison - * @param {string} url - GitHub URL + * Normalize git URLs for comparison + * @param {string} url - Git URL (any host) * @returns {string} - Normalized URL */ -function normalizeGitHubUrl(url) { - // Remove .git suffix +function normalizeGitUrl(url) { let normalized = url.replace(/\.git$/, ''); - // Convert SSH to HTTPS format for comparison - normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/'); - // Remove trailing slash + normalized = normalized.replace(/^git@([^:]+):/, 'https://$1/'); normalized = normalized.replace(/\/$/, ''); return normalized.toLowerCase(); } /** - * Parse GitHub URL to extract owner and repo - * @param {string} url - GitHub URL (HTTPS or SSH) + * Parse git URL to extract owner and repo (works with any host) + * @param {string} url - Git URL (HTTPS or SSH, any host) * @returns {{owner: string, repo: string}} - Parsed owner and repo */ -function parseGitHubUrl(url) { - // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git - // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git - const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/); - if (!match) { - throw new Error('Invalid GitHub URL format'); - } - return { - owner: match[1], - repo: match[2].replace(/\.git$/, '') - }; +function parseGitUrl(url) { + const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/); + const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/); + const match = httpsMatch || sshMatch; + if (!match) { throw new Error('Invalid git URL format'); } + return { owner: match[1], repo: match[2] }; } /** @@ -257,47 +249,11 @@ async function getCommitMessages(projectPath, limit = 5) { }); } -/** - * Create a new branch on GitHub using the API - * @param {Octokit} octokit - Octokit instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} branchName - Name of the new branch - * @param {string} baseBranch - Base branch to branch from (default: 'main') - * @returns {Promise} - */ -async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') { - try { - // Get the SHA of the base branch - const { data: ref } = await octokit.git.getRef({ - owner, - repo, - ref: `heads/${baseBranch}` - }); - - const baseSha = ref.object.sha; - - // Create the new branch - await octokit.git.createRef({ - owner, - repo, - ref: `refs/heads/${branchName}`, - sha: baseSha - }); - - console.log(`✅ Created branch '${branchName}' on GitHub`); - } catch (error) { - if (error.status === 422 && error.message.includes('Reference already exists')) { - console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`); - } else { - throw error; - } - } -} /** - * Create a pull request on GitHub - * @param {Octokit} octokit - Octokit instance + * Create a pull request on a Forgejo instance + * @param {string} baseUrl - Forgejo base URL + * @param {string} token - Forgejo access token * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {string} branchName - Head branch name @@ -306,22 +262,24 @@ async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = * @param {string} baseBranch - Base branch (default: 'main') * @returns {Promise<{number: number, url: string}>} - PR number and URL */ -async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') { - const { data: pr } = await octokit.pulls.create({ - owner, - repo, - title, - head: branchName, - base: baseBranch, - body +async function createForgejoPR(baseUrl, token, owner, repo, branchName, title, body, baseBranch = 'main') { + const response = await fetch(`${baseUrl}/api/v1/repos/${owner}/${repo}/pulls`, { + method: 'POST', + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title, body, head: branchName, base: baseBranch }), }); - console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`); + if (!response.ok) { + const err = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Failed to create PR: ${err.message || response.statusText}`); + } - return { - number: pr.number, - url: pr.html_url - }; + const pr = await response.json(); + console.log(`Created pull request #${pr.number}: ${pr.html_url}`); + return { number: pr.number, url: pr.html_url }; } /** @@ -331,12 +289,12 @@ async function createGitHubPR(octokit, owner, repo, branchName, title, body, bas * @param {string} projectPath - Path for cloning the repository * @returns {Promise} - Path to the cloned repository */ -async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { +async function cloneGitRepo(repoUrl, gitToken = null, projectPath) { return new Promise(async (resolve, reject) => { try { // Validate GitHub URL - if (!githubUrl || !githubUrl.includes('github.com')) { - throw new Error('Invalid GitHub URL'); + if (!repoUrl || !repoUrl.trim()) { + throw new Error('Repository URL is required'); } const cloneDir = path.resolve(projectPath); @@ -347,14 +305,14 @@ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { // Directory exists - check if it's a git repo with the same URL try { const existingUrl = await getGitRemoteUrl(cloneDir); - const normalizedExisting = normalizeGitHubUrl(existingUrl); - const normalizedRequested = normalizeGitHubUrl(githubUrl); + const normalizedExisting = normalizeGitUrl(existingUrl); + const normalizedRequested = normalizeGitUrl(repoUrl); if (normalizedExisting === normalizedRequested) { console.log('✅ Repository already exists at path with correct URL'); return resolve(cloneDir); } else { - throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`); + throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${repoUrl}`); } } catch (gitError) { throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`); @@ -367,11 +325,16 @@ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { await fs.mkdir(path.dirname(cloneDir), { recursive: true }); // Prepare the git clone URL with authentication if token is provided - let cloneUrl = githubUrl; - if (githubToken) { - // Convert HTTPS URL to authenticated URL - // Example: https://github.com/user/repo -> https://token@github.com/user/repo - cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`); + let cloneUrl = repoUrl; + if (gitToken) { + try { + const url = new URL(repoUrl); + url.username = gitToken; + url.password = ''; + cloneUrl = url.toString(); + } catch { + // SSH URLs used as-is + } } console.log('🔄 Cloning repository:', githubUrl); @@ -887,7 +850,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash); } - finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath); + finalProjectPath = await cloneGitRepo(githubUrl.trim(), tokenToUse, targetPath); } else { // Use existing project path finalProjectPath = normalizeProjectPath(path.resolve(projectPath)); @@ -997,17 +960,14 @@ router.post('/', validateExternalApiKey, async (req, res) => { throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.'); } - // Initialize Octokit - const octokit = new Octokit({ auth: tokenToUse }); - // Get GitHub URL - either from parameter or from git remote let repoUrl = githubUrl; if (!repoUrl) { console.log('🔍 Getting GitHub URL from git remote...'); try { repoUrl = await getGitRemoteUrl(finalProjectPath); - if (!repoUrl.includes('github.com')) { - throw new Error('Project does not have a GitHub remote configured'); + if (!repoUrl.trim()) { + throw new Error('Project does not have a git remote configured'); } console.log(`✅ Found GitHub remote: ${repoUrl}`); } catch (error) { @@ -1016,7 +976,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { } // Parse GitHub URL to get owner and repo - const { owner, repo } = parseGitHubUrl(repoUrl); + const { owner, repo } = parseGitUrl(repoUrl); console.log(`📦 Repository: ${owner}/${repo}`); // Use provided branch name or auto-generate from message @@ -1101,7 +1061,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { branchInfo = { name: finalBranchName, - url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}` + url: `${FORGEJO_BASE_URL}/${owner}/${repo}/src/branch/${finalBranchName}` }; } @@ -1126,7 +1086,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { // Create the pull request console.log('🔄 Creating pull request...'); - prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main'); + prInfo = await createForgejoPR(FORGEJO_BASE_URL, tokenToUse, owner, repo, finalBranchName, prTitle, prBody, 'main'); } // Send branch/PR info in response