1086 lines
36 KiB
JavaScript
1086 lines
36 KiB
JavaScript
// 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
|
||
}; |