1967 lines
63 KiB
JavaScript
1967 lines
63 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,
|
||
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
|
||
}; |