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