@setzkasten-cms/github-adapter 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +37 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +296 -0
- package/package.json +29 -0
- package/src/github-client.ts +226 -0
- package/src/github-content-repository.ts +121 -0
- package/src/index.ts +3 -0
- package/src/tree-builder.test.ts +94 -0
- package/src/tree-builder.ts +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Setzkasten Community License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lilapixel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to use,
|
|
7
|
+
copy, modify, merge, publish, and distribute the Software, subject to the
|
|
8
|
+
following conditions:
|
|
9
|
+
|
|
10
|
+
1. The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
2. The Software may not be used for commercial purposes without a separate
|
|
14
|
+
commercial license from the copyright holder. "Commercial purposes" means
|
|
15
|
+
any use of the Software that is primarily intended for or directed toward
|
|
16
|
+
commercial advantage or monetary compensation. This includes, but is not
|
|
17
|
+
limited to:
|
|
18
|
+
- Using the Software to manage content for a commercial website or product
|
|
19
|
+
- Offering the Software as part of a paid service
|
|
20
|
+
- Using the Software within a for-profit organization
|
|
21
|
+
|
|
22
|
+
3. Non-commercial use is permitted without restriction. This includes:
|
|
23
|
+
- Personal projects
|
|
24
|
+
- Open source projects
|
|
25
|
+
- Educational and academic use
|
|
26
|
+
- Non-profit organizations
|
|
27
|
+
|
|
28
|
+
4. A commercial license ("Enterprise License") may be obtained by contacting
|
|
29
|
+
Lilapixel at hello@lilapixel.de.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Result, ContentRepository, EntryListItem, EntryData, Asset, CommitResult, TreeNode } from '@setzkasten-cms/core';
|
|
2
|
+
|
|
3
|
+
interface GitHubClientConfig {
|
|
4
|
+
token: string;
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Low-level GitHub API client wrapping Octokit.
|
|
11
|
+
* All methods return Result types – never throws.
|
|
12
|
+
*/
|
|
13
|
+
declare class GitHubClient {
|
|
14
|
+
private octokit;
|
|
15
|
+
private owner;
|
|
16
|
+
private repo;
|
|
17
|
+
private branch;
|
|
18
|
+
private etagCache;
|
|
19
|
+
constructor(config: GitHubClientConfig);
|
|
20
|
+
getFileContent(path: string): Promise<Result<{
|
|
21
|
+
content: string;
|
|
22
|
+
sha: string;
|
|
23
|
+
}>>;
|
|
24
|
+
listDirectory(path: string): Promise<Result<Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
sha: string;
|
|
28
|
+
}>>>;
|
|
29
|
+
/**
|
|
30
|
+
* Get the current HEAD SHA for the configured branch.
|
|
31
|
+
*/
|
|
32
|
+
getHeadSha(): Promise<Result<string>>;
|
|
33
|
+
/**
|
|
34
|
+
* Get the tree SHA for a given commit.
|
|
35
|
+
*/
|
|
36
|
+
getCommitTree(commitSha: string): Promise<Result<string>>;
|
|
37
|
+
/**
|
|
38
|
+
* Create a new tree from a list of file changes.
|
|
39
|
+
*/
|
|
40
|
+
createTree(baseTreeSha: string, files: Array<{
|
|
41
|
+
path: string;
|
|
42
|
+
content: string;
|
|
43
|
+
mode?: '100644';
|
|
44
|
+
}>): Promise<Result<string>>;
|
|
45
|
+
/**
|
|
46
|
+
* Create a commit.
|
|
47
|
+
*/
|
|
48
|
+
createCommit(treeSha: string, parentSha: string, message: string): Promise<Result<string>>;
|
|
49
|
+
/**
|
|
50
|
+
* Update the branch ref to point to a new commit.
|
|
51
|
+
*/
|
|
52
|
+
updateRef(commitSha: string): Promise<Result<void>>;
|
|
53
|
+
/**
|
|
54
|
+
* Upload a binary file (image) via the Contents API.
|
|
55
|
+
*/
|
|
56
|
+
uploadFile(path: string, content: Uint8Array, message: string, existingSha?: string): Promise<Result<{
|
|
57
|
+
sha: string;
|
|
58
|
+
}>>;
|
|
59
|
+
private handleError;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ContentRepository implementation using GitHub API.
|
|
64
|
+
* Uses Git Trees API for atomic batch commits.
|
|
65
|
+
*/
|
|
66
|
+
declare class GitHubContentRepository implements ContentRepository {
|
|
67
|
+
private client;
|
|
68
|
+
private treeBuilder;
|
|
69
|
+
constructor(config: GitHubClientConfig);
|
|
70
|
+
listEntries(collection: string): Promise<Result<EntryListItem[]>>;
|
|
71
|
+
getEntry(collection: string, slug: string): Promise<Result<EntryData>>;
|
|
72
|
+
saveEntry(collection: string, slug: string, data: EntryData, assets?: Asset[]): Promise<Result<CommitResult>>;
|
|
73
|
+
deleteEntry(collection: string, slug: string): Promise<Result<CommitResult>>;
|
|
74
|
+
getTree(ref?: string): Promise<Result<TreeNode[]>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface FileChange {
|
|
78
|
+
path: string;
|
|
79
|
+
content: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Batch commit builder using the Git Trees API.
|
|
83
|
+
*
|
|
84
|
+
* Instead of creating individual commits per file (Keystatic's approach),
|
|
85
|
+
* this creates a single atomic commit with all changes.
|
|
86
|
+
*
|
|
87
|
+
* Flow:
|
|
88
|
+
* 1. GET current HEAD SHA
|
|
89
|
+
* 2. GET tree SHA of HEAD commit
|
|
90
|
+
* 3. POST new tree with all file changes
|
|
91
|
+
* 4. POST new commit pointing to the new tree
|
|
92
|
+
* 5. PATCH ref to point to the new commit
|
|
93
|
+
*/
|
|
94
|
+
declare class TreeBuilder {
|
|
95
|
+
private client;
|
|
96
|
+
constructor(client: GitHubClient);
|
|
97
|
+
commit(files: FileChange[], message: string): Promise<Result<{
|
|
98
|
+
sha: string;
|
|
99
|
+
url?: string;
|
|
100
|
+
}>>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { type FileChange, GitHubClient, type GitHubClientConfig, GitHubContentRepository, TreeBuilder };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// src/github-client.ts
|
|
2
|
+
import { Octokit } from "octokit";
|
|
3
|
+
import {
|
|
4
|
+
err,
|
|
5
|
+
ok,
|
|
6
|
+
rateLimitError,
|
|
7
|
+
networkError,
|
|
8
|
+
notFoundError,
|
|
9
|
+
conflictError
|
|
10
|
+
} from "@setzkasten-cms/core";
|
|
11
|
+
var GitHubClient = class {
|
|
12
|
+
octokit;
|
|
13
|
+
owner;
|
|
14
|
+
repo;
|
|
15
|
+
branch;
|
|
16
|
+
etagCache = /* @__PURE__ */ new Map();
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.octokit = new Octokit({ auth: config.token });
|
|
19
|
+
this.owner = config.owner;
|
|
20
|
+
this.repo = config.repo;
|
|
21
|
+
this.branch = config.branch;
|
|
22
|
+
}
|
|
23
|
+
async getFileContent(path) {
|
|
24
|
+
try {
|
|
25
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
26
|
+
owner: this.owner,
|
|
27
|
+
repo: this.repo,
|
|
28
|
+
path,
|
|
29
|
+
ref: this.branch
|
|
30
|
+
});
|
|
31
|
+
const data = response.data;
|
|
32
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
33
|
+
return err(notFoundError(path));
|
|
34
|
+
}
|
|
35
|
+
const content = Buffer.from(data.content, "base64").toString("utf-8");
|
|
36
|
+
return ok({ content, sha: data.sha });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return this.handleError(error, path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async listDirectory(path) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
44
|
+
owner: this.owner,
|
|
45
|
+
repo: this.repo,
|
|
46
|
+
path,
|
|
47
|
+
ref: this.branch
|
|
48
|
+
});
|
|
49
|
+
if (!Array.isArray(response.data)) {
|
|
50
|
+
return err(notFoundError(path));
|
|
51
|
+
}
|
|
52
|
+
return ok(
|
|
53
|
+
response.data.map((item) => ({
|
|
54
|
+
name: item.name,
|
|
55
|
+
type: item.type,
|
|
56
|
+
sha: item.sha
|
|
57
|
+
}))
|
|
58
|
+
);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return this.handleError(error, path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get the current HEAD SHA for the configured branch.
|
|
65
|
+
*/
|
|
66
|
+
async getHeadSha() {
|
|
67
|
+
try {
|
|
68
|
+
const response = await this.octokit.rest.git.getRef({
|
|
69
|
+
owner: this.owner,
|
|
70
|
+
repo: this.repo,
|
|
71
|
+
ref: `heads/${this.branch}`
|
|
72
|
+
});
|
|
73
|
+
return ok(response.data.object.sha);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return this.handleError(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get the tree SHA for a given commit.
|
|
80
|
+
*/
|
|
81
|
+
async getCommitTree(commitSha) {
|
|
82
|
+
try {
|
|
83
|
+
const response = await this.octokit.rest.git.getCommit({
|
|
84
|
+
owner: this.owner,
|
|
85
|
+
repo: this.repo,
|
|
86
|
+
commit_sha: commitSha
|
|
87
|
+
});
|
|
88
|
+
return ok(response.data.tree.sha);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return this.handleError(error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create a new tree from a list of file changes.
|
|
95
|
+
*/
|
|
96
|
+
async createTree(baseTreeSha, files) {
|
|
97
|
+
try {
|
|
98
|
+
const response = await this.octokit.rest.git.createTree({
|
|
99
|
+
owner: this.owner,
|
|
100
|
+
repo: this.repo,
|
|
101
|
+
base_tree: baseTreeSha,
|
|
102
|
+
tree: files.map((f) => ({
|
|
103
|
+
path: f.path,
|
|
104
|
+
mode: f.mode ?? "100644",
|
|
105
|
+
type: "blob",
|
|
106
|
+
content: f.content
|
|
107
|
+
}))
|
|
108
|
+
});
|
|
109
|
+
return ok(response.data.sha);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return this.handleError(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create a commit.
|
|
116
|
+
*/
|
|
117
|
+
async createCommit(treeSha, parentSha, message) {
|
|
118
|
+
try {
|
|
119
|
+
const response = await this.octokit.rest.git.createCommit({
|
|
120
|
+
owner: this.owner,
|
|
121
|
+
repo: this.repo,
|
|
122
|
+
tree: treeSha,
|
|
123
|
+
parents: [parentSha],
|
|
124
|
+
message
|
|
125
|
+
});
|
|
126
|
+
return ok(response.data.sha);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return this.handleError(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Update the branch ref to point to a new commit.
|
|
133
|
+
*/
|
|
134
|
+
async updateRef(commitSha) {
|
|
135
|
+
try {
|
|
136
|
+
await this.octokit.rest.git.updateRef({
|
|
137
|
+
owner: this.owner,
|
|
138
|
+
repo: this.repo,
|
|
139
|
+
ref: `heads/${this.branch}`,
|
|
140
|
+
sha: commitSha
|
|
141
|
+
});
|
|
142
|
+
return ok(void 0);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return this.handleError(error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Upload a binary file (image) via the Contents API.
|
|
149
|
+
*/
|
|
150
|
+
async uploadFile(path, content, message, existingSha) {
|
|
151
|
+
try {
|
|
152
|
+
const base64Content = Buffer.from(content).toString("base64");
|
|
153
|
+
const response = await this.octokit.rest.repos.createOrUpdateFileContents({
|
|
154
|
+
owner: this.owner,
|
|
155
|
+
repo: this.repo,
|
|
156
|
+
path,
|
|
157
|
+
message,
|
|
158
|
+
content: base64Content,
|
|
159
|
+
branch: this.branch,
|
|
160
|
+
...existingSha ? { sha: existingSha } : {}
|
|
161
|
+
});
|
|
162
|
+
return ok({ sha: response.data.content?.sha ?? "" });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return this.handleError(error, path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
handleError(error, path) {
|
|
168
|
+
if (error && typeof error === "object" && "status" in error) {
|
|
169
|
+
const status = error.status;
|
|
170
|
+
if (status === 404) {
|
|
171
|
+
return err(notFoundError(path ?? "unknown"));
|
|
172
|
+
}
|
|
173
|
+
if (status === 409) {
|
|
174
|
+
return err(conflictError("Concurrent edit detected. Please refresh and try again."));
|
|
175
|
+
}
|
|
176
|
+
if (status === 403 || status === 429) {
|
|
177
|
+
const retryAfter = 60;
|
|
178
|
+
return err(rateLimitError(retryAfter, 0));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
182
|
+
return err(networkError(message, error));
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// src/github-content-repository.ts
|
|
187
|
+
import {
|
|
188
|
+
ok as ok3,
|
|
189
|
+
err as err3,
|
|
190
|
+
serializationError
|
|
191
|
+
} from "@setzkasten-cms/core";
|
|
192
|
+
|
|
193
|
+
// src/tree-builder.ts
|
|
194
|
+
import { ok as ok2 } from "@setzkasten-cms/core";
|
|
195
|
+
var TreeBuilder = class {
|
|
196
|
+
constructor(client) {
|
|
197
|
+
this.client = client;
|
|
198
|
+
}
|
|
199
|
+
async commit(files, message) {
|
|
200
|
+
const headResult = await this.client.getHeadSha();
|
|
201
|
+
if (!headResult.ok) return headResult;
|
|
202
|
+
const treeResult = await this.client.getCommitTree(headResult.value);
|
|
203
|
+
if (!treeResult.ok) return treeResult;
|
|
204
|
+
const newTreeResult = await this.client.createTree(treeResult.value, files);
|
|
205
|
+
if (!newTreeResult.ok) return newTreeResult;
|
|
206
|
+
const commitResult = await this.client.createCommit(
|
|
207
|
+
newTreeResult.value,
|
|
208
|
+
headResult.value,
|
|
209
|
+
message
|
|
210
|
+
);
|
|
211
|
+
if (!commitResult.ok) return commitResult;
|
|
212
|
+
const updateResult = await this.client.updateRef(commitResult.value);
|
|
213
|
+
if (!updateResult.ok) return updateResult;
|
|
214
|
+
return ok2({ sha: commitResult.value });
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// src/github-content-repository.ts
|
|
219
|
+
var GitHubContentRepository = class {
|
|
220
|
+
client;
|
|
221
|
+
treeBuilder;
|
|
222
|
+
constructor(config) {
|
|
223
|
+
this.client = new GitHubClient(config);
|
|
224
|
+
this.treeBuilder = new TreeBuilder(this.client);
|
|
225
|
+
}
|
|
226
|
+
async listEntries(collection) {
|
|
227
|
+
const result = await this.client.listDirectory(collection);
|
|
228
|
+
if (!result.ok) return result;
|
|
229
|
+
return ok3(
|
|
230
|
+
result.value.filter((item) => item.name.endsWith(".json")).map((item) => ({
|
|
231
|
+
slug: item.name.replace(/\.json$/, ""),
|
|
232
|
+
name: item.name.replace(/\.json$/, "")
|
|
233
|
+
}))
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
async getEntry(collection, slug) {
|
|
237
|
+
const path = `${collection}/${slug}.json`;
|
|
238
|
+
const result = await this.client.getFileContent(path);
|
|
239
|
+
if (!result.ok) return result;
|
|
240
|
+
try {
|
|
241
|
+
const content = JSON.parse(result.value.content);
|
|
242
|
+
return ok3({ content, sha: result.value.sha });
|
|
243
|
+
} catch {
|
|
244
|
+
return err3(serializationError(`Failed to parse JSON at ${path}`, path));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async saveEntry(collection, slug, data, assets) {
|
|
248
|
+
const files = [];
|
|
249
|
+
const jsonPath = `${collection}/${slug}.json`;
|
|
250
|
+
const jsonContent = JSON.stringify(data.content, null, 2);
|
|
251
|
+
files.push({ path: jsonPath, content: jsonContent });
|
|
252
|
+
if (assets && assets.length > 0) {
|
|
253
|
+
for (const asset of assets) {
|
|
254
|
+
const uploadResult = await this.client.uploadFile(
|
|
255
|
+
asset.path,
|
|
256
|
+
asset.content,
|
|
257
|
+
`Upload ${asset.path}`
|
|
258
|
+
);
|
|
259
|
+
if (!uploadResult.ok) return uploadResult;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const commitMessage = `Update ${collection}/${slug}`;
|
|
263
|
+
const result = await this.treeBuilder.commit(files, commitMessage);
|
|
264
|
+
if (!result.ok) return result;
|
|
265
|
+
return ok3({
|
|
266
|
+
sha: result.value.sha,
|
|
267
|
+
message: commitMessage,
|
|
268
|
+
url: result.value.url
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async deleteEntry(collection, slug) {
|
|
272
|
+
const path = `${collection}/${slug}.json`;
|
|
273
|
+
const fileResult = await this.client.getFileContent(path);
|
|
274
|
+
if (!fileResult.ok) return fileResult;
|
|
275
|
+
return ok3({
|
|
276
|
+
sha: "",
|
|
277
|
+
message: `Delete ${collection}/${slug}`
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async getTree(ref) {
|
|
281
|
+
const result = await this.client.listDirectory("");
|
|
282
|
+
if (!result.ok) return result;
|
|
283
|
+
return ok3(
|
|
284
|
+
result.value.map((item) => ({
|
|
285
|
+
path: item.name,
|
|
286
|
+
type: item.type === "dir" ? "dir" : "file",
|
|
287
|
+
sha: item.sha
|
|
288
|
+
}))
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
export {
|
|
293
|
+
GitHubClient,
|
|
294
|
+
GitHubContentRepository,
|
|
295
|
+
TreeBuilder
|
|
296
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@setzkasten-cms/github-adapter",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"octokit": "^4.1.0",
|
|
17
|
+
"@setzkasten-cms/core": "0.4.2"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^25.4.0",
|
|
21
|
+
"vitest": "^3.2.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"test": "vitest run --passWithNoTests",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Octokit } from 'octokit'
|
|
2
|
+
import {
|
|
3
|
+
err,
|
|
4
|
+
ok,
|
|
5
|
+
rateLimitError,
|
|
6
|
+
networkError,
|
|
7
|
+
notFoundError,
|
|
8
|
+
conflictError,
|
|
9
|
+
type Result,
|
|
10
|
+
} from '@setzkasten-cms/core'
|
|
11
|
+
|
|
12
|
+
export interface GitHubClientConfig {
|
|
13
|
+
token: string
|
|
14
|
+
owner: string
|
|
15
|
+
repo: string
|
|
16
|
+
branch: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Low-level GitHub API client wrapping Octokit.
|
|
21
|
+
* All methods return Result types – never throws.
|
|
22
|
+
*/
|
|
23
|
+
export class GitHubClient {
|
|
24
|
+
private octokit: Octokit
|
|
25
|
+
private owner: string
|
|
26
|
+
private repo: string
|
|
27
|
+
private branch: string
|
|
28
|
+
private etagCache = new Map<string, { etag: string; data: unknown }>()
|
|
29
|
+
|
|
30
|
+
constructor(config: GitHubClientConfig) {
|
|
31
|
+
this.octokit = new Octokit({ auth: config.token })
|
|
32
|
+
this.owner = config.owner
|
|
33
|
+
this.repo = config.repo
|
|
34
|
+
this.branch = config.branch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getFileContent(path: string): Promise<Result<{ content: string; sha: string }>> {
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
40
|
+
owner: this.owner,
|
|
41
|
+
repo: this.repo,
|
|
42
|
+
path,
|
|
43
|
+
ref: this.branch,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const data = response.data
|
|
47
|
+
if (Array.isArray(data) || data.type !== 'file') {
|
|
48
|
+
return err(notFoundError(path))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = Buffer.from(data.content, 'base64').toString('utf-8')
|
|
52
|
+
return ok({ content, sha: data.sha })
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return this.handleError(error, path)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listDirectory(path: string): Promise<Result<Array<{ name: string; type: string; sha: string }>>> {
|
|
59
|
+
try {
|
|
60
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
61
|
+
owner: this.owner,
|
|
62
|
+
repo: this.repo,
|
|
63
|
+
path,
|
|
64
|
+
ref: this.branch,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(response.data)) {
|
|
68
|
+
return err(notFoundError(path))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return ok(
|
|
72
|
+
response.data.map((item) => ({
|
|
73
|
+
name: item.name,
|
|
74
|
+
type: item.type,
|
|
75
|
+
sha: item.sha,
|
|
76
|
+
})),
|
|
77
|
+
)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return this.handleError(error, path)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the current HEAD SHA for the configured branch.
|
|
85
|
+
*/
|
|
86
|
+
async getHeadSha(): Promise<Result<string>> {
|
|
87
|
+
try {
|
|
88
|
+
const response = await this.octokit.rest.git.getRef({
|
|
89
|
+
owner: this.owner,
|
|
90
|
+
repo: this.repo,
|
|
91
|
+
ref: `heads/${this.branch}`,
|
|
92
|
+
})
|
|
93
|
+
return ok(response.data.object.sha)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return this.handleError(error)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the tree SHA for a given commit.
|
|
101
|
+
*/
|
|
102
|
+
async getCommitTree(commitSha: string): Promise<Result<string>> {
|
|
103
|
+
try {
|
|
104
|
+
const response = await this.octokit.rest.git.getCommit({
|
|
105
|
+
owner: this.owner,
|
|
106
|
+
repo: this.repo,
|
|
107
|
+
commit_sha: commitSha,
|
|
108
|
+
})
|
|
109
|
+
return ok(response.data.tree.sha)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return this.handleError(error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a new tree from a list of file changes.
|
|
117
|
+
*/
|
|
118
|
+
async createTree(
|
|
119
|
+
baseTreeSha: string,
|
|
120
|
+
files: Array<{ path: string; content: string; mode?: '100644' }>,
|
|
121
|
+
): Promise<Result<string>> {
|
|
122
|
+
try {
|
|
123
|
+
const response = await this.octokit.rest.git.createTree({
|
|
124
|
+
owner: this.owner,
|
|
125
|
+
repo: this.repo,
|
|
126
|
+
base_tree: baseTreeSha,
|
|
127
|
+
tree: files.map((f) => ({
|
|
128
|
+
path: f.path,
|
|
129
|
+
mode: f.mode ?? '100644',
|
|
130
|
+
type: 'blob',
|
|
131
|
+
content: f.content,
|
|
132
|
+
})),
|
|
133
|
+
})
|
|
134
|
+
return ok(response.data.sha)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return this.handleError(error)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a commit.
|
|
142
|
+
*/
|
|
143
|
+
async createCommit(
|
|
144
|
+
treeSha: string,
|
|
145
|
+
parentSha: string,
|
|
146
|
+
message: string,
|
|
147
|
+
): Promise<Result<string>> {
|
|
148
|
+
try {
|
|
149
|
+
const response = await this.octokit.rest.git.createCommit({
|
|
150
|
+
owner: this.owner,
|
|
151
|
+
repo: this.repo,
|
|
152
|
+
tree: treeSha,
|
|
153
|
+
parents: [parentSha],
|
|
154
|
+
message,
|
|
155
|
+
})
|
|
156
|
+
return ok(response.data.sha)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return this.handleError(error)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Update the branch ref to point to a new commit.
|
|
164
|
+
*/
|
|
165
|
+
async updateRef(commitSha: string): Promise<Result<void>> {
|
|
166
|
+
try {
|
|
167
|
+
await this.octokit.rest.git.updateRef({
|
|
168
|
+
owner: this.owner,
|
|
169
|
+
repo: this.repo,
|
|
170
|
+
ref: `heads/${this.branch}`,
|
|
171
|
+
sha: commitSha,
|
|
172
|
+
})
|
|
173
|
+
return ok(undefined)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return this.handleError(error)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Upload a binary file (image) via the Contents API.
|
|
181
|
+
*/
|
|
182
|
+
async uploadFile(
|
|
183
|
+
path: string,
|
|
184
|
+
content: Uint8Array,
|
|
185
|
+
message: string,
|
|
186
|
+
existingSha?: string,
|
|
187
|
+
): Promise<Result<{ sha: string }>> {
|
|
188
|
+
try {
|
|
189
|
+
const base64Content = Buffer.from(content).toString('base64')
|
|
190
|
+
|
|
191
|
+
const response = await this.octokit.rest.repos.createOrUpdateFileContents({
|
|
192
|
+
owner: this.owner,
|
|
193
|
+
repo: this.repo,
|
|
194
|
+
path,
|
|
195
|
+
message,
|
|
196
|
+
content: base64Content,
|
|
197
|
+
branch: this.branch,
|
|
198
|
+
...(existingSha ? { sha: existingSha } : {}),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return ok({ sha: response.data.content?.sha ?? '' })
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return this.handleError(error, path)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private handleError(error: unknown, path?: string): Result<never> {
|
|
208
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
209
|
+
const status = (error as { status: number }).status
|
|
210
|
+
|
|
211
|
+
if (status === 404) {
|
|
212
|
+
return err(notFoundError(path ?? 'unknown'))
|
|
213
|
+
}
|
|
214
|
+
if (status === 409) {
|
|
215
|
+
return err(conflictError('Concurrent edit detected. Please refresh and try again.'))
|
|
216
|
+
}
|
|
217
|
+
if (status === 403 || status === 429) {
|
|
218
|
+
const retryAfter = 60 // default
|
|
219
|
+
return err(rateLimitError(retryAfter, 0))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const message = error instanceof Error ? error.message : 'Unknown network error'
|
|
224
|
+
return err(networkError(message, error))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ok,
|
|
3
|
+
err,
|
|
4
|
+
serializationError,
|
|
5
|
+
type ContentRepository,
|
|
6
|
+
type EntryData,
|
|
7
|
+
type EntryListItem,
|
|
8
|
+
type Asset,
|
|
9
|
+
type CommitResult,
|
|
10
|
+
type TreeNode,
|
|
11
|
+
type Result,
|
|
12
|
+
} from '@setzkasten-cms/core'
|
|
13
|
+
import { GitHubClient, type GitHubClientConfig } from './github-client'
|
|
14
|
+
import { TreeBuilder, type FileChange } from './tree-builder'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ContentRepository implementation using GitHub API.
|
|
18
|
+
* Uses Git Trees API for atomic batch commits.
|
|
19
|
+
*/
|
|
20
|
+
export class GitHubContentRepository implements ContentRepository {
|
|
21
|
+
private client: GitHubClient
|
|
22
|
+
private treeBuilder: TreeBuilder
|
|
23
|
+
|
|
24
|
+
constructor(config: GitHubClientConfig) {
|
|
25
|
+
this.client = new GitHubClient(config)
|
|
26
|
+
this.treeBuilder = new TreeBuilder(this.client)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async listEntries(collection: string): Promise<Result<EntryListItem[]>> {
|
|
30
|
+
const result = await this.client.listDirectory(collection)
|
|
31
|
+
if (!result.ok) return result
|
|
32
|
+
|
|
33
|
+
return ok(
|
|
34
|
+
result.value
|
|
35
|
+
.filter((item) => item.name.endsWith('.json'))
|
|
36
|
+
.map((item) => ({
|
|
37
|
+
slug: item.name.replace(/\.json$/, ''),
|
|
38
|
+
name: item.name.replace(/\.json$/, ''),
|
|
39
|
+
})),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getEntry(collection: string, slug: string): Promise<Result<EntryData>> {
|
|
44
|
+
const path = `${collection}/${slug}.json`
|
|
45
|
+
const result = await this.client.getFileContent(path)
|
|
46
|
+
if (!result.ok) return result
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const content = JSON.parse(result.value.content) as Record<string, unknown>
|
|
50
|
+
return ok({ content, sha: result.value.sha })
|
|
51
|
+
} catch {
|
|
52
|
+
return err(serializationError(`Failed to parse JSON at ${path}`, path))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async saveEntry(
|
|
57
|
+
collection: string,
|
|
58
|
+
slug: string,
|
|
59
|
+
data: EntryData,
|
|
60
|
+
assets?: Asset[],
|
|
61
|
+
): Promise<Result<CommitResult>> {
|
|
62
|
+
const files: FileChange[] = []
|
|
63
|
+
|
|
64
|
+
// Add the JSON content file
|
|
65
|
+
const jsonPath = `${collection}/${slug}.json`
|
|
66
|
+
const jsonContent = JSON.stringify(data.content, null, 2)
|
|
67
|
+
files.push({ path: jsonPath, content: jsonContent })
|
|
68
|
+
|
|
69
|
+
// Upload assets first (via Contents API for binary files)
|
|
70
|
+
if (assets && assets.length > 0) {
|
|
71
|
+
for (const asset of assets) {
|
|
72
|
+
const uploadResult = await this.client.uploadFile(
|
|
73
|
+
asset.path,
|
|
74
|
+
asset.content,
|
|
75
|
+
`Upload ${asset.path}`,
|
|
76
|
+
)
|
|
77
|
+
if (!uploadResult.ok) return uploadResult as Result<never>
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Batch commit all JSON changes
|
|
82
|
+
const commitMessage = `Update ${collection}/${slug}`
|
|
83
|
+
const result = await this.treeBuilder.commit(files, commitMessage)
|
|
84
|
+
|
|
85
|
+
if (!result.ok) return result as Result<never>
|
|
86
|
+
|
|
87
|
+
return ok({
|
|
88
|
+
sha: result.value.sha,
|
|
89
|
+
message: commitMessage,
|
|
90
|
+
url: result.value.url,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async deleteEntry(collection: string, slug: string): Promise<Result<CommitResult>> {
|
|
95
|
+
// For delete, we'd need to use the Contents API with the file SHA
|
|
96
|
+
// This is a simplified version – full implementation would use tree builder
|
|
97
|
+
const path = `${collection}/${slug}.json`
|
|
98
|
+
const fileResult = await this.client.getFileContent(path)
|
|
99
|
+
if (!fileResult.ok) return fileResult as Result<never>
|
|
100
|
+
|
|
101
|
+
// TODO: Implement deletion via tree builder (remove file from tree)
|
|
102
|
+
return ok({
|
|
103
|
+
sha: '',
|
|
104
|
+
message: `Delete ${collection}/${slug}`,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getTree(ref?: string): Promise<Result<TreeNode[]>> {
|
|
109
|
+
// Simplified – returns top-level directory listing
|
|
110
|
+
const result = await this.client.listDirectory('')
|
|
111
|
+
if (!result.ok) return result as Result<never>
|
|
112
|
+
|
|
113
|
+
return ok(
|
|
114
|
+
result.value.map((item) => ({
|
|
115
|
+
path: item.name,
|
|
116
|
+
type: item.type === 'dir' ? 'dir' as const : 'file' as const,
|
|
117
|
+
sha: item.sha,
|
|
118
|
+
})),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { ok, err, networkError } from '@setzkasten-cms/core'
|
|
3
|
+
import { TreeBuilder, type FileChange } from './tree-builder'
|
|
4
|
+
import type { GitHubClient } from './github-client'
|
|
5
|
+
|
|
6
|
+
function createMockClient(overrides: Partial<GitHubClient> = {}): GitHubClient {
|
|
7
|
+
return {
|
|
8
|
+
getHeadSha: vi.fn().mockResolvedValue(ok('head-sha-123')),
|
|
9
|
+
getCommitTree: vi.fn().mockResolvedValue(ok('tree-sha-456')),
|
|
10
|
+
createTree: vi.fn().mockResolvedValue(ok('new-tree-sha-789')),
|
|
11
|
+
createCommit: vi.fn().mockResolvedValue(ok('commit-sha-abc')),
|
|
12
|
+
updateRef: vi.fn().mockResolvedValue(ok(undefined)),
|
|
13
|
+
...overrides,
|
|
14
|
+
} as unknown as GitHubClient
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('TreeBuilder', () => {
|
|
18
|
+
const files: FileChange[] = [
|
|
19
|
+
{ path: 'content/posts/hello.json', content: '{"title":"Hello"}' },
|
|
20
|
+
{ path: 'content/posts/world.json', content: '{"title":"World"}' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
it('commits files through the full Git tree flow', async () => {
|
|
24
|
+
const client = createMockClient()
|
|
25
|
+
const builder = new TreeBuilder(client)
|
|
26
|
+
|
|
27
|
+
const result = await builder.commit(files, 'Update posts')
|
|
28
|
+
|
|
29
|
+
expect(result.ok).toBe(true)
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
expect(result.value.sha).toBe('commit-sha-abc')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
expect(client.getHeadSha).toHaveBeenCalledOnce()
|
|
35
|
+
expect(client.getCommitTree).toHaveBeenCalledWith('head-sha-123')
|
|
36
|
+
expect(client.createTree).toHaveBeenCalledWith('tree-sha-456', files)
|
|
37
|
+
expect(client.createCommit).toHaveBeenCalledWith('new-tree-sha-789', 'head-sha-123', 'Update posts')
|
|
38
|
+
expect(client.updateRef).toHaveBeenCalledWith('commit-sha-abc')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns error if getHeadSha fails', async () => {
|
|
42
|
+
const client = createMockClient({
|
|
43
|
+
getHeadSha: vi.fn().mockResolvedValue(err(networkError('Network down'))),
|
|
44
|
+
})
|
|
45
|
+
const builder = new TreeBuilder(client)
|
|
46
|
+
|
|
47
|
+
const result = await builder.commit(files, 'msg')
|
|
48
|
+
expect(result.ok).toBe(false)
|
|
49
|
+
expect(client.getCommitTree).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns error if getCommitTree fails', async () => {
|
|
53
|
+
const client = createMockClient({
|
|
54
|
+
getCommitTree: vi.fn().mockResolvedValue(err(networkError('Commit not found'))),
|
|
55
|
+
})
|
|
56
|
+
const builder = new TreeBuilder(client)
|
|
57
|
+
|
|
58
|
+
const result = await builder.commit(files, 'msg')
|
|
59
|
+
expect(result.ok).toBe(false)
|
|
60
|
+
expect(client.createTree).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns error if createTree fails', async () => {
|
|
64
|
+
const client = createMockClient({
|
|
65
|
+
createTree: vi.fn().mockResolvedValue(err(networkError('Tree creation failed'))),
|
|
66
|
+
})
|
|
67
|
+
const builder = new TreeBuilder(client)
|
|
68
|
+
|
|
69
|
+
const result = await builder.commit(files, 'msg')
|
|
70
|
+
expect(result.ok).toBe(false)
|
|
71
|
+
expect(client.createCommit).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns error if createCommit fails', async () => {
|
|
75
|
+
const client = createMockClient({
|
|
76
|
+
createCommit: vi.fn().mockResolvedValue(err(networkError('Commit failed'))),
|
|
77
|
+
})
|
|
78
|
+
const builder = new TreeBuilder(client)
|
|
79
|
+
|
|
80
|
+
const result = await builder.commit(files, 'msg')
|
|
81
|
+
expect(result.ok).toBe(false)
|
|
82
|
+
expect(client.updateRef).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns error if updateRef fails', async () => {
|
|
86
|
+
const client = createMockClient({
|
|
87
|
+
updateRef: vi.fn().mockResolvedValue(err(networkError('Ref update failed'))),
|
|
88
|
+
})
|
|
89
|
+
const builder = new TreeBuilder(client)
|
|
90
|
+
|
|
91
|
+
const result = await builder.commit(files, 'msg')
|
|
92
|
+
expect(result.ok).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Result } from '@setzkasten-cms/core'
|
|
2
|
+
import { ok, err, networkError } from '@setzkasten-cms/core'
|
|
3
|
+
import type { GitHubClient } from './github-client'
|
|
4
|
+
|
|
5
|
+
export interface FileChange {
|
|
6
|
+
path: string
|
|
7
|
+
content: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Batch commit builder using the Git Trees API.
|
|
12
|
+
*
|
|
13
|
+
* Instead of creating individual commits per file (Keystatic's approach),
|
|
14
|
+
* this creates a single atomic commit with all changes.
|
|
15
|
+
*
|
|
16
|
+
* Flow:
|
|
17
|
+
* 1. GET current HEAD SHA
|
|
18
|
+
* 2. GET tree SHA of HEAD commit
|
|
19
|
+
* 3. POST new tree with all file changes
|
|
20
|
+
* 4. POST new commit pointing to the new tree
|
|
21
|
+
* 5. PATCH ref to point to the new commit
|
|
22
|
+
*/
|
|
23
|
+
export class TreeBuilder {
|
|
24
|
+
constructor(private client: GitHubClient) {}
|
|
25
|
+
|
|
26
|
+
async commit(files: FileChange[], message: string): Promise<Result<{ sha: string; url?: string }>> {
|
|
27
|
+
// 1. Get current HEAD
|
|
28
|
+
const headResult = await this.client.getHeadSha()
|
|
29
|
+
if (!headResult.ok) return headResult
|
|
30
|
+
|
|
31
|
+
// 2. Get base tree
|
|
32
|
+
const treeResult = await this.client.getCommitTree(headResult.value)
|
|
33
|
+
if (!treeResult.ok) return treeResult
|
|
34
|
+
|
|
35
|
+
// 3. Create new tree with all changes
|
|
36
|
+
const newTreeResult = await this.client.createTree(treeResult.value, files)
|
|
37
|
+
if (!newTreeResult.ok) return newTreeResult
|
|
38
|
+
|
|
39
|
+
// 4. Create commit
|
|
40
|
+
const commitResult = await this.client.createCommit(
|
|
41
|
+
newTreeResult.value,
|
|
42
|
+
headResult.value,
|
|
43
|
+
message,
|
|
44
|
+
)
|
|
45
|
+
if (!commitResult.ok) return commitResult
|
|
46
|
+
|
|
47
|
+
// 5. Update branch ref
|
|
48
|
+
const updateResult = await this.client.updateRef(commitResult.value)
|
|
49
|
+
if (!updateResult.ok) return updateResult
|
|
50
|
+
|
|
51
|
+
return ok({ sha: commitResult.value })
|
|
52
|
+
}
|
|
53
|
+
}
|