import { Octokit } from '@octokit/rest' import { createHash } from 'crypto' import type { RemoteFile } from './types' // --------------------------------------------------------------------------- // Octokit helpers // --------------------------------------------------------------------------- export function getOctokit(): Octokit { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN non configure') return new Octokit({ auth: token }) } export function parseRepoUrl(): { owner: string; repo: string } { const url = process.env.GIT_REPO_URL if (!url) throw new Error('GIT_REPO_URL non configure') const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/) const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/) const shortMatch = url.match(/^([^/]+)\/([^/]+)$/) const match = httpsMatch || sshMatch || shortMatch if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`) return { owner: match[1], repo: match[2] } } /** Compute the SHA that Git would assign to a blob with this content */ export function computeGitBlobSha(content: Buffer): string { const header = `blob ${content.length}\0` const store = Buffer.concat([Buffer.from(header), content]) return createHash('sha1').update(store).digest('hex') } // --------------------------------------------------------------------------- // Read remote folder contents (with SHA per file) // --------------------------------------------------------------------------- export async function getRemoteFolder( folderPath: string, ): Promise<{ exists: boolean; files: RemoteFile[] }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' try { const { data } = await octokit.repos.getContent({ owner, repo, path: folderPath, ref: branch, }) if (Array.isArray(data)) { return { exists: true, files: data.map((f) => ({ name: f.name, sha: f.sha })), } } return { exists: false, files: [] } } catch (err: unknown) { const status = (err as { status?: number })?.status if (status === 404) { return { exists: false, files: [] } } throw err } } // --------------------------------------------------------------------------- // Push all files in a single commit (with optional deletions) // --------------------------------------------------------------------------- export async function pushAllToGitHub( files: { path: string; contentBase64: string }[], deletePaths: string[], commitMessage: string, ): Promise<{ commitUrl: string }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' // 1. Get latest commit on branch const { data: ref } = await octokit.git.getRef({ owner, repo, ref: `heads/${branch}`, }) const latestCommitSha = ref.object.sha // 2. Get that commit's tree const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) // 3. Create all blobs in parallel const blobResults = await Promise.all( files.map((f) => octokit.git.createBlob({ owner, repo, content: f.contentBase64, encoding: 'base64', }), ), ) // 4. Build tree entries: new/changed files + deletions const newFilePaths = new Set(files.map((f) => f.path)) const deleteEntries = deletePaths .filter((p) => !newFilePaths.has(p)) .map((p) => ({ path: p, mode: '100644' as const, type: 'blob' as const, sha: null, })) const { data: newTree } = await octokit.git.createTree({ owner, repo, base_tree: commit.tree.sha, tree: [ ...files.map((f, i) => ({ path: f.path, mode: '100644' as const, type: 'blob' as const, sha: blobResults[i].data.sha, })), ...deleteEntries, ], }) // 5. Create a single commit const { data: newCommit } = await octokit.git.createCommit({ owner, repo, message: commitMessage, tree: newTree.sha, parents: [latestCommitSha], }) // 6. Update branch ref await octokit.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: newCommit.sha, }) return { commitUrl: newCommit.html_url } }