@sourcepress/github 0.1.0

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.
@@ -0,0 +1,5 @@
1
+ export { GitHubClient } from "./client.js";
2
+ export { listContentFiles, getContentFile, listKnowledgeFiles } from "./content.js";
3
+ export { GitHubPRApprovalProvider } from "./approval.js";
4
+ export type { GitHubClientConfig, GitHubFile, GitHubTreeEntry, GitHubPR } from "./types.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,UAAU,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { GitHubClient } from "./client.js";
2
+ export { listContentFiles, getContentFile, listKnowledgeFiles } from "./content.js";
3
+ export { GitHubPRApprovalProvider } from "./approval.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,33 @@
1
+ export interface GitHubClientConfig {
2
+ owner: string;
3
+ repo: string;
4
+ branch: string;
5
+ token: string;
6
+ }
7
+ export interface GitHubFile {
8
+ path: string;
9
+ sha: string;
10
+ content: string;
11
+ encoding: "utf-8" | "base64";
12
+ }
13
+ export interface GitHubTreeEntry {
14
+ path: string;
15
+ sha: string;
16
+ type: "blob" | "tree";
17
+ size?: number;
18
+ }
19
+ export interface GitHubPR {
20
+ number: number;
21
+ title: string;
22
+ body: string;
23
+ state: "open" | "closed" | "merged";
24
+ html_url: string;
25
+ head: {
26
+ ref: string;
27
+ sha: string;
28
+ };
29
+ base: {
30
+ ref: string;
31
+ };
32
+ }
33
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACnC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;CACtB"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@sourcepress/github",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit",
16
+ "clean": "rm -rf dist"
17
+ },
18
+ "dependencies": {
19
+ "@sourcepress/core": "workspace:*",
20
+ "yaml": "^2.8.3"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.7.0",
24
+ "vitest": "^3.0.0"
25
+ }
26
+ }
@@ -0,0 +1,207 @@
1
+ import type { ContentChange } from "@sourcepress/core";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { GitHubPRApprovalProvider } from "../approval.js";
4
+ import type { GitHubClient } from "../client.js";
5
+
6
+ function makeMockClient(): GitHubClient {
7
+ return {
8
+ createBranch: vi.fn().mockResolvedValue("abc123"),
9
+ createOrUpdateFile: vi.fn().mockResolvedValue({ sha: "def456", commit_sha: "ghi789" }),
10
+ createPR: vi.fn().mockResolvedValue({
11
+ number: 42,
12
+ title: "[SourcePress] create: posts/hello",
13
+ body: "",
14
+ state: "open",
15
+ html_url: "https://github.com/owner/repo/pull/42",
16
+ head: { ref: "sourcepress/approve/posts/hello-123", sha: "abc" },
17
+ base: { ref: "main" },
18
+ }),
19
+ mergePR: vi.fn().mockResolvedValue(undefined),
20
+ closePR: vi.fn().mockResolvedValue(undefined),
21
+ getPR: vi.fn().mockResolvedValue({
22
+ number: 42,
23
+ title: "",
24
+ body: "",
25
+ state: "open",
26
+ html_url: "https://github.com/owner/repo/pull/42",
27
+ head: { ref: "branch", sha: "abc" },
28
+ base: { ref: "main" },
29
+ }),
30
+ getFile: vi.fn(),
31
+ getTree: vi.fn(),
32
+ getHeadSha: vi.fn(),
33
+ owner: "owner",
34
+ repo: "repo",
35
+ branch: "main",
36
+ } as unknown as GitHubClient;
37
+ }
38
+
39
+ const sampleChange: ContentChange = {
40
+ collection: "posts",
41
+ slug: "hello",
42
+ path: "content/posts/hello.md",
43
+ action: "create",
44
+ content: "# Hello\n\nThis is a test post.",
45
+ frontmatter: { title: "Hello", draft: false },
46
+ provenance: {
47
+ generated_by: "claude-3-5-sonnet",
48
+ generated_at: "2026-04-04T10:00:00Z",
49
+ source_files: ["knowledge/brand.md", "knowledge/tone.md"],
50
+ eval_score: 92,
51
+ prompt_version: "v1.2",
52
+ },
53
+ };
54
+
55
+ describe("GitHubPRApprovalProvider", () => {
56
+ let client: GitHubClient;
57
+ let provider: GitHubPRApprovalProvider;
58
+
59
+ beforeEach(() => {
60
+ client = makeMockClient();
61
+ provider = new GitHubPRApprovalProvider(client);
62
+ });
63
+
64
+ it("submit() creates branch, commits file, opens PR", async () => {
65
+ const request = await provider.submit(sampleChange);
66
+
67
+ expect(client.createBranch).toHaveBeenCalledOnce();
68
+ const branchArg = (client.createBranch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
69
+ expect(branchArg).toMatch(/^sourcepress\/approve\/posts\/hello-\d+$/);
70
+
71
+ expect(client.createOrUpdateFile).toHaveBeenCalledWith(
72
+ "content/posts/hello.md",
73
+ "# Hello\n\nThis is a test post.",
74
+ expect.stringContaining("hello"),
75
+ branchArg,
76
+ );
77
+
78
+ expect(client.createPR).toHaveBeenCalledOnce();
79
+
80
+ expect(request.status).toBe("pending");
81
+ expect(request.id).toBe("42");
82
+ expect(request.pr_number).toBe(42);
83
+ expect(request.pr_url).toBe("https://github.com/owner/repo/pull/42");
84
+ });
85
+
86
+ it("status() returns correct status from PR state", async () => {
87
+ const mockGetPR = client.getPR as ReturnType<typeof vi.fn>;
88
+
89
+ mockGetPR.mockResolvedValueOnce({
90
+ number: 1,
91
+ state: "open",
92
+ html_url: "",
93
+ head: { ref: "", sha: "" },
94
+ base: { ref: "" },
95
+ title: "",
96
+ body: "",
97
+ });
98
+ expect(await provider.status("1")).toBe("pending");
99
+
100
+ mockGetPR.mockResolvedValueOnce({
101
+ number: 2,
102
+ state: "merged",
103
+ html_url: "",
104
+ head: { ref: "", sha: "" },
105
+ base: { ref: "" },
106
+ title: "",
107
+ body: "",
108
+ });
109
+ expect(await provider.status("2")).toBe("approved");
110
+
111
+ mockGetPR.mockResolvedValueOnce({
112
+ number: 3,
113
+ state: "closed",
114
+ html_url: "",
115
+ head: { ref: "", sha: "" },
116
+ base: { ref: "" },
117
+ title: "",
118
+ body: "",
119
+ });
120
+ expect(await provider.status("3")).toBe("rejected");
121
+ });
122
+
123
+ it("approve() merges the PR and fires status change callback", async () => {
124
+ await provider.submit(sampleChange);
125
+
126
+ const callback = vi.fn();
127
+ provider.onStatusChange(callback);
128
+
129
+ await provider.approve("42", "editor@example.com", "Looks good");
130
+
131
+ expect(client.mergePR).toHaveBeenCalledWith(42);
132
+ expect(callback).toHaveBeenCalledWith("42", "approved", "editor@example.com");
133
+
134
+ const requests = await provider.pending();
135
+ expect(requests).toHaveLength(0);
136
+ });
137
+
138
+ it("reject() closes the PR and fires status change callback", async () => {
139
+ await provider.submit(sampleChange);
140
+
141
+ const callback = vi.fn();
142
+ provider.onStatusChange(callback);
143
+
144
+ await provider.reject("42", "editor@example.com", "Needs revision");
145
+
146
+ expect(client.closePR).toHaveBeenCalledWith(42);
147
+ expect(callback).toHaveBeenCalledWith("42", "rejected", "editor@example.com");
148
+
149
+ const requests = await provider.pending();
150
+ expect(requests).toHaveLength(0);
151
+ });
152
+
153
+ it("pending() returns only pending requests", async () => {
154
+ const mockCreatePR = client.createPR as ReturnType<typeof vi.fn>;
155
+ mockCreatePR
156
+ .mockResolvedValueOnce({
157
+ number: 1,
158
+ state: "open",
159
+ html_url: "https://github.com/owner/repo/pull/1",
160
+ head: { ref: "b1", sha: "s1" },
161
+ base: { ref: "main" },
162
+ title: "",
163
+ body: "",
164
+ })
165
+ .mockResolvedValueOnce({
166
+ number: 2,
167
+ state: "open",
168
+ html_url: "https://github.com/owner/repo/pull/2",
169
+ head: { ref: "b2", sha: "s2" },
170
+ base: { ref: "main" },
171
+ title: "",
172
+ body: "",
173
+ })
174
+ .mockResolvedValueOnce({
175
+ number: 3,
176
+ state: "open",
177
+ html_url: "https://github.com/owner/repo/pull/3",
178
+ head: { ref: "b3", sha: "s3" },
179
+ base: { ref: "main" },
180
+ title: "",
181
+ body: "",
182
+ });
183
+
184
+ await provider.submit({ ...sampleChange, slug: "post-1", path: "content/posts/post-1.md" });
185
+ await provider.submit({ ...sampleChange, slug: "post-2", path: "content/posts/post-2.md" });
186
+ await provider.submit({ ...sampleChange, slug: "post-3", path: "content/posts/post-3.md" });
187
+
188
+ await provider.approve("1", "editor@example.com");
189
+ await provider.reject("2", "editor@example.com", "Not ready");
190
+
191
+ const pending = await provider.pending();
192
+ expect(pending).toHaveLength(1);
193
+ expect(pending[0].id).toBe("3");
194
+ });
195
+
196
+ it("submit() includes EU AI Act provenance in PR body", async () => {
197
+ await provider.submit(sampleChange);
198
+
199
+ const mockCreatePR = client.createPR as ReturnType<typeof vi.fn>;
200
+ const prBody = mockCreatePR.mock.calls[0][1] as string;
201
+
202
+ expect(prBody).toContain("claude-3-5-sonnet");
203
+ expect(prBody).toContain("knowledge/brand.md");
204
+ expect(prBody).toContain("2026-04-04T10:00:00Z");
205
+ expect(prBody).toContain("92");
206
+ });
207
+ });
@@ -0,0 +1,68 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { GitHubClient } from "../client.js";
3
+ import { getContentFile, listContentFiles } from "../content.js";
4
+
5
+ const mockFetch = vi.fn();
6
+ vi.stubGlobal("fetch", mockFetch);
7
+
8
+ function mockResponse(data: unknown, status = 200) {
9
+ return {
10
+ ok: status >= 200 && status < 300,
11
+ status,
12
+ json: () => Promise.resolve(data),
13
+ text: () => Promise.resolve(JSON.stringify(data)),
14
+ };
15
+ }
16
+
17
+ describe("content operations", () => {
18
+ let client: GitHubClient;
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ client = new GitHubClient({ owner: "test", repo: "site", branch: "main", token: "test-token" });
23
+ });
24
+
25
+ describe("listContentFiles", () => {
26
+ it("returns only files under the content path", async () => {
27
+ mockFetch.mockResolvedValueOnce(
28
+ mockResponse({
29
+ tree: [
30
+ { path: "content/posts/hello.mdx", sha: "abc", type: "blob" },
31
+ { path: "content/posts/world.mdx", sha: "def", type: "blob" },
32
+ { path: "knowledge/client.md", sha: "ghi", type: "blob" },
33
+ { path: "content/posts", sha: "jkl", type: "tree" },
34
+ ],
35
+ }),
36
+ );
37
+ const files = await listContentFiles(client, "content/");
38
+ expect(files).toEqual(["content/posts/hello.mdx", "content/posts/world.mdx"]);
39
+ });
40
+ });
41
+
42
+ describe("getContentFile", () => {
43
+ it("parses frontmatter and body from MDX file", async () => {
44
+ const rawContent = "---\ntitle: Hello World\ndraft: true\n---\n\nThis is the body.";
45
+ mockFetch.mockResolvedValueOnce(
46
+ mockResponse({
47
+ path: "content/posts/hello.mdx",
48
+ sha: "abc123",
49
+ content: btoa(rawContent),
50
+ encoding: "base64",
51
+ }),
52
+ );
53
+ const file = await getContentFile(client, "content/posts/hello.mdx", "posts");
54
+ expect(file).not.toBeNull();
55
+ expect(file?.slug).toBe("hello");
56
+ expect(file?.collection).toBe("posts");
57
+ expect(file?.frontmatter.title).toBe("Hello World");
58
+ expect(file?.frontmatter.draft).toBe(true);
59
+ expect(file?.body).toBe("This is the body.");
60
+ });
61
+
62
+ it("returns null for non-existent file", async () => {
63
+ mockFetch.mockResolvedValueOnce(mockResponse(null, 404));
64
+ const file = await getContentFile(client, "content/posts/nope.mdx", "posts");
65
+ expect(file).toBeNull();
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,117 @@
1
+ import type {
2
+ ApprovalProvider,
3
+ ApprovalRequest,
4
+ ApprovalStatus,
5
+ ContentChange,
6
+ StatusChangeHandler,
7
+ } from "@sourcepress/core";
8
+ import type { GitHubClient } from "./client.js";
9
+
10
+ export class GitHubPRApprovalProvider implements ApprovalProvider {
11
+ private client: GitHubClient;
12
+ private requests: Map<string, ApprovalRequest> = new Map();
13
+ private callbacks: StatusChangeHandler[] = [];
14
+
15
+ constructor(client: GitHubClient) {
16
+ this.client = client;
17
+ }
18
+
19
+ async submit(change: ContentChange): Promise<ApprovalRequest> {
20
+ const timestamp = Date.now();
21
+ const branchName = `sourcepress/approve/${change.collection}/${change.slug}-${timestamp}`;
22
+
23
+ await this.client.createBranch(branchName);
24
+ await this.client.createOrUpdateFile(
25
+ change.path,
26
+ change.content,
27
+ `chore: submit ${change.action} ${change.slug} for approval`,
28
+ branchName,
29
+ );
30
+
31
+ const prBody = [
32
+ "## SourcePress Approval Request",
33
+ "",
34
+ `**Collection:** ${change.collection}`,
35
+ `**Slug:** ${change.slug}`,
36
+ `**Action:** ${change.action}`,
37
+ "",
38
+ "## EU AI Act Provenance",
39
+ "",
40
+ "| Field | Value |",
41
+ "|-------|-------|",
42
+ `| generated_by | ${change.provenance.generated_by} |`,
43
+ `| generated_at | ${change.provenance.generated_at} |`,
44
+ `| source_files | ${change.provenance.source_files.join(", ")} |`,
45
+ `| eval_score | ${change.provenance.eval_score ?? "N/A"} |`,
46
+ `| prompt_version | ${change.provenance.prompt_version ?? "N/A"} |`,
47
+ ].join("\n");
48
+
49
+ const pr = await this.client.createPR(
50
+ `[SourcePress] ${change.action}: ${change.collection}/${change.slug}`,
51
+ prBody,
52
+ branchName,
53
+ );
54
+
55
+ const request: ApprovalRequest = {
56
+ id: String(pr.number),
57
+ change,
58
+ status: "pending",
59
+ submitted_at: new Date().toISOString(),
60
+ submitted_by: change.provenance.generated_by,
61
+ pr_url: pr.html_url,
62
+ pr_number: pr.number,
63
+ };
64
+
65
+ this.requests.set(request.id, request);
66
+ return request;
67
+ }
68
+
69
+ async status(id: string): Promise<ApprovalStatus> {
70
+ const pr = await this.client.getPR(Number(id));
71
+ if (pr.state === "open") return "pending";
72
+ // GitHub API returns "closed" for both closed and merged; check merged field
73
+ // The GitHubPR type uses "merged" as a state value per our types.ts
74
+ if (pr.state === "merged") return "approved";
75
+ return "rejected";
76
+ }
77
+
78
+ async approve(id: string, by: string, comment?: string): Promise<void> {
79
+ await this.client.mergePR(Number(id));
80
+
81
+ const existing = this.requests.get(id);
82
+ if (existing) {
83
+ existing.status = "approved";
84
+ existing.reviewed_by = by;
85
+ existing.reviewed_at = new Date().toISOString();
86
+ if (comment) existing.review_comment = comment;
87
+ }
88
+
89
+ for (const cb of this.callbacks) {
90
+ cb(id, "approved", by);
91
+ }
92
+ }
93
+
94
+ async reject(id: string, by: string, reason: string): Promise<void> {
95
+ await this.client.closePR(Number(id));
96
+
97
+ const existing = this.requests.get(id);
98
+ if (existing) {
99
+ existing.status = "rejected";
100
+ existing.reviewed_by = by;
101
+ existing.reviewed_at = new Date().toISOString();
102
+ existing.review_comment = reason;
103
+ }
104
+
105
+ for (const cb of this.callbacks) {
106
+ cb(id, "rejected", by);
107
+ }
108
+ }
109
+
110
+ async pending(): Promise<ApprovalRequest[]> {
111
+ return Array.from(this.requests.values()).filter((r) => r.status === "pending");
112
+ }
113
+
114
+ onStatusChange(callback: StatusChangeHandler): void {
115
+ this.callbacks.push(callback);
116
+ }
117
+ }
package/src/client.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { GitHubClientConfig, GitHubFile, GitHubPR, GitHubTreeEntry } from "./types.js";
2
+
3
+ interface GitHubFileResponse {
4
+ path: string;
5
+ sha: string;
6
+ content: string;
7
+ encoding: string;
8
+ }
9
+
10
+ interface GitHubTreeResponse {
11
+ tree: Array<{ path: string; sha: string; type: "blob" | "tree"; size: number }>;
12
+ }
13
+
14
+ interface GitHubCreateFileResponse {
15
+ content: { sha: string };
16
+ commit: { sha: string };
17
+ }
18
+
19
+ interface GitHubRefResponse {
20
+ object: { sha: string };
21
+ }
22
+
23
+ export class GitHubClient {
24
+ private baseUrl: string;
25
+ private headers: Record<string, string>;
26
+ readonly owner: string;
27
+ readonly repo: string;
28
+ readonly branch: string;
29
+
30
+ constructor(config: GitHubClientConfig) {
31
+ this.owner = config.owner;
32
+ this.repo = config.repo;
33
+ this.branch = config.branch;
34
+ this.baseUrl = `https://api.github.com/repos/${config.owner}/${config.repo}`;
35
+ this.headers = {
36
+ Authorization: `Bearer ${config.token}`,
37
+ Accept: "application/vnd.github.v3+json",
38
+ "X-GitHub-Api-Version": "2022-11-28",
39
+ };
40
+ }
41
+
42
+ async getFile(path: string, ref?: string): Promise<GitHubFile | null> {
43
+ const url = `${this.baseUrl}/contents/${path}${ref ? `?ref=${ref}` : ""}`;
44
+ const response = await fetch(url, { headers: this.headers });
45
+ if (response.status === 404) return null;
46
+ if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);
47
+ const data = (await response.json()) as GitHubFileResponse;
48
+ let content: string;
49
+ if (data.encoding === "base64") {
50
+ const binary = atob(data.content.replace(/\n/g, ""));
51
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
52
+ content = new TextDecoder("utf-8").decode(bytes);
53
+ } else {
54
+ content = data.content;
55
+ }
56
+ return { path: data.path, sha: data.sha, content, encoding: "utf-8" };
57
+ }
58
+
59
+ async getTree(sha?: string): Promise<GitHubTreeEntry[]> {
60
+ const treeSha = sha ?? this.branch;
61
+ const url = `${this.baseUrl}/git/trees/${treeSha}?recursive=1`;
62
+ const response = await fetch(url, { headers: this.headers });
63
+ if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);
64
+ const data = (await response.json()) as GitHubTreeResponse;
65
+ return data.tree.map((entry) => ({
66
+ path: entry.path,
67
+ sha: entry.sha,
68
+ type: entry.type,
69
+ size: entry.size,
70
+ }));
71
+ }
72
+
73
+ async createOrUpdateFile(
74
+ path: string,
75
+ content: string,
76
+ message: string,
77
+ branch?: string,
78
+ existingSha?: string,
79
+ ): Promise<{ sha: string; commit_sha: string }> {
80
+ const url = `${this.baseUrl}/contents/${path}`;
81
+ const body: Record<string, unknown> = {
82
+ message,
83
+ content:
84
+ typeof Buffer !== "undefined"
85
+ ? Buffer.from(content, "utf-8").toString("base64")
86
+ : btoa(String.fromCodePoint(...new TextEncoder().encode(content))),
87
+ branch: branch ?? this.branch,
88
+ };
89
+ if (existingSha) body.sha = existingSha;
90
+ const response = await fetch(url, {
91
+ method: "PUT",
92
+ headers: { ...this.headers, "Content-Type": "application/json" },
93
+ body: JSON.stringify(body),
94
+ });
95
+ if (!response.ok) {
96
+ throw new Error(`GitHub API error: ${response.status}`);
97
+ }
98
+ const data = (await response.json()) as GitHubCreateFileResponse;
99
+ return { sha: data.content.sha, commit_sha: data.commit.sha };
100
+ }
101
+
102
+ async createBranch(branchName: string, fromSha?: string): Promise<string> {
103
+ const sha = fromSha ?? (await this.getHeadSha());
104
+ const url = `${this.baseUrl}/git/refs`;
105
+ const response = await fetch(url, {
106
+ method: "POST",
107
+ headers: { ...this.headers, "Content-Type": "application/json" },
108
+ body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }),
109
+ });
110
+ if (!response.ok) {
111
+ throw new Error(`GitHub API error: ${response.status}`);
112
+ }
113
+ const data = (await response.json()) as GitHubRefResponse;
114
+ return data.object.sha;
115
+ }
116
+
117
+ async createPR(title: string, body: string, head: string, base?: string): Promise<GitHubPR> {
118
+ const url = `${this.baseUrl}/pulls`;
119
+ const response = await fetch(url, {
120
+ method: "POST",
121
+ headers: { ...this.headers, "Content-Type": "application/json" },
122
+ body: JSON.stringify({ title, body, head, base: base ?? this.branch }),
123
+ });
124
+ if (!response.ok) {
125
+ throw new Error(`GitHub API error: ${response.status}`);
126
+ }
127
+ return (await response.json()) as GitHubPR;
128
+ }
129
+
130
+ async mergePR(prNumber: number): Promise<void> {
131
+ const url = `${this.baseUrl}/pulls/${prNumber}/merge`;
132
+ const response = await fetch(url, {
133
+ method: "PUT",
134
+ headers: { ...this.headers, "Content-Type": "application/json" },
135
+ body: JSON.stringify({ merge_method: "squash" }),
136
+ });
137
+ if (!response.ok) {
138
+ throw new Error(`GitHub API error: ${response.status}`);
139
+ }
140
+ }
141
+
142
+ async getPR(prNumber: number): Promise<GitHubPR> {
143
+ const url = `${this.baseUrl}/pulls/${prNumber}`;
144
+ const response = await fetch(url, { headers: this.headers });
145
+ if (!response.ok) {
146
+ throw new Error(`GitHub API error: ${response.status}`);
147
+ }
148
+ return (await response.json()) as GitHubPR;
149
+ }
150
+
151
+ async closePR(prNumber: number): Promise<void> {
152
+ const url = `${this.baseUrl}/pulls/${prNumber}`;
153
+ const response = await fetch(url, {
154
+ method: "PATCH",
155
+ headers: { ...this.headers, "Content-Type": "application/json" },
156
+ body: JSON.stringify({ state: "closed" }),
157
+ });
158
+ if (!response.ok) {
159
+ throw new Error(`GitHub API error: ${response.status}`);
160
+ }
161
+ }
162
+
163
+ async getHeadSha(): Promise<string> {
164
+ const url = `${this.baseUrl}/git/ref/heads/${this.branch}`;
165
+ const response = await fetch(url, { headers: this.headers });
166
+ if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);
167
+ const data = (await response.json()) as GitHubRefResponse;
168
+ return data.object.sha;
169
+ }
170
+ }