fix: support gitea git remote uploads

This commit is contained in:
Tom Boullay
2026-05-15 01:08:44 +02:00
parent 23253c2277
commit f53f606daa
10 changed files with 258 additions and 76 deletions
+228 -46
View File
@@ -8,29 +8,102 @@ const LFS_BATCH_SIZE = 100
type LogDetails = Record<string, string | number | boolean | undefined>
interface GitRemoteConfig {
apiBaseUrl: string
lfsBatchUrl: string
owner: string
provider: 'github' | 'gitea'
repo: string
token: string
webUrl: string
}
class GitApiError extends Error {
constructor(message: string, public status: number) {
super(message)
}
}
function isHttpError(err: unknown): err is { status: number } {
return isRecord(err) && typeof err.status === 'number'
}
function getOctokit(): Octokit {
const token = process.env.GITHUB_TOKEN
if (!token) throw new Error('GITHUB_TOKEN non configure')
return new Octokit({ auth: token })
function encodePath(path: string) {
return path.split('/').map(encodeURIComponent).join('/')
}
function parseRepoUrl(): { owner: string; repo: string } {
function getRepoApiPath(remote: GitRemoteConfig) {
return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}`
}
async function requestGitJson(
remote: GitRemoteConfig,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<unknown> {
const res = await fetch(`${remote.apiBaseUrl}${path}`, {
method: init.method ?? 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `token ${remote.token}`,
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
})
if (!res.ok) {
const text = await res.text()
throw new GitApiError(text || `Git API request failed (${res.status})`, res.status)
}
return res.json()
}
function getGitToken() {
const token = process.env.GIT_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()
if (!token) throw new Error('GIT_TOKEN non configure')
return token
}
function getOctokit(remote: GitRemoteConfig): Octokit {
return new Octokit({
auth: remote.token,
baseUrl: remote.apiBaseUrl,
})
}
function cleanRepoName(repo: string) {
return repo.replace(/\/+$/, '').replace(/\.git$/, '')
}
function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig {
const normalizedHost = host.toLowerCase()
const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}`
const isGitHub = normalizedHost === 'github.com'
return {
apiBaseUrl: isGitHub ? 'https://api.github.com' : `${origin}/api/v1`,
lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`,
owner,
provider: isGitHub ? 'github' : 'gitea',
repo,
token: getGitToken(),
webUrl: `${origin}/${owner}/${repo}`,
}
}
function parseRepoUrl(): GitRemoteConfig {
const url = process.env.GIT_REPO_URL?.trim()
if (!url) throw new Error('GIT_REPO_URL non configure')
const cleanRepoName = (repo: string) => repo.replace(/\/+$/, '').replace(/\.git$/, '')
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
if (shortMatch) {
return { owner: shortMatch[1], repo: cleanRepoName(shortMatch[2]) }
return buildRemoteConfig('github.com', shortMatch[1], cleanRepoName(shortMatch[2]))
}
const sshMatch = url.match(/github\.com:([^/\s]+)\/(.+)$/)
if (sshMatch) {
return { owner: sshMatch[1], repo: cleanRepoName(sshMatch[2]) }
const scpLikeMatch = !url.includes('://') ? url.match(/^(?:[^@\s]+@)?([^:\s]+):([^/\s]+)\/([^/\s]+)$/) : null
if (scpLikeMatch) {
return buildRemoteConfig(scpLikeMatch[1], scpLikeMatch[2], cleanRepoName(scpLikeMatch[3]))
}
if (URL.canParse(url)) {
@@ -40,8 +113,8 @@ function parseRepoUrl(): { owner: string; repo: string } {
.split('/')
.filter(Boolean)
if (parsed.hostname === 'github.com' && pathParts.length >= 2) {
return { owner: pathParts[0], repo: cleanRepoName(pathParts[1]) }
if ((parsed.protocol === 'https:' || parsed.protocol === 'http:' || parsed.protocol === 'ssh:') && pathParts.length >= 2) {
return buildRemoteConfig(parsed.hostname, pathParts[0], cleanRepoName(pathParts[1]), parsed.protocol)
}
}
@@ -65,6 +138,44 @@ function parseLfsPointer(content: string): { oid: string; size: number } | null
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
}
interface GitContentEntry {
content?: string
name: string
path: string
sha: string
size: number
type?: string
}
function isGitContentEntry(value: unknown): value is GitContentEntry {
return isRecord(value)
&& typeof value.name === 'string'
&& typeof value.path === 'string'
&& typeof value.sha === 'string'
&& typeof value.size === 'number'
&& (value.content === undefined || typeof value.content === 'string')
&& (value.type === undefined || typeof value.type === 'string')
}
function decodeBase64Content(content: string) {
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
}
async function getRemoteContent(remote: GitRemoteConfig, path: string, branch: string) {
const query = new URLSearchParams({ ref: branch })
return requestGitJson(remote, `${getRepoApiPath(remote)}/contents/${encodePath(path)}?${query.toString()}`)
}
async function getRemoteFileEntry(remote: GitRemoteConfig, path: string, branch: string): Promise<GitContentEntry | null> {
try {
const data = await getRemoteContent(remote, path, branch)
return isGitContentEntry(data) ? data : null
} catch (err) {
if (isHttpError(err) && err.status === 404) return null
throw err
}
}
function formatElapsed(startedAt: number) {
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
}
@@ -89,6 +200,11 @@ interface LfsObject {
contentBase64: string
}
type LfsPushFile = PushFile & {
oid: string
size: number
}
interface LfsBatchAction {
href: string
header?: Record<string, string>
@@ -151,8 +267,7 @@ function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
}
async function uploadToLfs(
owner: string,
repo: string,
remote: GitRemoteConfig,
objects: LfsObject[],
): Promise<void> {
if (objects.length === 0) return
@@ -160,13 +275,12 @@ async function uploadToLfs(
const batches = chunkArray(objects, LFS_BATCH_SIZE)
for (let i = 0; i < batches.length; i++) {
await uploadToLfsBatch(owner, repo, batches[i], i + 1, batches.length)
await uploadToLfsBatch(remote, batches[i], i + 1, batches.length)
}
}
async function uploadToLfsBatch(
owner: string,
repo: string,
remote: GitRemoteConfig,
objects: LfsObject[],
batchNumber: number,
totalBatches: number,
@@ -176,15 +290,12 @@ async function uploadToLfsBatch(
objects: objects.length,
})
const token = process.env.GITHUB_TOKEN!
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
const batchRes = await fetch(lfsUrl, {
const batchRes = await fetch(remote.lfsBatchUrl, {
method: 'POST',
headers: {
'Accept': 'application/vnd.git-lfs+json',
'Content-Type': 'application/vnd.git-lfs+json',
'Authorization': `token ${token}`,
'Authorization': `token ${remote.token}`,
},
body: JSON.stringify({
operation: 'upload',
@@ -262,41 +373,107 @@ async function uploadToLfsBatch(
})
}
interface GiteaFileOperation {
content?: string
operation: 'create' | 'update' | 'delete'
path: string
sha?: string
}
function getGiteaCommitUrl(value: unknown, remote: GitRemoteConfig, branch: string) {
if (isRecord(value) && isRecord(value.commit)) {
if (typeof value.commit.html_url === 'string') return value.commit.html_url
if (typeof value.commit.sha === 'string') return `${remote.webUrl}/commit/${value.commit.sha}`
}
return `${remote.webUrl}/commits/branch/${branch}`
}
async function pushAllToGitea(
remote: GitRemoteConfig,
regularFiles: PushFile[],
lfsFiles: LfsPushFile[],
deletePaths: string[],
commitMessage: string,
branch: string,
): Promise<{ commitUrl: string }> {
const committedFiles: PushFile[] = [
...regularFiles,
...lfsFiles.map((file) => ({
path: file.path,
contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'),
})),
]
const newFilePaths = new Set(committedFiles.map((file) => file.path))
const operations: GiteaFileOperation[] = []
for (const file of committedFiles) {
const existing = await getRemoteFileEntry(remote, file.path, branch)
operations.push({
content: file.contentBase64,
operation: existing ? 'update' : 'create',
path: file.path,
sha: existing?.sha,
})
}
for (const path of deletePaths) {
if (newFilePaths.has(path)) continue
const existing = await getRemoteFileEntry(remote, path, branch)
if (!existing) continue
operations.push({
operation: 'delete',
path,
sha: existing.sha,
})
}
if (operations.length === 0) {
return { commitUrl: `${remote.webUrl}/commits/branch/${branch}` }
}
const data = await requestGitJson(remote, `${getRepoApiPath(remote)}/contents`, {
method: 'POST',
body: {
branch,
files: operations,
message: commitMessage,
},
})
return { commitUrl: getGiteaCommitUrl(data, remote, branch) }
}
export async function getRemoteFolder(
folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> {
const octokit = getOctokit()
const { owner, repo } = parseRepoUrl()
const remote = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
try {
const { data } = await octokit.repos.getContent({
owner,
repo,
path: folderPath,
ref: branch,
})
const data = await getRemoteContent(remote, folderPath, branch)
if (!Array.isArray(data)) {
throw new Error(`Le chemin distant ${folderPath} existe mais ce n'est pas un dossier`)
}
const files: RemoteFile[] = await Promise.all(
data.map(async (f): Promise<RemoteFile> => {
data.map(async (f: unknown): Promise<RemoteFile> => {
if (!isGitContentEntry(f)) {
throw new Error(`Reponse Git invalide pour ${folderPath}`)
}
if (!isLfsFile(f.name) || f.size > 1024) {
return { name: f.name, size: f.size }
}
try {
const { data: fileData } = await octokit.repos.getContent({
owner,
repo,
path: `${folderPath}/${f.name}`,
ref: branch,
})
const fileData = await getRemoteFileEntry(remote, `${folderPath}/${f.name}`, branch)
if (!Array.isArray(fileData) && 'content' in fileData && fileData.content) {
const content = Buffer.from(fileData.content, 'base64').toString('utf-8')
if (fileData?.content) {
const content = decodeBase64Content(fileData.content)
const pointer = parseLfsPointer(content)
if (pointer) {
return { name: f.name, size: pointer.size }
@@ -319,16 +496,15 @@ export async function getRemoteFolder(
}
}
export async function pushAllToGitHub(
export async function pushAllToGit(
files: PushFile[],
deletePaths: string[],
commitMessage: string,
): Promise<{ commitUrl: string }> {
const octokit = getOctokit()
const { owner, repo } = parseRepoUrl()
const remote = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
const lfsFiles: LfsPushFile[] = []
const regularFiles: PushFile[] = []
for (const f of files) {
@@ -343,12 +519,18 @@ export async function pushAllToGitHub(
if (lfsFiles.length > 0) {
await uploadToLfs(
owner,
repo,
remote,
lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })),
)
}
if (remote.provider === 'gitea') {
return pushAllToGitea(remote, regularFiles, lfsFiles, deletePaths, commitMessage, branch)
}
const octokit = getOctokit(remote)
const { owner, repo } = remote
const { data: ref } = await octokit.git.getRef({
owner,
repo,
@@ -425,5 +607,5 @@ export async function pushAllToGitHub(
sha: newCommit.sha,
})
return { commitUrl: newCommit.html_url }
return { commitUrl: newCommit.html_url || `${remote.webUrl}/commit/${newCommit.sha}` }
}