230 lines
7.8 KiB
TypeScript
230 lines
7.8 KiB
TypeScript
import type { GitHost } from './git-host';
|
|
import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';
|
|
|
|
/// Gitea Contents API implementation.
|
|
///
|
|
/// Endpoints:
|
|
/// GET {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
|
|
/// POST {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (create)
|
|
/// PUT {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (update)
|
|
/// DELETE {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
|
|
///
|
|
/// Auth: `token {apiToken}` header.
|
|
/// Content is base64-encoded in both request and response bodies.
|
|
/// Updates and deletes require the current file SHA.
|
|
|
|
export class GiteaHost implements GitHost {
|
|
private baseUrl: string;
|
|
private gitApiBase: string;
|
|
private commitsUrl: string;
|
|
private branch: string = 'main';
|
|
private headers: Record<string, string>;
|
|
|
|
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
|
// Remove trailing slash from hostUrl
|
|
const host = hostUrl.replace(/\/+$/, '');
|
|
const apiUrl = `${host}/api/v1`;
|
|
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
|
|
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
|
|
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
|
|
this.headers = {
|
|
'Authorization': `token ${apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
};
|
|
}
|
|
|
|
async readFile(path: string): Promise<Uint8Array> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
headers: this.headers,
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Gitea readFile ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const json = await resp.json();
|
|
// Gitea returns base64 content with possible newlines
|
|
const clean = (json.content as string).replace(/\n/g, '');
|
|
return base64ToUint8Array(clean);
|
|
}
|
|
|
|
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
|
|
const b64 = uint8ArrayToBase64(content);
|
|
|
|
// Try to get the current SHA for an update; if 404 it's a create.
|
|
let sha: string | null = null;
|
|
try {
|
|
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
|
headers: this.headers,
|
|
});
|
|
if (existing.ok) {
|
|
const json = await existing.json();
|
|
sha = json.sha as string;
|
|
}
|
|
} catch {
|
|
// File does not exist — will create.
|
|
}
|
|
|
|
const body: Record<string, string> = { content: b64, message };
|
|
if (sha) {
|
|
body.sha = sha;
|
|
}
|
|
|
|
const method = sha ? 'PUT' : 'POST';
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method,
|
|
headers: this.headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error(`Gitea writeFile ${path}: ${resp.status} ${text}`);
|
|
}
|
|
}
|
|
|
|
async deleteFile(path: string, message: string): Promise<void> {
|
|
// Need the current SHA to delete.
|
|
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
|
headers: this.headers,
|
|
});
|
|
if (!existing.ok) {
|
|
throw new Error(`Gitea deleteFile ${path}: file not found (${existing.status})`);
|
|
}
|
|
const json = await existing.json();
|
|
const sha = json.sha as string;
|
|
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method: 'DELETE',
|
|
headers: this.headers,
|
|
body: JSON.stringify({ message, sha }),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error(`Gitea deleteFile ${path}: ${resp.status} ${text}`);
|
|
}
|
|
}
|
|
|
|
async listDir(path: string): Promise<string[]> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
headers: this.headers,
|
|
});
|
|
if (!resp.ok) {
|
|
if (resp.status === 404) return [];
|
|
throw new Error(`Gitea listDir ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const json = await resp.json();
|
|
if (!Array.isArray(json)) return [];
|
|
return json.map((item: { name: string }) => item.name);
|
|
}
|
|
|
|
async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> {
|
|
try {
|
|
const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&limit=1`;
|
|
const resp = await fetch(url, { headers: this.headers });
|
|
if (!resp.ok) return null;
|
|
const json = await resp.json();
|
|
if (!Array.isArray(json) || json.length === 0) return null;
|
|
const c = json[0];
|
|
return {
|
|
sha: String(c.sha).slice(0, 7),
|
|
author: c.commit?.author?.name ?? 'unknown',
|
|
date: c.commit?.author?.date ?? '',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
|
|
if (content.length <= BLOB_THRESHOLD_BYTES) {
|
|
await this.writeFile(path, content, message);
|
|
return path;
|
|
}
|
|
|
|
// Git Data API fallback (Gitea v1; same shape as GitHub).
|
|
// 1. Create the blob.
|
|
const blobResp = await fetch(`${this.gitApiBase}/blobs`, {
|
|
method: 'POST',
|
|
headers: this.headers,
|
|
body: JSON.stringify({
|
|
content: uint8ArrayToBase64(content),
|
|
encoding: 'base64',
|
|
}),
|
|
});
|
|
if (!blobResp.ok) {
|
|
const text = await blobResp.text();
|
|
throw new Error(`Gitea putBlob create-blob ${path}: ${blobResp.status} ${text}`);
|
|
}
|
|
const { sha: blobSha } = await blobResp.json() as { sha: string };
|
|
|
|
// 2. Get current ref (branch tip commit SHA).
|
|
const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers });
|
|
if (!refResp.ok) {
|
|
const text = await refResp.text();
|
|
throw new Error(`Gitea putBlob get-ref ${this.branch}: ${refResp.status} ${text}`);
|
|
}
|
|
const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } };
|
|
|
|
// 3. Get current commit's tree SHA.
|
|
const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers });
|
|
if (!commitResp.ok) {
|
|
const text = await commitResp.text();
|
|
throw new Error(`Gitea putBlob get-commit ${commitSha}: ${commitResp.status} ${text}`);
|
|
}
|
|
const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } };
|
|
|
|
// 4. Create new tree adding the blob at `path` on top of base tree.
|
|
const treeResp = await fetch(`${this.gitApiBase}/trees`, {
|
|
method: 'POST',
|
|
headers: this.headers,
|
|
body: JSON.stringify({
|
|
base_tree: baseTreeSha,
|
|
tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }],
|
|
}),
|
|
});
|
|
if (!treeResp.ok) {
|
|
const text = await treeResp.text();
|
|
throw new Error(`Gitea putBlob create-tree: ${treeResp.status} ${text}`);
|
|
}
|
|
const { sha: newTreeSha } = await treeResp.json() as { sha: string };
|
|
|
|
// 5. Create new commit pointing at the new tree.
|
|
const newCommitResp = await fetch(`${this.gitApiBase}/commits`, {
|
|
method: 'POST',
|
|
headers: this.headers,
|
|
body: JSON.stringify({
|
|
message,
|
|
tree: newTreeSha,
|
|
parents: [commitSha],
|
|
}),
|
|
});
|
|
if (!newCommitResp.ok) {
|
|
const text = await newCommitResp.text();
|
|
throw new Error(`Gitea putBlob create-commit: ${newCommitResp.status} ${text}`);
|
|
}
|
|
const { sha: newCommitSha } = await newCommitResp.json() as { sha: string };
|
|
|
|
// 6. Fast-forward branch to the new commit.
|
|
const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, {
|
|
method: 'PATCH',
|
|
headers: this.headers,
|
|
body: JSON.stringify({ sha: newCommitSha, force: false }),
|
|
});
|
|
if (!updateRefResp.ok) {
|
|
const text = await updateRefResp.text();
|
|
throw new Error(`Gitea putBlob update-ref: ${updateRefResp.status} ${text}`);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
async getBlob(path: string): Promise<Uint8Array> {
|
|
return this.readFile(path);
|
|
}
|
|
|
|
async deleteBlob(path: string, message: string): Promise<void> {
|
|
return this.deleteFile(path, message);
|
|
}
|
|
}
|