refactor: split git provider adapters
This commit is contained in:
+233
@@ -0,0 +1,233 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { LFS_EXTENSIONS } from '@/lib/constants'
|
||||
import { isRecord } from '@/lib/guards'
|
||||
import type { GitRemoteConfig, LfsObject, LfsPushFile } from './types'
|
||||
import type { PushFile } from '@/lib/types'
|
||||
|
||||
const LFS_BATCH_SIZE = 100
|
||||
|
||||
type LogDetails = Record<string, string | number | boolean | undefined>
|
||||
|
||||
interface LfsBatchAction {
|
||||
href: string
|
||||
header?: Record<string, string>
|
||||
}
|
||||
|
||||
interface LfsBatchObject {
|
||||
oid: string
|
||||
size: number
|
||||
actions?: {
|
||||
upload?: LfsBatchAction
|
||||
verify?: LfsBatchAction
|
||||
}
|
||||
error?: { code: number; message: string }
|
||||
}
|
||||
|
||||
export function isLfsFile(filePath: string): boolean {
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
|
||||
return LFS_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
function formatElapsed(startedAt: number) {
|
||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) {
|
||||
console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
||||
}
|
||||
|
||||
function chunkArray<T>(items: T[], size: number) {
|
||||
const chunks: T[][] = []
|
||||
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
|
||||
}
|
||||
|
||||
function parseLfsAction(value: unknown): LfsBatchAction | undefined {
|
||||
if (!isRecord(value) || typeof value.href !== 'string') return undefined
|
||||
return {
|
||||
href: value.href,
|
||||
header: isStringRecord(value.header) ? value.header : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
|
||||
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
|
||||
|
||||
const actions = isRecord(value.actions)
|
||||
? {
|
||||
upload: parseLfsAction(value.actions.upload),
|
||||
verify: parseLfsAction(value.actions.verify),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
|
||||
? { code: value.error.code, message: value.error.message }
|
||||
: undefined
|
||||
|
||||
return { oid: value.oid, size: value.size, actions, error }
|
||||
}
|
||||
|
||||
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.objects)) {
|
||||
throw new Error('LFS batch response invalide')
|
||||
}
|
||||
|
||||
const objects: LfsBatchObject[] = []
|
||||
for (const object of value.objects) {
|
||||
const parsed = parseLfsBatchObject(object)
|
||||
if (!parsed) {
|
||||
throw new Error('LFS batch object invalide')
|
||||
}
|
||||
objects.push(parsed)
|
||||
}
|
||||
|
||||
return objects
|
||||
}
|
||||
|
||||
function getLfsAuthorizationHeader(remote: GitRemoteConfig) {
|
||||
if (remote.provider === 'github') return `token ${remote.token}`
|
||||
|
||||
if (!remote.username) {
|
||||
throw new Error('GIT_USERNAME non configure pour Git LFS sur Gitea')
|
||||
}
|
||||
|
||||
return `Basic ${Buffer.from(`${remote.username}:${remote.token}`, 'utf-8').toString('base64')}`
|
||||
}
|
||||
|
||||
export function buildLfsPointer(sha256: string, size: number): string {
|
||||
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
|
||||
}
|
||||
|
||||
export function parseLfsPointer(content: string): { oid: string; size: number } | null {
|
||||
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
|
||||
const sizeMatch = content.match(/^size (\d+)$/m)
|
||||
const oidMatch = content.match(/^oid sha256:([a-f0-9]{64})$/m)
|
||||
if (!sizeMatch || !oidMatch) return null
|
||||
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
|
||||
}
|
||||
|
||||
export function splitLfsFiles(files: PushFile[]) {
|
||||
const lfsFiles: LfsPushFile[] = []
|
||||
const regularFiles: PushFile[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (isLfsFile(file.path)) {
|
||||
const buffer = Buffer.from(file.contentBase64, 'base64')
|
||||
const oid = createHash('sha256').update(buffer).digest('hex')
|
||||
lfsFiles.push({ ...file, oid, size: buffer.length })
|
||||
} else {
|
||||
regularFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return { lfsFiles, regularFiles }
|
||||
}
|
||||
|
||||
export async function uploadToLfs(remote: GitRemoteConfig, objects: LfsObject[]): Promise<void> {
|
||||
if (objects.length === 0) return
|
||||
|
||||
const batches = chunkArray(objects, LFS_BATCH_SIZE)
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
await uploadToLfsBatch(remote, batches[i], i + 1, batches.length)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToLfsBatch(
|
||||
remote: GitRemoteConfig,
|
||||
objects: LfsObject[],
|
||||
batchNumber: number,
|
||||
totalBatches: number,
|
||||
): Promise<void> {
|
||||
const startedAt = performance.now()
|
||||
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} started`, startedAt, {
|
||||
objects: objects.length,
|
||||
})
|
||||
|
||||
const batchRes = await fetch(remote.lfsBatchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.git-lfs+json',
|
||||
'Content-Type': 'application/vnd.git-lfs+json',
|
||||
'Authorization': getLfsAuthorizationHeader(remote),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'upload',
|
||||
transfers: ['basic'],
|
||||
objects: objects.map((object) => ({ oid: object.oid, size: object.size })),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!batchRes.ok) {
|
||||
const text = await batchRes.text()
|
||||
console.error(`[ERROR] Git LFS -> Batch ${batchNumber}/${totalBatches} failed | Timer: ${formatElapsed(startedAt)}`, {
|
||||
objects: objects.length,
|
||||
status: batchRes.status,
|
||||
})
|
||||
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
|
||||
}
|
||||
|
||||
const batchData: unknown = await batchRes.json()
|
||||
const batchObjects = parseLfsBatchResponse(batchData)
|
||||
const objectMap = new Map(objects.map((object) => [object.oid, object]))
|
||||
|
||||
for (const object of batchObjects) {
|
||||
if (object.error) {
|
||||
throw new Error(`LFS error for ${object.oid}: ${object.error.message} (${object.error.code})`)
|
||||
}
|
||||
|
||||
if (!object.actions?.upload) continue
|
||||
|
||||
const local = objectMap.get(object.oid)
|
||||
if (!local) continue
|
||||
|
||||
const uploadAction = object.actions.upload
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...uploadAction.header,
|
||||
}
|
||||
|
||||
const uploadRes = await fetch(uploadAction.href, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: Buffer.from(local.contentBase64, 'base64'),
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text()
|
||||
throw new Error(`LFS upload failed for ${object.oid} (${uploadRes.status}): ${text}`)
|
||||
}
|
||||
|
||||
if (object.actions.verify) {
|
||||
const verifyAction = object.actions.verify
|
||||
const verifyHeaders: Record<string, string> = {
|
||||
'Accept': 'application/vnd.git-lfs+json',
|
||||
'Content-Type': 'application/vnd.git-lfs+json',
|
||||
...verifyAction.header,
|
||||
}
|
||||
|
||||
const verifyRes = await fetch(verifyAction.href, {
|
||||
method: 'POST',
|
||||
headers: verifyHeaders,
|
||||
body: JSON.stringify({ oid: object.oid, size: object.size }),
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const text = await verifyRes.text()
|
||||
throw new Error(`LFS verify failed for ${object.oid} (${verifyRes.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} done`, startedAt, {
|
||||
objects: objects.length,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user