// src/git/apiHandler.js (CommonJS) // enthält: createRepoGitHub, createRepoGitea, listGiteaRepos, // getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile const axios = require('axios'); const http = require('http'); const https = require('https'); // IPv4 bevorzugen – verhindert ETIMEDOUT wenn der Hostname nur per IPv6 erreichbar wäre // oder Node.js fälschlicherweise IPv6 vorranging versucht. const ipv4HttpAgent = new http.Agent({ family: 4, keepAlive: true }); const ipv4HttpsAgent = new https.Agent({ family: 4, keepAlive: true }); const axiosInstance = axios.create({ httpAgent: ipv4HttpAgent, httpsAgent: ipv4HttpsAgent, }); function normalizeAndValidateBaseUrl(rawUrl) { const value = (rawUrl || '').trim(); if (!value) { throw new Error('Gitea URL fehlt. Bitte tragen Sie eine URL ein.'); } let parsed; try { parsed = new URL(value); } catch (_) { throw new Error('Ungueltige Gitea URL. Beispiel fuer IPv6: http://[2001:db8::1]:3000'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Gitea URL muss mit http:// oder https:// beginnen.'); } return value.replace(/\/$/, ''); } function normalizeBase(url) { if (!url) return null; return normalizeAndValidateBaseUrl(url); } async function checkGiteaConnection({ token, url, timeout = 8000 }) { const base = normalizeAndValidateBaseUrl(url); const started = Date.now(); const versionRes = await axiosInstance.get(`${base}/api/v1/version`, { timeout, validateStatus: () => true, headers: { 'User-Agent': 'Git-Manager-GUI' } }); const latencyMs = Math.max(1, Date.now() - started); const apiReachable = versionRes.status >= 200 && versionRes.status < 500; let authStatus = null; let authOk = false; if (token) { const userRes = await axiosInstance.get(`${base}/api/v1/user`, { timeout, validateStatus: () => true, headers: { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' } }); authStatus = userRes.status; authOk = userRes.status >= 200 && userRes.status < 300; } const serverVersion = (versionRes.data && (versionRes.data.version || versionRes.data.Version || versionRes.data.tag)) || null; return { ok: apiReachable && (!!token ? authOk : true), base, checks: { urlValid: true, apiReachable, authProvided: !!token, authOk }, metrics: { latencyMs, versionStatus: versionRes.status, authStatus }, server: { version: serverVersion } }; } function buildContentsUrl(base, owner, repo, p) { if (!p) return `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`; const parts = p.split('/').map(seg => encodeURIComponent(seg)).join('/'); return `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${parts}`; } async function tryRequest(url, token, opts = {}) { try { const res = await axiosInstance.get(url, { headers: token ? { Authorization: `token ${token}` } : {}, timeout: opts.timeout || 10000 }); return { ok: true, data: res.data, status: res.status, url }; } catch (err) { return { ok: false, error: err, status: err.response ? err.response.status : null, url }; } } async function createRepoGitHub({ name, token, auto_init = true, license = '', private: isPrivate = false }) { const body = { name, private: isPrivate, auto_init: auto_init }; // GitHub verwendet 'license_template' statt 'license' if (license) { body.license_template = license; } try { const response = await axiosInstance.post('https://api.github.com/user/repos', body, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (error) { // Benutzerfreundliche Fehlerbehandlung if (error.response) { const status = error.response.status; const data = error.response.data; if (status === 401) { throw new Error('Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren GitHub-Token.'); } else if (status === 422) { const msg = data?.message || 'Repository konnte nicht erstellt werden'; if (msg.includes('name already exists')) { throw new Error(`Ein Repository mit dem Namen "${name}" existiert bereits.`); } throw new Error(`GitHub-Fehler: ${msg}`); } else if (status === 403) { throw new Error('Zugriff verweigert. Bitte überprüfen Sie Ihre Token-Berechtigungen.'); } else { throw new Error(`GitHub-Fehler (${status}): ${data?.message || error.message}`); } } else if (error.request) { throw new Error('Keine Antwort von GitHub. Bitte überprüfen Sie Ihre Internetverbindung.'); } else { throw new Error(`Fehler beim Erstellen des Repositories: ${error.message}`); } } } async function createRepoGitea({ name, token, url, auto_init = true, license = '', private: isPrivate = false }) { const endpoint = normalizeBase(url) + '/api/v1/user/repos'; console.log('=== createRepoGitea DEBUG ==='); console.log('Endpoint:', endpoint); console.log('Token present:', !!token); console.log('Token length:', token ? token.length : 0); console.log('Name:', name); console.log('auto_init:', auto_init); console.log('License:', license); // Normalisiere Lizenz zu Großbuchstaben, wenn vorhanden const normalizedLicense = license ? license.toUpperCase() : ''; const body = { name, private: isPrivate, auto_init: auto_init, default_branch: 'main' }; if (normalizedLicense) { body.license = normalizedLicense; } console.log('Request body:', JSON.stringify(body, null, 2)); try { const response = await axiosInstance.post(endpoint, body, { headers: { Authorization: `token ${token}` }, timeout: 15000 // 15 Sekunden Timeout }); console.log('Success! Status:', response.status); return response.data; } catch (error) { console.error('Error creating repo:', error.response?.status, error.response?.data); // Wenn Lizenz-Fehler auftritt (500 mit Lizenz-Meldung), versuche ohne Lizenz if (error.response?.status === 500 && error.response?.data?.message?.includes('getLicense') && normalizedLicense) { console.warn(`Lizenz "${normalizedLicense}" wird vom Server nicht unterstützt. Versuche ohne Lizenz...`); const bodyWithoutLicense = { name, private: isPrivate, auto_init: auto_init, default_branch: 'main' }; try { const retryResponse = await axiosInstance.post(endpoint, bodyWithoutLicense, { headers: { Authorization: `token ${token}` }, timeout: 15000 }); console.log('Success without license! Status:', retryResponse.status); console.warn(`Hinweis: Repository wurde ohne Lizenz erstellt, da "${normalizedLicense}" nicht verfügbar ist.`); return retryResponse.data; } catch (retryError) { // Falls auch ohne Lizenz fehlschlägt, behandle den neuen Fehler error = retryError; } } // Benutzerfreundliche Fehlerbehandlung if (error.response) { const status = error.response.status; const data = error.response.data; if (status === 401) { throw new Error('Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren Gitea-Token.'); } else if (status === 409 || (status === 422 && data?.message?.includes('already exists'))) { throw new Error(`Ein Repository mit dem Namen "${name}" existiert bereits auf Gitea.`); } else if (status === 403) { throw new Error('Zugriff verweigert. Bitte überprüfen Sie Ihre Token-Berechtigungen.'); } else if (status === 404) { throw new Error('Gitea-Server nicht gefunden. Bitte überprüfen Sie die URL.'); } else if (status === 422) { const msg = data?.message || 'Repository konnte nicht erstellt werden'; throw new Error(`Gitea-Fehler: ${msg}`); } else if (status === 500 && data?.message?.includes('getLicense')) { throw new Error(`Die Lizenz "${normalizedLicense}" wird von Ihrem Gitea-Server nicht unterstützt. Bitte wählen Sie eine andere Lizenz oder lassen Sie das Feld leer.`); } else { throw new Error(`Gitea-Fehler (${status}): ${data?.message || error.message}`); } } else if (error.code === 'ECONNABORTED') { throw new Error('Zeitüberschreitung beim Verbinden mit Gitea. Bitte überprüfen Sie Ihre Internetverbindung oder Server-URL.'); } else if (error.request) { throw new Error('Keine Antwort von Gitea-Server. Bitte überprüfen Sie die URL und Ihre Internetverbindung.'); } else { throw new Error(`Fehler beim Erstellen des Repositories: ${error.message}`); } } } async function listGiteaRepos({ token, url }) { const endpoint = normalizeBase(url) + '/api/v1/user/repos'; const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } function toDateKey(value) { if (value == null) return null; if (typeof value === 'string') { const s = value.trim(); if (!s) return null; const match = s.match(/^(\d{4}-\d{2}-\d{2})/); if (match) return match[1]; if (/^\d+$/.test(s)) { const n = Number(s); if (!Number.isFinite(n)) return null; const ms = n < 1e12 ? n * 1000 : n; const d = new Date(ms); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } const d = new Date(s); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } if (typeof value === 'number' && Number.isFinite(value)) { const ms = value < 1e12 ? value * 1000 : value; const d = new Date(ms); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } if (value instanceof Date && !Number.isNaN(value.getTime())) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; } return null; } function normalizeHeatmapEntries(payload) { const acc = new Map(); const addEntry = (dateLike, countLike) => { const key = toDateKey(dateLike); if (!key) return; const n = Number(countLike); const count = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1; acc.set(key, (acc.get(key) || 0) + count); }; const walk = (node, depth = 0) => { if (depth > 6 || node == null) return; if (Array.isArray(node)) { node.forEach(item => walk(item, depth + 1)); return; } if (typeof node === 'number' || typeof node === 'string' || node instanceof Date) { addEntry(node, 1); return; } if (typeof node !== 'object') return; const dateLike = node.date ?? node.day ?? node.timestamp ?? node.time ?? node.ts ?? node.created_at ?? node.created ?? node.when; const countLike = node.count ?? node.contributions ?? node.value ?? node.total ?? node.commits ?? node.frequency; if (dateLike != null) { addEntry(dateLike, countLike); } if (node.data != null) walk(node.data, depth + 1); if (node.values != null) walk(node.values, depth + 1); if (node.heatmap != null) walk(node.heatmap, depth + 1); if (node.contributions != null) walk(node.contributions, depth + 1); if (node.days != null) walk(node.days, depth + 1); const keys = Object.keys(node); for (const key of keys) { if (/^\d{4}-\d{2}-\d{2}$/.test(key)) { addEntry(key, node[key]); } } }; walk(payload, 0); return Array.from(acc.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); } async function getGiteaUserHeatmap({ token, url }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const headers = { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' }; const meRes = await axiosInstance.get(`${base}/api/v1/user`, { headers, timeout: 10000 }); const username = meRes?.data?.login || meRes?.data?.username || meRes?.data?.name || null; const candidates = [ `${base}/api/v1/user/heatmap`, username ? `${base}/api/v1/users/${encodeURIComponent(username)}/heatmap` : null ].filter(Boolean); let lastError = null; for (const endpoint of candidates) { try { const response = await axiosInstance.get(endpoint, { headers, timeout: 12000, validateStatus: () => true }); if (response.status >= 200 && response.status < 300) { return { username, endpoint, entries: normalizeHeatmapEntries(response.data) }; } lastError = new Error(`Heatmap endpoint failed (${response.status}): ${endpoint}`); } catch (e) { lastError = e; } } throw lastError || new Error('No usable heatmap endpoint found'); } /** * Returns array of items for a directory or single item for file. * Each item includes name, path, type, size, download_url, sha (if present). */ async function getGiteaRepoContents({ token, url, owner, repo, path = '', ref = 'HEAD' }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); if (!owner && repo && repo.includes('/')) { const parts = repo.split('/'); owner = parts[0]; repo = parts[1]; } // HEAD folgt automatisch dem default_branch des Repos (main ODER master) let branchRef = ref || 'HEAD'; // console.log('=== getGiteaRepoContents DEBUG ==='); // Optional: Stumm geschaltet // console.log('Input ref:', ref, 'Final branchRef:', branchRef, 'Path:', path); const candidates = []; candidates.push(buildContentsUrl(base, owner, repo, path) + `?ref=${branchRef}`); candidates.push(buildContentsUrl(base, owner, repo, path)); // Fallback: master und main explizit versuchen if (branchRef === 'HEAD') { candidates.push(buildContentsUrl(base, owner, repo, path) + `?ref=main`); candidates.push(buildContentsUrl(base, owner, repo, path) + `?ref=master`); } if (path) { candidates.push(`${base}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branchRef}`); candidates.push(`${base}/api/v1/repos/${owner}/${repo}/contents/${path}`); } let lastErr = null; for (const urlCandidate of candidates) { const r = await tryRequest(urlCandidate, token); if (r.ok) { const payload = r.data; if (Array.isArray(payload)) { return { ok: true, items: payload.map(item => ({ name: item.name, path: item.path, type: item.type, size: item.size, download_url: item.download_url || item.html_url || null, sha: item.sha || item.commit_id || null }))}; } else { return { ok: true, items: [{ name: payload.name, path: payload.path, type: payload.type, size: payload.size, download_url: payload.download_url || payload.html_url || null, sha: payload.sha || payload.commit_id || null }]}; } } else { lastErr = r; if (r.status && (r.status === 401 || r.status === 403)) { throw new Error(`Auth error (${r.status}) when requesting ${r.url}`); } } } // Alle Kandidaten fehlgeschlagen — prüfen ob Repo leer ist (kein Commit) if (lastErr && lastErr.status === 404) { const repoInfoUrl = `${base}/api/v1/repos/${owner}/${repo}`; const repoInfo = await tryRequest(repoInfoUrl, token); if (repoInfo.ok && repoInfo.data.empty) { // Repo existiert, ist aber leer return { ok: true, items: [], empty: true }; } if (repoInfo.ok && !repoInfo.data.empty) { // Repo existiert und hat Commits, aber Pfad nicht gefunden return { ok: true, items: [] }; } } const msg = lastErr ? `Failed (${lastErr.status || 'no-status'}) ${lastErr.url}` : 'Unknown error'; const err = new Error('getGiteaRepoContents failed: ' + msg); err.detail = lastErr; throw err; } /** * Get file content (decoded) from Gitea repo. */ async function getGiteaFileContent({ token, url, owner, repo, path, ref = 'HEAD' }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); if (!owner && repo && repo.includes('/')) { const parts = repo.split('/'); owner = parts[0]; repo = parts[1]; } let branchRef = ref || 'HEAD'; const candidates = []; candidates.push(buildContentsUrl(base, owner, repo, path) + `?ref=${branchRef}`); candidates.push(buildContentsUrl(base, owner, repo, path)); // Fallback: raw API + main/master candidates.push(`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(path)}?ref=${branchRef}`); if (branchRef === 'HEAD') { candidates.push(`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(path)}?ref=main`); candidates.push(`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(path)}?ref=master`); } candidates.push(`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(path)}`); let lastErr = null; for (const c of candidates) { const r = await tryRequest(c, token); if (r.ok) { if (r.data && typeof r.data === 'object' && r.data.content) { try { return Buffer.from(r.data.content, 'base64').toString('utf8'); } catch (e) { return r.data.content; } } if (typeof r.data === 'string') return r.data; if (r.data && r.data.download_url) { const r2 = await tryRequest(r.data.download_url, token); if (r2.ok) return r2.data; } return JSON.stringify(r.data, null, 2); } else { lastErr = r; if (r.status && (r.status === 401 || r.status === 403)) { throw new Error(`Auth error (${r.status}) when requesting ${r.url}`); } } } const msg = lastErr ? `Failed (${lastErr.status || 'no-status'}) ${lastErr.url}` : 'Unknown error'; const err = new Error('getGiteaFileContent failed: ' + msg); err.detail = lastErr; throw err; } /** * Upload (create or update) a file to Gitea. * Implementiert Retry-Logik für Server-Caching-Probleme (404 beim Lesen, 422 beim Schreiben). */ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, message = 'Upload via Git Manager GUI', branch = 'HEAD' }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); if (!owner && repo && repo.includes('/')) { const parts = repo.split('/'); owner = parts[0]; repo = parts[1]; } // Behalte den branch so wie übergeben - keine Konvertierung let branchName = branch || 'HEAD'; const fetchSha = async () => { try { const existing = await getGiteaRepoContents({ token, url: base, owner, repo, path, ref: branchName }); const items = existing && existing.items ? existing.items : (Array.isArray(existing) ? existing : []); if (items.length > 0 && items[0].sha) { return items[0].sha; } return null; } catch (e) { return null; } }; const fetchShaFromDir = async () => { try { const pathParts = path.split('/'); const fileName = pathParts.pop(); const dirPath = pathParts.join('/'); const result = await getGiteaRepoContents({ token, url: base, owner, repo, path: dirPath, ref: branchName }); const list = result && result.items ? result.items : (Array.isArray(result) ? result : []); const item = list.find(i => i.name === fileName); if (item && item.sha) return item.sha; return null; } catch (e) { return null; } }; const endpoint = buildContentsUrl(base, owner, repo, path); // Helper für Warten const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); let retryCount = 0; const MAX_RETRIES = 2; // Reduziert auf 2 Retries für schnelleren Fallback while (retryCount <= MAX_RETRIES) { let sha = await fetchSha(); if (!sha) { sha = await fetchShaFromDir(); } const body = { content: contentBase64, message, branch: branchName }; if (sha) body.sha = sha; console.log(`[Upload Debug] Datei: ${path}, Branch: ${branchName}, SHA: ${sha ? sha.substring(0, 8) : 'keine'}`); try { const res = await axiosInstance.put(endpoint, body, { headers: { Authorization: `token ${token}` }, timeout: 30000 // 30 Sekunden Timeout für größere Dateien }); console.log(`[Upload Success] ${path} erfolgreich gespeichert`); return res.data; } catch (err) { console.error(`Upload Attempt ${retryCount + 1} for ${path}:`, err.response ? err.response.data : err.message); // Behandle 500 Server-Fehler speziell if (err.response && err.response.status === 500) { const errorMsg = err.response.data?.message || err.message; // Git-Referenz-Konflikt: Branch hat sich geändert, SHA ist veraltet if (errorMsg.includes('cannot lock ref') || errorMsg.includes('failed to update ref') || errorMsg.includes('but expected')) { if (retryCount < MAX_RETRIES) { retryCount++; console.warn(`-> Git-Referenz-Konflikt erkannt. Branch hat sich geändert. Aktualisiere SHA und versuche erneut... (Retry ${retryCount}/${MAX_RETRIES})`); await sleep(500); // Kurze Pause continue; // SHA wird in der nächsten Iteration neu geholt } else { throw new Error(`Git-Referenz-Konflikt: Der Branch "${branchName}" wurde während des Uploads geändert. Bitte versuchen Sie es erneut.`); } } // Wenn es ein Lizenz-Fehler ist (sollte nicht passieren, aber als Fallback) if (errorMsg.includes('getLicense')) { throw new Error(`Server-Fehler beim Speichern: Lizenz-Problem. Versuchen Sie es erneut.`); } // Wenn es ein anderes Branch-Problem ist if (errorMsg.includes('branch') || errorMsg.includes('ref')) { throw new Error(`Server-Fehler: Problem mit Branch "${branchName}". Details: ${errorMsg}`); } // Allgemeiner 500-Fehler throw new Error(`Server-Fehler (500) beim Speichern der Datei. Details: ${errorMsg}`); } const isShaRequired = err.response && err.response.status === 422 && err.response.data && err.response.data.message && err.response.data.message.includes('[SHA]'); if (isShaRequired && retryCount < MAX_RETRIES) { retryCount++; console.warn(`-> 422 SHA Required. Waiting 1.5 seconds for server index update... (Retry ${retryCount}/${MAX_RETRIES})`); await sleep(1500); // Reduzierte Wartezeit für schnelleren Fallback // Schleife wird neu gestartet, SHA wird erneut gesucht continue; } else if (isShaRequired && retryCount >= MAX_RETRIES) { // Verbesserte Fehlermeldung mit Hinweis auf Git-Fallback const error = new Error(`API-Upload fehlgeschlagen: Repository wurde gerade erstellt, Index noch nicht bereit. Verwende Git-Fallback.`); error.code = 'SHA_NOT_FOUND'; throw error; } // Andere Fehler mit besserer Meldung werfen if (err.response) { const status = err.response.status; const data = err.response.data; if (status === 401) { throw new Error('Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren Token.'); } else if (status === 403) { throw new Error('Zugriff verweigert. Keine Berechtigung zum Schreiben in dieses Repository.'); } else if (status === 404) { throw new Error(`Datei oder Repository nicht gefunden. Bitte überprüfen Sie den Pfad: ${path}`); } else { throw new Error(`Fehler beim Speichern (${status}): ${data?.message || err.message}`); } } throw err; } } } /* ================================ COMMIT HISTORY FUNCTIONS (GITEA) ================================ */ /** * Get commit history from Gitea repository */ async function getGiteaCommits({ token, url, owner, repo, branch = 'HEAD', page = 1, limit = 50 }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` }, params: { sha: branch, page, limit } }); return response.data; } catch (err) { console.error('getGiteaCommits error:', err.response?.data || err.message); throw err; } } /** * Get a specific commit with diff */ async function getGiteaCommit({ token, url, owner, repo, sha }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits/${sha}`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (err) { console.error('getGiteaCommit error:', err.response?.data || err.message); throw err; } } /** * Get commit diff/patch */ async function getGiteaCommitDiff({ token, url, owner, repo, sha }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); // Gitea returns diff in the commit endpoint with .diff extension const endpoint = `${base}/${owner}/${repo}/commit/${sha}.diff`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (err) { console.error('getGiteaCommitDiff error:', err.response?.data || err.message); throw err; } } /** * Get commit file changes/stats */ async function getGiteaCommitFiles({ token, url, owner, repo, sha }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits/${sha}`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); // Gitea commit response includes stats const commit = response.data; // Normalize files to match local git format const files = (commit.files || []).map(f => ({ file: f.filename || f.file || '', changes: (f.additions || 0) + (f.deletions || 0), insertions: f.additions || 0, deletions: f.deletions || 0, binary: f.binary || false })); return { files, stats: commit.stats || { additions: 0, deletions: 0, total: 0 } }; } catch (err) { console.error('getGiteaCommitFiles error:', err.response?.data || err.message); throw err; } } /** * Search commits in Gitea repository */ async function searchGiteaCommits({ token, url, owner, repo, query, branch = 'HEAD' }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); // Gitea doesn't have direct commit search, so we get commits and filter const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` }, params: { sha: branch, limit: 100 // Get more for searching } }); // Filter commits by query const lowerQuery = query.toLowerCase(); const filtered = response.data.filter(commit => { const message = (commit.commit?.message || '').toLowerCase(); const author = (commit.commit?.author?.name || '').toLowerCase(); return message.includes(lowerQuery) || author.includes(lowerQuery); }); return filtered; } catch (err) { console.error('searchGiteaCommits error:', err.response?.data || err.message); throw err; } } /** * Get branches for branch graph visualization */ async function getGiteaBranches({ token, url, owner, repo }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (err) { console.error('getGiteaBranches error:', err.response?.data || err.message); throw err; } } /* ================================ RELEASE MANAGEMENT FUNCTIONS ================================ */ /** * List all releases for a repository */ async function listGiteaReleases({ token, url, owner, repo }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (err) { console.error('listGiteaReleases error:', err.response?.data || err.message); throw err; } } /** * Get a specific release by tag */ async function getGiteaRelease({ token, url, owner, repo, tag }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`; try { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}` } }); return response.data; } catch (err) { console.error('getGiteaRelease error:', err.response?.data || err.message); throw err; } } /** * Create a new release */ async function createGiteaRelease({ token, url, owner, repo, data }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`; const body = { tag_name: data.tag_name, name: data.name || data.tag_name, body: data.body || '', draft: data.draft || false, prerelease: data.prerelease || false, target_commitish: data.target_commitish || 'HEAD' }; try { const response = await axiosInstance.post(endpoint, body, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, timeout: 15000 }); return response.data; } catch (err) { console.error('createGiteaRelease error:', err.response?.data || err.message); // Benutzerfreundliche Fehlerbehandlung if (err.response) { const status = err.response.status; const data = err.response.data; if (status === 401) { throw new Error('Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren Token.'); } else if (status === 403) { throw new Error('Zugriff verweigert. Keine Berechtigung zum Erstellen von Releases.'); } else if (status === 404) { throw new Error(`Repository "${owner}/${repo}" nicht gefunden.`); } else if (status === 409 || (status === 422 && data?.message?.includes('already exists'))) { throw new Error(`Ein Release mit dem Tag "${data.tag_name}" existiert bereits.`); } else if (status === 422) { const msg = data?.message || 'Release konnte nicht erstellt werden'; throw new Error(`Gitea-Fehler: ${msg}`); } else if (status === 500) { const msg = data?.message || err.message; throw new Error(`Server-Fehler (500) beim Erstellen des Release. Details: ${msg}`); } else { throw new Error(`Fehler beim Erstellen des Release (${status}): ${data?.message || err.message}`); } } else if (err.code === 'ECONNABORTED') { throw new Error('Zeitüberschreitung. Bitte versuchen Sie es erneut.'); } else if (err.request) { throw new Error('Keine Antwort vom Server. Bitte überprüfen Sie Ihre Internetverbindung.'); } else { throw new Error(`Fehler beim Erstellen des Release: ${err.message}`); } } } /** * Edit/update an existing release */ async function editGiteaRelease({ token, url, owner, repo, releaseId, data }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`; const body = {}; if (data.name !== undefined) body.name = data.name; if (data.body !== undefined) body.body = data.body; if (data.draft !== undefined) body.draft = data.draft; if (data.prerelease !== undefined) body.prerelease = data.prerelease; if (data.tag_name !== undefined) body.tag_name = data.tag_name; try { const response = await axiosInstance.patch(endpoint, body, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' } }); return response.data; } catch (err) { console.error('editGiteaRelease error:', err.response?.data || err.message); throw err; } } /** * Delete a release */ async function deleteGiteaRelease({ token, url, owner, repo, releaseId }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`; try { await axiosInstance.delete(endpoint, { headers: { Authorization: `token ${token}` } }); return { ok: true }; } catch (err) { console.error('deleteGiteaRelease error:', err.response?.data || err.message); throw err; } } /** * Upload a release asset (attachment) * Note: Gitea uses multipart/form-data for asset uploads */ async function uploadReleaseAsset({ token, url, owner, repo, releaseId, filePath, fileName }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const FormData = require('form-data'); const fs = require('fs'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}/assets`; const formData = new FormData(); formData.append('attachment', fs.createReadStream(filePath), { filename: fileName || require('path').basename(filePath) }); try { const response = await axiosInstance.post(endpoint, formData, { headers: { Authorization: `token ${token}`, ...formData.getHeaders() }, maxContentLength: Infinity, maxBodyLength: Infinity }); return response.data; } catch (err) { console.error('uploadReleaseAsset error:', err.response?.data || err.message); throw err; } } /** * Delete a release asset */ async function deleteReleaseAsset({ token, url, owner, repo, assetId }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/assets/${assetId}`; try { await axiosInstance.delete(endpoint, { headers: { Authorization: `token ${token}` } }); return { ok: true }; } catch (err) { console.error('deleteReleaseAsset error:', err.response?.data || err.message); throw err; } } module.exports = { normalizeAndValidateBaseUrl, createRepoGitHub, createRepoGitea, checkGiteaConnection, listGiteaRepos, getGiteaUserHeatmap, getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile, // Commit History getGiteaCommits, getGiteaCommit, getGiteaCommitDiff, getGiteaCommitFiles, searchGiteaCommits, getGiteaBranches, // Release Management listGiteaReleases, getGiteaRelease, createGiteaRelease, editGiteaRelease, deleteGiteaRelease, uploadReleaseAsset, deleteReleaseAsset };