@illuma-ai/code-sandbox 1.0.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,415 @@
1
+ /**
2
+ * GitService — GitHub API integration for loading and saving project files.
3
+ *
4
+ * Flow:
5
+ * - Clone: GET repo tree → GET file contents → return FileMap
6
+ * - Commit: POST branch → PUT files → POST PR
7
+ *
8
+ * All operations use the GitHub REST API (no git CLI needed).
9
+ * Works entirely in the browser — no server required.
10
+ */
11
+
12
+ import type {
13
+ FileMap,
14
+ GitCommitRequest,
15
+ GitHubRepo,
16
+ GitPRRequest,
17
+ GitPRResult,
18
+ } from "../types";
19
+
20
+ const GITHUB_API = "https://api.github.com";
21
+
22
+ /** File extensions to treat as text (others are skipped) */
23
+ const TEXT_EXTENSIONS = new Set([
24
+ ".js",
25
+ ".jsx",
26
+ ".ts",
27
+ ".tsx",
28
+ ".json",
29
+ ".html",
30
+ ".htm",
31
+ ".css",
32
+ ".scss",
33
+ ".sass",
34
+ ".less",
35
+ ".md",
36
+ ".mdx",
37
+ ".txt",
38
+ ".yml",
39
+ ".yaml",
40
+ ".toml",
41
+ ".xml",
42
+ ".svg",
43
+ ".env",
44
+ ".gitignore",
45
+ ".eslintrc",
46
+ ".prettierrc",
47
+ ".sh",
48
+ ".bash",
49
+ ".zsh",
50
+ ".py",
51
+ ".rb",
52
+ ".go",
53
+ ".rs",
54
+ ".java",
55
+ ".kt",
56
+ ".c",
57
+ ".cpp",
58
+ ".h",
59
+ ".hpp",
60
+ ".cs",
61
+ ".php",
62
+ ".sql",
63
+ ".graphql",
64
+ ".lock",
65
+ ".mjs",
66
+ ".cjs",
67
+ ".mts",
68
+ ".cts",
69
+ ".vue",
70
+ ".svelte",
71
+ ".astro",
72
+ ]);
73
+
74
+ /** Directories to skip when cloning */
75
+ const SKIP_DIRS = new Set([
76
+ "node_modules",
77
+ ".git",
78
+ "dist",
79
+ "build",
80
+ ".next",
81
+ ".cache",
82
+ "__pycache__",
83
+ ".venv",
84
+ "venv",
85
+ "coverage",
86
+ ".nyc_output",
87
+ ]);
88
+
89
+ /** Max file size to fetch (skip large files) */
90
+ const MAX_FILE_SIZE = 500_000; // 500KB
91
+
92
+ /**
93
+ * GitHub API service for cloning repos and creating PRs.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const git = new GitService('ghp_xxxxx');
98
+ * const files = await git.cloneRepo({ owner: 'user', repo: 'my-app' });
99
+ * // ... user edits files ...
100
+ * const pr = await git.createPR({
101
+ * owner: 'user', repo: 'my-app',
102
+ * branch: 'sandbox/changes', changes: editedFiles,
103
+ * message: 'Update from sandbox', title: 'Sandbox changes',
104
+ * });
105
+ * ```
106
+ */
107
+ export class GitService {
108
+ private token: string;
109
+
110
+ constructor(token: string) {
111
+ this.token = token;
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // Clone: fetch repo files as a FileMap
116
+ // -------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Clone a GitHub repository into a flat file map.
120
+ *
121
+ * Uses the Git Trees API for a single request to get the full file listing,
122
+ * then fetches text file contents in parallel batches.
123
+ *
124
+ * @param repo - GitHub repository coordinates
125
+ * @param onProgress - Optional progress callback (0-100)
126
+ * @returns FileMap of path → content
127
+ */
128
+ async cloneRepo(
129
+ repo: GitHubRepo,
130
+ onProgress?: (percent: number, message: string) => void,
131
+ ): Promise<FileMap> {
132
+ const { owner, repo: repoName, branch = "main", path: subPath } = repo;
133
+
134
+ onProgress?.(5, "Fetching repository structure...");
135
+
136
+ // 1. Get the tree (recursive) for the branch
137
+ const treeUrl = `${GITHUB_API}/repos/${owner}/${repoName}/git/trees/${branch}?recursive=1`;
138
+ const treeRes = await this.fetch(treeUrl);
139
+ const treeData = await treeRes.json();
140
+
141
+ if (!treeData.tree) {
142
+ throw new Error(
143
+ `Failed to fetch repo tree: ${treeData.message || "Unknown error"}`,
144
+ );
145
+ }
146
+
147
+ // 2. Filter to text files, skip large/binary/excluded dirs
148
+ const filesToFetch: Array<{ path: string; sha: string; size: number }> = [];
149
+
150
+ for (const item of treeData.tree) {
151
+ if (item.type !== "blob") {
152
+ continue;
153
+ }
154
+
155
+ // Apply subPath filter if specified
156
+ if (subPath && !item.path.startsWith(subPath)) {
157
+ continue;
158
+ }
159
+
160
+ // Skip excluded directories
161
+ const parts = item.path.split("/");
162
+ if (parts.some((p: string) => SKIP_DIRS.has(p))) {
163
+ continue;
164
+ }
165
+
166
+ // Skip files that are too large
167
+ if (item.size > MAX_FILE_SIZE) {
168
+ continue;
169
+ }
170
+
171
+ // Only include text files
172
+ const ext = "." + item.path.split(".").pop()?.toLowerCase();
173
+ const basename = parts[parts.length - 1];
174
+ // Include files with known extensions, dotfiles, or extensionless config files
175
+ if (
176
+ TEXT_EXTENSIONS.has(ext) ||
177
+ basename.startsWith(".") ||
178
+ !basename.includes(".")
179
+ ) {
180
+ filesToFetch.push({ path: item.path, sha: item.sha, size: item.size });
181
+ }
182
+ }
183
+
184
+ onProgress?.(15, `Found ${filesToFetch.length} files to fetch...`);
185
+
186
+ // 3. Fetch file contents in parallel batches
187
+ const files: FileMap = {};
188
+ const batchSize = 20;
189
+
190
+ for (let i = 0; i < filesToFetch.length; i += batchSize) {
191
+ const batch = filesToFetch.slice(i, i + batchSize);
192
+
193
+ const results = await Promise.all(
194
+ batch.map(async (file) => {
195
+ try {
196
+ const contentUrl = `${GITHUB_API}/repos/${owner}/${repoName}/contents/${file.path}?ref=${branch}`;
197
+ const res = await this.fetch(contentUrl);
198
+ const data = await res.json();
199
+
200
+ if (data.encoding === "base64" && data.content) {
201
+ const content = atob(data.content.replace(/\n/g, ""));
202
+ // Strip subPath prefix if specified
203
+ const relativePath = subPath
204
+ ? file.path.replace(new RegExp(`^${subPath}/?`), "")
205
+ : file.path;
206
+ return { path: relativePath, content };
207
+ }
208
+ return null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }),
213
+ );
214
+
215
+ for (const result of results) {
216
+ if (result) {
217
+ files[result.path] = result.content;
218
+ }
219
+ }
220
+
221
+ const percent =
222
+ 15 + Math.round(((i + batch.length) / filesToFetch.length) * 80);
223
+ onProgress?.(
224
+ percent,
225
+ `Fetching files... (${Math.min(i + batchSize, filesToFetch.length)}/${filesToFetch.length})`,
226
+ );
227
+ }
228
+
229
+ onProgress?.(100, `Loaded ${Object.keys(files).length} files`);
230
+
231
+ return files;
232
+ }
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Commit + PR: write changes back to GitHub
236
+ // -------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Create a new branch with file changes and open a pull request.
240
+ *
241
+ * Steps:
242
+ * 1. Get the base branch's HEAD SHA
243
+ * 2. Create a new branch from that SHA
244
+ * 3. For each changed file, update its contents on the new branch
245
+ * 4. Create a pull request
246
+ */
247
+ async createPR(request: GitPRRequest): Promise<GitPRResult> {
248
+ const {
249
+ owner,
250
+ repo,
251
+ branch,
252
+ baseBranch = "main",
253
+ changes,
254
+ message,
255
+ title,
256
+ body,
257
+ } = request;
258
+
259
+ // 1. Get base branch HEAD SHA
260
+ const refRes = await this.fetch(
261
+ `${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
262
+ );
263
+ const refData = await refRes.json();
264
+ const baseSha = refData.object.sha;
265
+
266
+ // 2. Create new branch
267
+ await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
268
+ method: "POST",
269
+ body: JSON.stringify({
270
+ ref: `refs/heads/${branch}`,
271
+ sha: baseSha,
272
+ }),
273
+ });
274
+
275
+ // 3. Commit each changed file
276
+ for (const [path, content] of Object.entries(changes)) {
277
+ // Get current file SHA (if it exists)
278
+ let fileSha: string | undefined;
279
+ try {
280
+ const fileRes = await this.fetch(
281
+ `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
282
+ );
283
+ const fileData = await fileRes.json();
284
+ fileSha = fileData.sha;
285
+ } catch {
286
+ // File doesn't exist yet — that's fine, we'll create it
287
+ }
288
+
289
+ await this.fetch(
290
+ `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
291
+ {
292
+ method: "PUT",
293
+ body: JSON.stringify({
294
+ message,
295
+ content: btoa(content),
296
+ branch,
297
+ ...(fileSha ? { sha: fileSha } : {}),
298
+ }),
299
+ },
300
+ );
301
+ }
302
+
303
+ // 4. Create pull request
304
+ const prRes = await this.fetch(
305
+ `${GITHUB_API}/repos/${owner}/${repo}/pulls`,
306
+ {
307
+ method: "POST",
308
+ body: JSON.stringify({
309
+ title,
310
+ body:
311
+ body ||
312
+ `Changes from code sandbox.\n\nModified files:\n${Object.keys(
313
+ changes,
314
+ )
315
+ .map((p) => `- ${p}`)
316
+ .join("\n")}`,
317
+ head: branch,
318
+ base: baseBranch,
319
+ }),
320
+ },
321
+ );
322
+
323
+ const prData = await prRes.json();
324
+
325
+ return {
326
+ number: prData.number,
327
+ url: prData.html_url,
328
+ branch,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Commit changes without creating a PR.
334
+ */
335
+ async commit(request: GitCommitRequest): Promise<string> {
336
+ const {
337
+ owner,
338
+ repo,
339
+ branch,
340
+ baseBranch = "main",
341
+ changes,
342
+ message,
343
+ } = request;
344
+
345
+ // Get base SHA
346
+ const refRes = await this.fetch(
347
+ `${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
348
+ );
349
+ const refData = await refRes.json();
350
+ const baseSha = refData.object.sha;
351
+
352
+ // Create branch (may already exist)
353
+ try {
354
+ await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
355
+ method: "POST",
356
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
357
+ });
358
+ } catch {
359
+ // Branch already exists — continue
360
+ }
361
+
362
+ // Commit files
363
+ for (const [path, content] of Object.entries(changes)) {
364
+ let fileSha: string | undefined;
365
+ try {
366
+ const fileRes = await this.fetch(
367
+ `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
368
+ );
369
+ const fileData = await fileRes.json();
370
+ fileSha = fileData.sha;
371
+ } catch {
372
+ // New file
373
+ }
374
+
375
+ await this.fetch(
376
+ `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
377
+ {
378
+ method: "PUT",
379
+ body: JSON.stringify({
380
+ message,
381
+ content: btoa(content),
382
+ branch,
383
+ ...(fileSha ? { sha: fileSha } : {}),
384
+ }),
385
+ },
386
+ );
387
+ }
388
+
389
+ return branch;
390
+ }
391
+
392
+ // -------------------------------------------------------------------------
393
+ // Private
394
+ // -------------------------------------------------------------------------
395
+
396
+ /** Fetch with auth headers */
397
+ private async fetch(url: string, init?: RequestInit): Promise<Response> {
398
+ const res = await fetch(url, {
399
+ ...init,
400
+ headers: {
401
+ Authorization: `Bearer ${this.token}`,
402
+ Accept: "application/vnd.github.v3+json",
403
+ "Content-Type": "application/json",
404
+ ...(init?.headers || {}),
405
+ },
406
+ });
407
+
408
+ if (!res.ok) {
409
+ const body = await res.text();
410
+ throw new Error(`GitHub API error ${res.status}: ${body}`);
411
+ }
412
+
413
+ return res;
414
+ }
415
+ }