Files
Git-Manager-Gui/src/git/apiHandler.js
2026-03-24 19:18:28 +01:00

1086 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
};