@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 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.
@@ -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,3 @@
1
+ export { GitHubClient, type GitHubClientConfig } from './github-client'
2
+ export { GitHubContentRepository } from './github-content-repository'
3
+ export { TreeBuilder, type FileChange } from './tree-builder'
@@ -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
+ }