Files
Git-Manager-Gui/src/git/apiHandler.js
2026-03-25 23:07:09 +01:00

1967 lines
63 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,
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 = /<tool-tip\b[^>]*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
};