feat(ext/sw): GitHubHost.putBlob with Git Data API fallback
Blobs ≤ BLOB_THRESHOLD_BYTES (900 KB) take the Contents API path (same as writeFile). Larger blobs use the Git Data API: POST blob, GET ref + commit, POST tree (with base_tree), POST commit, PATCH ref. Tests cover both paths plus error propagation. getBlob/deleteBlob are thin wrappers over readFile/deleteFile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { GitHost } from './git-host';
|
||||
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
|
||||
import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';
|
||||
|
||||
/// GitHub Contents API implementation.
|
||||
///
|
||||
@@ -13,10 +13,13 @@ import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
|
||||
|
||||
export class GitHubHost implements GitHost {
|
||||
private baseUrl: string;
|
||||
private gitApiBase: string;
|
||||
private branch: string = 'main';
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(repoPath: string, apiToken: string) {
|
||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||
this.gitApiBase = `https://api.github.com/repos/${repoPath}/git`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -106,8 +109,86 @@ export class GitHubHost implements GitHost {
|
||||
return json.map((item: { name: string }) => item.name);
|
||||
}
|
||||
|
||||
async putBlob(_path: string, _content: Uint8Array, _message: string): Promise<string> {
|
||||
throw new Error(`GitHubHost.putBlob not implemented — Task 3`);
|
||||
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
|
||||
if (content.length <= BLOB_THRESHOLD_BYTES) {
|
||||
// Contents API path — same as writeFile
|
||||
await this.writeFile(path, content, message);
|
||||
return path;
|
||||
}
|
||||
|
||||
// Git Data API fallback for large blobs.
|
||||
// 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(`GitHub 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) {
|
||||
throw new Error(`GitHub putBlob get-ref ${this.branch}: ${refResp.status} ${refResp.statusText}`);
|
||||
}
|
||||
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) {
|
||||
throw new Error(`GitHub putBlob get-commit ${commitSha}: ${commitResp.status} ${commitResp.statusText}`);
|
||||
}
|
||||
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(`GitHub 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(`GitHub 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(`GitHub putBlob update-ref: ${updateRefResp.status} ${text}`);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async getBlob(path: string): Promise<Uint8Array> {
|
||||
|
||||
Reference in New Issue
Block a user