// 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, description = '', homepage = '' }) { const body = { name, private: isPrivate, auto_init: auto_init }; if (description) body.description = description; if (homepage) body.homepage = homepage; // 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'; // 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; } try { const response = await axiosInstance.post(endpoint, body, { headers: { Authorization: `token ${token}` }, timeout: 15000 // 15 Sekunden Timeout }); 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.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 base = normalizeBase(url); const endpoint = `${base}/api/v1/user/repos`; const repoKey = (repo) => ( repo?.full_name || `${repo?.owner?.login || repo?.owner?.username || ''}/${repo?.name || ''}` ); const fetchAllPages = async (extraParams = {}) => { const perPage = 100; const maxPages = 50; const all = []; for (let page = 1; page <= maxPages; page += 1) { const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' }, params: { page, limit: perPage, ...extraParams }, timeout: 15000 }); const repos = Array.isArray(response.data) ? response.data : []; all.push(...repos); if (repos.length < perPage) break; } return all; }; // Manche Gitea-Versionen liefern mit mode=all explizit auch private/collab Repos. let repos = []; try { repos = await fetchAllPages({ mode: 'all', affiliation: 'owner,collaborator,organization_member' }); } catch (_) { repos = await fetchAllPages({ affiliation: 'owner,collaborator,organization_member' }); } // Fallback: private separat abfragen und mergen, falls der erste Abruf sie nicht enthielt. if (!repos.some(r => !!r?.private)) { try { const privateRepos = await fetchAllPages({ mode: 'private' }); const byKey = new Map(); for (const repo of repos) { const key = repoKey(repo); byKey.set(key, repo); } for (const repo of privateRepos) { const key = repoKey(repo); if (!byKey.has(key)) byKey.set(key, repo); } repos = Array.from(byKey.values()); } catch (_) { // ignorieren: nicht jede Gitea-Version unterstützt mode=private } } return repos; } async function getGiteaCurrentUser({ token, url }) { const endpoint = normalizeBase(url) + '/api/v1/user'; const response = await axiosInstance.get(endpoint, { headers: { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' }, timeout: 10000 }); return response.data; } async function listGiteaTopicsCatalog({ token, url, maxPages = 20, perPage = 100 }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); const topics = new Set(); let page = 1; while (page <= Math.max(1, maxPages)) { const response = await axiosInstance.get(`${base}/api/v1/user/repos`, { headers: { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' }, params: { page, limit: perPage }, timeout: 15000 }); const repos = Array.isArray(response.data) ? response.data : []; for (const repo of repos) { const repoTopics = Array.isArray(repo?.topics) ? repo.topics : []; for (const t of repoTopics) { const s = String(t || '').trim(); if (s) topics.add(s); } } if (repos.length < perPage) break; page += 1; } return Array.from(topics).sort((a, b) => a.localeCompare(b, 'de')); } 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; } } async function migrateRepoToGitea({ token, url, cloneUrl, repoName, description, isPrivate, authToken, authUsername }) { const base = normalizeAndValidateBaseUrl(url); const body = { clone_addr: cloneUrl, repo_name: repoName, description: description || '', private: isPrivate || false, issues: true, labels: true, milestones: true, releases: true, wiki: true, pull_requests: true }; // Optionale Auth für private Source-Repos (z.B. GitHub-Token) if (authUsername) body.auth_username = authUsername; if (authToken) body.auth_token = authToken; const res = await axiosInstance.post( `${base}/api/v1/repos/migrate`, body, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 60000 } ); return res.data; } async function updateGiteaRepoAvatar({ token, url, owner, repo, imageBase64 }) { const base = normalizeAndValidateBaseUrl(url); const pureBase64 = imageBase64.replace(/^data:[^;]+;base64,/, ''); await axiosInstance.post( `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`, { image: pureBase64 }, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 } ); } async function updateGiteaRepoVisibility({ token, url, owner, repo, isPrivate }) { const base = normalizeAndValidateBaseUrl(url); const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const res = await axiosInstance.patch( endpoint, { private: !!isPrivate }, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 } ); return res.data; } async function updateGiteaRepoTopics({ token, url, owner, repo, topics }) { const base = normalizeAndValidateBaseUrl(url); const repoPath = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const safeTopics = Array.isArray(topics) ? topics : []; try { const res = await axiosInstance.put( `${base}/api/v1/repos/${repoPath}/topics`, { topics: safeTopics }, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 } ); return res.data; } catch (err) { // Fallback fuer aeltere/abweichende Gitea-Versionen const res = await axiosInstance.patch( `${base}/api/v1/repos/${repoPath}`, { topics: safeTopics }, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 } ); return res.data; } } /* ==================================================== GITHUB API FUNCTIONS ==================================================== */ const GITHUB_API = 'https://api.github.com'; function githubHeaders(token) { return { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; } function parseGithubLinkHeader(header) { if (!header) return {}; const links = {}; header.split(',').forEach(part => { const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); if (match) links[match[2]] = match[1]; }); return links; } async function listGithubRepos({ token }) { if (!token) throw new Error('GitHub Token fehlt. Bitte Token in Settings eintragen.'); const fetchPaged = async (startUrl) => { const allRepos = []; let nextUrl = startUrl; while (nextUrl) { const response = await axiosInstance.get(nextUrl, { headers: githubHeaders(token), timeout: 15000 }); const repos = Array.isArray(response.data) ? response.data : []; allRepos.push(...repos); const links = parseGithubLinkHeader(response.headers['link']); nextUrl = links.next || null; } return allRepos; }; const primaryUrl = `${GITHUB_API}/user/repos?affiliation=owner,collaborator,organization_member&per_page=100&page=1`; const fallbackUrl = `${GITHUB_API}/user/repos?type=all&per_page=100&page=1`; try { return await fetchPaged(primaryUrl); } catch (error) { if (error?.response?.status === 422) { return await fetchPaged(fallbackUrl); } throw error; } } async function getGithubCurrentUser({ token }) { if (!token) throw new Error('GitHub Token fehlt.'); const response = await axiosInstance.get(`${GITHUB_API}/user`, { headers: githubHeaders(token), timeout: 10000 }); return response.data; } async function getGithubUserHeatmap({ token, monthsBack = 20 }) { if (!token) throw new Error('GitHub Token fehlt.'); const me = await getGithubCurrentUser({ token }); const username = me?.login || null; if (!username) throw new Error('GitHub Benutzer konnte nicht ermittelt werden.'); const to = new Date(); const from = new Date(to.getFullYear(), to.getMonth(), to.getDate()); from.setMonth(from.getMonth() - Math.max(1, Number(monthsBack) || 20)); const formatDate = (d) => { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; }; const query = ` query($login: String!, $from: DateTime!, $to: DateTime!) { user(login: $login) { contributionsCollection(from: $from, to: $to) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } } `; // Primaere Quelle: derselbe Contribution-Calendar-Endpunkt wie im GitHub-Profil. // Das liefert die sichtbar "echten" Daten der Profilansicht. try { const contributionsUrl = `https://github.com/users/${encodeURIComponent(username)}/contributions` + `?from=${encodeURIComponent(formatDate(from))}&to=${encodeURIComponent(formatDate(to))}`; const response = await axiosInstance.get(contributionsUrl, { headers: { 'User-Agent': 'Git-Manager-GUI', 'Accept': 'text/html,application/xhtml+xml,image/svg+xml' }, timeout: 20000 }); const html = typeof response.data === 'string' ? response.data : ''; const acc = new Map(); const dayTags = html.match(/<[^>]*ContributionCalendar-day[^>]*>/gi) || []; const readAttr = (tag, attr) => { const rx = new RegExp(`${attr}\\s*=\\s*(["'])(.*?)\\1`, 'i'); const m = tag.match(rx); return m ? m[2] : ''; }; const tooltipCountById = new Map(); const tooltipRegex = /]*for=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/tool-tip>/gi; let tooltipMatch; while ((tooltipMatch = tooltipRegex.exec(html)) !== null) { const targetId = tooltipMatch[2] || ''; const body = String(tooltipMatch[3] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); if (!targetId || !body) continue; if (/^No contributions?/i.test(body)) { tooltipCountById.set(targetId, 0); continue; } const m = body.match(/(\d+)\s+contributions?/i); if (m) { tooltipCountById.set(targetId, Number(m[1])); } } for (const tag of dayTags) { const date = normalizeHeatmapEntryDate(readAttr(tag, 'data-date')); if (!date) continue; const id = readAttr(tag, 'id'); const level = Number(readAttr(tag, 'data-level')); const rawCount = readAttr(tag, 'data-count'); const parsedDataCount = rawCount === '' ? Number.NaN : Number(rawCount); let count = Number.NaN; // 1) Tooltips enthalten i.d.R. den echten Wert aus dem GitHub-Graphen. if (id && tooltipCountById.has(id)) { count = Number(tooltipCountById.get(id)); } // 2) Fallback auf data-count. if (!Number.isFinite(count) && Number.isFinite(parsedDataCount)) { count = parsedDataCount; } if (!Number.isFinite(count)) { const aria = readAttr(tag, 'aria-label'); const ariaMatch = aria.match(/(\d+)\s+contributions?/i); if (ariaMatch) { count = Number(ariaMatch[1]); } } if (!Number.isFinite(count)) { count = Number.isFinite(level) && level > 0 ? level : 0; } // Sicherheitsnetz: manche GitHub-Varianten liefern data-count=0, // obwohl data-level > 0 ist. Dann zumindest Intensitaet als Aktivitaet nutzen. if (Number.isFinite(level) && level > 0 && Number.isFinite(count) && count <= 0) { count = level; } const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; acc.set(date, safeCount); } if (acc.size > 0) { const entries = Array.from(acc.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); return { username, endpoint: contributionsUrl, entries }; } } catch (_) { // Fallback auf API-basierte Quellen unten. } // GitHub GraphQL liefert in der Praxis nur Bereiche bis max. ca. 1 Jahr stabil, // deshalb teilen wir den gewünschten Zeitraum in mehrere Segmente. const ranges = []; let cursorTo = new Date(to); while (cursorTo >= from) { const cursorFrom = new Date(cursorTo.getFullYear() - 1, cursorTo.getMonth(), cursorTo.getDate() + 1); const segmentFrom = cursorFrom < from ? new Date(from) : cursorFrom; ranges.push({ from: segmentFrom, to: new Date(cursorTo) }); cursorTo = new Date(segmentFrom); cursorTo.setDate(cursorTo.getDate() - 1); } const graphAcc = new Map(); let graphOk = false; for (const range of ranges) { try { const response = await axiosInstance.post( `${GITHUB_API}/graphql`, { query, variables: { login: username, from: range.from.toISOString(), to: range.to.toISOString() } }, { headers: githubHeaders(token), timeout: 20000, validateStatus: () => true } ); if (!(response.status >= 200 && response.status < 300) || response.data?.errors) { continue; } const weeks = response.data?.data?.user?.contributionsCollection?.contributionCalendar?.weeks || []; for (const week of weeks) { const days = week?.contributionDays || []; for (const day of days) { const date = normalizeHeatmapEntryDate(day?.date); if (!date) continue; const count = Number(day?.contributionCount || 0); const n = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; graphAcc.set(date, (graphAcc.get(date) || 0) + n); } } graphOk = true; } catch (_) { // probiere naechstes Segment } } if (graphOk && graphAcc.size > 0) { const entries = Array.from(graphAcc.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); return { username, endpoint: `${GITHUB_API}/graphql (chunked)`, entries }; } try { let nextUrl = `${GITHUB_API}/users/${encodeURIComponent(username)}/events?per_page=100&page=1`; let pageCount = 0; const acc = new Map(); while (nextUrl && pageCount < 10) { const response = await axiosInstance.get(nextUrl, { headers: githubHeaders(token), timeout: 15000 }); const events = Array.isArray(response.data) ? response.data : []; for (const ev of events) { const date = normalizeHeatmapEntryDate(ev?.created_at); if (!date) continue; acc.set(date, (acc.get(date) || 0) + 1); } const links = parseGithubLinkHeader(response.headers?.link); nextUrl = links.next || null; pageCount += 1; } if (acc.size > 0) { const entries = Array.from(acc.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); return { username, endpoint: `${GITHUB_API}/users/${encodeURIComponent(username)}/events`, entries }; } } catch (_) { // letztes Fallback unten } return { username, endpoint: 'none', entries: [] }; } async function getGithubRepoContents({ token, owner, repo, path = '', ref = 'HEAD' }) { const refParam = (ref && ref !== 'HEAD') ? `?ref=${encodeURIComponent(ref)}` : ''; const safePath = path ? path.split('/').map(encodeURIComponent).join('/') : ''; const endpoint = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${safePath}${refParam}`; try { const response = await axiosInstance.get(endpoint, { headers: githubHeaders(token), timeout: 15000 }); const payload = response.data; if (Array.isArray(payload)) { return { ok: true, items: payload.map(item => ({ name: item.name, path: item.path, type: item.type === 'dir' ? 'dir' : 'file', size: item.size || 0, download_url: item.download_url || null, sha: item.sha || null })) }; } return { ok: true, items: [{ name: payload.name, path: payload.path, type: payload.type === 'dir' ? 'dir' : 'file', size: payload.size || 0, download_url: payload.download_url || null, sha: payload.sha || null }] }; } catch (err) { if (err.response?.status === 404) { return { ok: true, items: [], empty: true }; } throw err; } } async function getGithubFileSha({ token, owner, repo, path, ref = 'HEAD' }) { const refParam = (ref && ref !== 'HEAD') ? `?ref=${encodeURIComponent(ref)}` : ''; const safePath = path.split('/').map(encodeURIComponent).join('/'); const endpoint = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${safePath}${refParam}`; try { const response = await axiosInstance.get(endpoint, { headers: githubHeaders(token), timeout: 10000 }); return response.data?.sha || null; } catch (_) { return null; } } async function getGithubFileContent({ token, owner, repo, path, ref = 'HEAD' }) { const refParam = (ref && ref !== 'HEAD') ? `?ref=${encodeURIComponent(ref)}` : ''; const safePath = path.split('/').map(encodeURIComponent).join('/'); const endpoint = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${safePath}${refParam}`; const response = await axiosInstance.get(endpoint, { headers: githubHeaders(token), timeout: 15000 }); const data = response.data; if (data.content) { return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf8'); } if (data.download_url) { const rawRes = await axiosInstance.get(data.download_url, { headers: { Authorization: `token ${token}`, 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 }); return typeof rawRes.data === 'string' ? rawRes.data : JSON.stringify(rawRes.data, null, 2); } throw new Error('GitHub Dateiinhalt nicht verfügbar.'); } async function uploadGithubFile({ token, owner, repo, path, contentBase64, message = 'Upload via Git Manager GUI', branch = 'main' }) { const safePath = path.split('/').map(encodeURIComponent).join('/'); const endpoint = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${safePath}`; const sha = await getGithubFileSha({ token, owner, repo, path, ref: branch }); const body = { message, content: contentBase64, branch }; if (sha) body.sha = sha; const response = await axiosInstance.put(endpoint, body, { headers: githubHeaders(token), timeout: 30000 }); return response.data; } async function deleteGithubFile({ token, owner, repo, path, message = 'Delete via Git Manager GUI', sha, branch = 'main' }) { let fileSha = sha; if (!fileSha) { fileSha = await getGithubFileSha({ token, owner, repo, path, ref: branch }); } if (!fileSha) throw new Error(`SHA für Datei nicht gefunden: ${path}`); const safePath = path.split('/').map(encodeURIComponent).join('/'); const endpoint = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${safePath}`; await axiosInstance.delete(endpoint, { headers: githubHeaders(token), data: { message, sha: fileSha, branch }, timeout: 15000 }); return { ok: true }; } async function getGithubCommits({ token, owner, repo, branch = '', page = 1, limit = 50 }) { const params = { per_page: limit, page }; if (branch && branch !== 'HEAD') params.sha = branch; const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`, { headers: githubHeaders(token), params, timeout: 15000 } ); return (response.data || []).map(c => ({ sha: c.sha, commit: { message: c.commit?.message || '', author: { name: c.commit?.author?.name || '', email: c.commit?.author?.email || '', date: c.commit?.author?.date || '' }, committer: { name: c.commit?.committer?.name || '', date: c.commit?.committer?.date || '' } }, author: c.author ? { login: c.author.login, avatar_url: c.author.avatar_url } : null, html_url: c.html_url || '' })); } async function getGithubCommitDiff({ token, owner, repo, sha }) { const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}`, { headers: { ...githubHeaders(token), 'Accept': 'application/vnd.github.diff' }, timeout: 15000 } ); return response.data; } async function getGithubCommitFiles({ token, owner, repo, sha }) { const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}`, { headers: githubHeaders(token), timeout: 15000 } ); const commit = response.data; const files = (commit.files || []).map(f => ({ file: f.filename || '', changes: f.changes || 0, insertions: f.additions || 0, deletions: f.deletions || 0, binary: f.status === 'binary' || false, status: f.status || '' })); return { files, stats: { additions: commit.stats?.additions || 0, deletions: commit.stats?.deletions || 0, total: commit.stats?.total || 0 } }; } async function searchGithubCommits({ token, owner, repo, query, branch = '' }) { const params = { per_page: 100 }; if (branch && branch !== 'HEAD') params.sha = branch; const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`, { headers: githubHeaders(token), params, timeout: 15000 } ); const lowerQuery = query.toLowerCase(); return (response.data || []).filter(c => { const message = (c.commit?.message || '').toLowerCase(); const author = (c.commit?.author?.name || '').toLowerCase(); return message.includes(lowerQuery) || author.includes(lowerQuery); }).map(c => ({ sha: c.sha, commit: { message: c.commit?.message || '', author: { name: c.commit?.author?.name || '', email: c.commit?.author?.email || '', date: c.commit?.author?.date || '' } }, author: c.author ? { login: c.author.login, avatar_url: c.author.avatar_url } : null, html_url: c.html_url || '' })); } async function getGithubBranches({ token, owner, repo }) { const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, { headers: githubHeaders(token), params: { per_page: 100 }, timeout: 15000 } ); return (response.data || []).map(b => ({ name: b.name, commit: { id: b.commit?.sha || '', sha: b.commit?.sha || '' }, protected: b.protected || false })); } async function listGithubReleases({ token, owner, repo }) { const response = await axiosInstance.get( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, { headers: githubHeaders(token), params: { per_page: 100 }, timeout: 15000 } ); return (response.data || []).map(r => ({ id: r.id, tag_name: r.tag_name, name: r.name || r.tag_name, body: r.body || '', draft: r.draft || false, prerelease: r.prerelease || false, created_at: r.created_at, published_at: r.published_at, html_url: r.html_url || '', assets: (r.assets || []).map(a => ({ id: a.id, name: a.name, size: a.size, download_count: a.download_count, browser_download_url: a.browser_download_url })) })); } async function createGithubRelease({ token, owner, repo, data }) { 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 || 'main' }; const response = await axiosInstance.post( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, body, { headers: githubHeaders(token), timeout: 15000 } ); return response.data; } async function editGithubRelease({ token, owner, repo, releaseId, data }) { 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; const response = await axiosInstance.patch( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`, body, { headers: githubHeaders(token), timeout: 15000 } ); return response.data; } async function deleteGithubRelease({ token, owner, repo, releaseId }) { await axiosInstance.delete( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`, { headers: githubHeaders(token), timeout: 15000 } ); return { ok: true }; } async function updateGithubRepoVisibility({ token, owner, repo, isPrivate }) { const response = await axiosInstance.patch( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { private: !!isPrivate }, { headers: githubHeaders(token), timeout: 15000 } ); return response.data; } async function updateGithubRepoTopics({ token, owner, repo, topics }) { const response = await axiosInstance.put( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/topics`, { names: Array.isArray(topics) ? topics : [] }, { headers: { ...githubHeaders(token), 'Accept': 'application/vnd.github.mercy-preview+json' }, timeout: 15000 } ); return response.data; } async function deleteGithubRepo({ token, owner, repo }) { await axiosInstance.delete( `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { headers: githubHeaders(token), timeout: 15000 } ); return { ok: true }; } /* ==================================================== END GITHUB API FUNCTIONS ==================================================== */ async function updateGiteaAvatar({ token, url, imageBase64 }) { const base = normalizeAndValidateBaseUrl(url); // Strip data-URL prefix if present (e.g. "data:image/png;base64,") const pureBase64 = imageBase64.replace(/^data:[^;]+;base64,/, ''); await axiosInstance.post( `${base}/api/v1/user/avatar`, { image: pureBase64 }, { headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'Git-Manager-GUI' }, timeout: 15000 } ); } module.exports = { normalizeAndValidateBaseUrl, createRepoGitHub, createRepoGitea, checkGiteaConnection, updateGiteaAvatar, updateGiteaRepoAvatar, updateGiteaRepoVisibility, updateGiteaRepoTopics, migrateRepoToGitea, listGiteaTopicsCatalog, getGiteaCurrentUser, listGiteaRepos, getGiteaUserHeatmap, getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile, // Commit History getGiteaCommits, getGiteaCommit, getGiteaCommitDiff, getGiteaCommitFiles, searchGiteaCommits, getGiteaBranches, // Release Management listGiteaReleases, getGiteaRelease, createGiteaRelease, editGiteaRelease, deleteGiteaRelease, uploadReleaseAsset, deleteReleaseAsset, // GitHub API listGithubRepos, getGithubCurrentUser, getGithubUserHeatmap, getGithubRepoContents, getGithubFileContent, uploadGithubFile, deleteGithubFile, getGithubCommits, getGithubCommitDiff, getGithubCommitFiles, searchGithubCommits, getGithubBranches, listGithubReleases, createGithubRelease, editGithubRelease, deleteGithubRelease, updateGithubRepoVisibility, updateGithubRepoTopics, deleteGithubRepo };