Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

import.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

import.tsxBlame422 lines · 1 contributor
bdbd0deClaude1/**
2 * GitHub Import — automatic migration from GitHub to gluecron.
3 *
4 * Developer connects GitHub, gluecron pulls ALL their repos
5 * automatically. Issues, descriptions, branches — everything.
6 * One click. Walk away. Come back to everything migrated.
7 */
8
9import { Hono } from "hono";
80bed05Claude10import { and, eq } from "drizzle-orm";
bdbd0deClaude11import { db } from "../db";
80bed05Claude12import { repositories } from "../db/schema";
bdbd0deClaude13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
a4f3e24Claude15import { requireAdmin } from "../middleware/admin";
bdbd0deClaude16import type { AuthEnv } from "../middleware/auth";
17import { config } from "../lib/config";
18import { mkdir } from "fs/promises";
19import { join } from "path";
80bed05Claude20import {
21 parseGithubUrl,
22 sanitizeRepoName,
23 buildCloneUrl,
24} from "../lib/import-helper";
bdbd0deClaude25
26const importRoutes = new Hono<AuthEnv>();
27
28importRoutes.use("*", softAuth);
29
30interface GitHubRepo {
31 name: string;
32 full_name: string;
33 description: string | null;
34 private: boolean;
35 clone_url: string;
36 default_branch: string;
37 stargazers_count: number;
38 fork: boolean;
39 language: string | null;
40}
41
42// ─── IMPORT PAGE ─────────────────────────────────────────────
43
a4f3e24Claude44importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude45 const user = c.get("user")!;
46 const success = c.req.query("success");
47 const error = c.req.query("error");
48 const imported = c.req.query("imported");
49
80bed05Claude50 // Inline progress banner: the clone subprocess can take 30+s for big
51 // repos, so give the user visible feedback while the POST is in flight.
52 // Pure client-side — no extra routes, no websockets, no polling.
53 const progressScript = `
54 (function () {
55 var forms = document.querySelectorAll('form[data-import-form]');
56 var banner = document.getElementById('import-progress');
57 if (!banner) return;
58 forms.forEach(function (form) {
59 form.addEventListener('submit', function () {
60 // Validate non-empty required fields before showing progress.
61 var req = form.querySelectorAll('[required]');
62 for (var i = 0; i < req.length; i++) {
63 if (!req[i].value || !req[i].value.trim()) return;
64 }
65 banner.style.display = 'block';
66 var btns = form.querySelectorAll('button[type="submit"]');
67 btns.forEach(function (b) {
68 b.disabled = true;
69 b.textContent = 'Importing…';
70 });
71 // Scroll banner into view so user sees progress above the fold.
72 try { banner.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
73 });
74 });
75 })();
76 `;
77
bdbd0deClaude78 return c.html(
79 <Layout title="Import from GitHub" user={user}>
80 <div style="max-width: 700px">
81 <h2 style="margin-bottom: 4px">Import from GitHub</h2>
82 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
83 Migrate your repositories from GitHub to gluecron automatically.
84 All branches, all history, all code — one click.
85 </p>
86 {success && (
87 <div class="auth-success">
88 {decodeURIComponent(success)}
89 {imported && (
90 <div style="margin-top: 8px">
91 Successfully imported {decodeURIComponent(imported)} repositories.
92 </div>
93 )}
80bed05Claude94 <div style="margin-top: 10px">
95 <a href={`/${user.username}`} class="btn btn-primary" style="margin-right: 8px">
96 View my repositories
97 </a>
98 <a href="/explore" class="btn">Explore</a>
99 </div>
bdbd0deClaude100 </div>
101 )}
102 {error && (
103 <div class="auth-error">{decodeURIComponent(error)}</div>
104 )}
105
80bed05Claude106 <div
107 id="import-progress"
108 role="status"
109 aria-live="polite"
110 style="display: none; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #f0b429; border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px"
111 >
112 <strong>Import in progress…</strong>
113 <div style="color: var(--text-muted); margin-top: 4px">
114 Cloning from GitHub. Large repositories can take 30+ seconds — don't close this tab.
115 </div>
116 </div>
117
bdbd0deClaude118 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
119 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
120 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
121 Import all public repositories from a GitHub user or organization.
122 </p>
80bed05Claude123 <form method="POST" action="/import/github/user" data-import-form>
bdbd0deClaude124 <div style="display: flex; gap: 8px">
125 <input
126 type="text"
127 name="github_username"
128 required
129 placeholder="GitHub username or org"
130 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
131 />
132 <button type="submit" class="btn btn-primary">
133 Import all repos
134 </button>
135 </div>
136 </form>
137 </div>
138
139 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
140 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
141 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
80bed05Claude142 Import a specific repository by URL (https, ssh, or owner/repo).
bdbd0deClaude143 </p>
80bed05Claude144 <form method="POST" action="/import/github/repo" data-import-form>
bdbd0deClaude145 <div style="display: flex; gap: 8px">
146 <input
147 type="text"
148 name="repo_url"
149 required
150 placeholder="https://github.com/owner/repo"
151 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
152 />
153 <button type="submit" class="btn btn-primary">
154 Import
155 </button>
156 </div>
157 </form>
158 </div>
159
160 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
161 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
162 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
163 Use a GitHub personal access token to import private repositories too.
164 Generate one at github.com → Settings → Developer settings → Personal access tokens.
165 </p>
80bed05Claude166 <form method="POST" action="/import/github/user" data-import-form>
bdbd0deClaude167 <div class="form-group">
168 <input
169 type="text"
170 name="github_username"
171 required
172 placeholder="GitHub username"
173 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
174 />
175 </div>
176 <div class="form-group">
177 <input
178 type="password"
179 name="github_token"
80bed05Claude180 required
bdbd0deClaude181 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
182 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: var(--font-mono); width: 100%"
183 />
184 </div>
185 <button type="submit" class="btn btn-primary">
186 Import all repos (public + private)
187 </button>
188 </form>
189 </div>
190 </div>
80bed05Claude191 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude192 </Layout>
193 );
194});
195
196// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
197
a4f3e24Claude198importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude199 const user = c.get("user")!;
200 const body = await c.req.parseBody();
201 const githubUsername = String(body.github_username || "").trim();
202 const githubToken = String(body.github_token || "").trim() || null;
203
204 if (!githubUsername) {
205 return c.redirect("/import?error=GitHub+username+is+required");
206 }
207
208 try {
209 // Fetch repos from GitHub API
210 const headers: Record<string, string> = {
211 Accept: "application/vnd.github.v3+json",
212 "User-Agent": "gluecron/1.0",
213 };
214 if (githubToken) {
215 headers.Authorization = `Bearer ${githubToken}`;
216 }
217
218 const repos: GitHubRepo[] = [];
219 let page = 1;
220 while (true) {
221 const url = githubToken
222 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
223 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
224
225 const res = await fetch(url, { headers });
226 if (!res.ok) {
227 const errText = await res.text();
228 return c.redirect(
229 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
230 );
231 }
232 const batch: GitHubRepo[] = await res.json();
233 if (batch.length === 0) break;
234 repos.push(...batch);
235 page++;
236 if (page > 10) break; // safety limit: 1000 repos
237 }
238
239 if (repos.length === 0) {
240 return c.redirect("/import?error=No+repositories+found+for+this+user");
241 }
242
243 // Import each repo
244 let imported = 0;
245 let skipped = 0;
80bed05Claude246 let failed = 0;
bdbd0deClaude247
248 for (const ghRepo of repos) {
80bed05Claude249 const targetName = sanitizeRepoName(ghRepo.name);
250
251 // Check uniqueness in THIS user's namespace (owner+name is the
252 // real unique key — the previous check ignored ownerId and
253 // could skip repos other users happened to share a name with).
bdbd0deClaude254 const [existing] = await db
255 .select()
256 .from(repositories)
257 .where(
80bed05Claude258 and(
259 eq(repositories.ownerId, user.id),
260 eq(repositories.name, targetName)
261 )
bdbd0deClaude262 )
263 .limit(1);
264
80bed05Claude265 if (existing) {
bdbd0deClaude266 skipped++;
267 continue;
268 }
269
270 try {
271 await importSingleRepo(user, ghRepo, githubToken);
272 imported++;
273 } catch (err) {
80bed05Claude274 failed++;
bdbd0deClaude275 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
276 }
277 }
278
80bed05Claude279 const summary =
280 `${imported}+imported%2C+${skipped}+skipped` +
281 (failed > 0 ? `%2C+${failed}+failed` : "");
282 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude283 } catch (err) {
284 console.error("[import] error:", err);
285 return c.redirect(
286 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
287 );
288 }
289});
290
291// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
292
a4f3e24Claude293importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude294 const user = c.get("user")!;
295 const body = await c.req.parseBody();
296 const repoUrl = String(body.repo_url || "").trim();
297
298 if (!repoUrl) {
299 return c.redirect("/import?error=Repository+URL+is+required");
300 }
301
80bed05Claude302 const parsed = parseGithubUrl(repoUrl);
303 if (!parsed) {
304 return c.redirect(
305 "/import?error=" +
306 encodeURIComponent(
307 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
308 )
309 );
bdbd0deClaude310 }
311
80bed05Claude312 const { owner: ghOwner, repo: ghRepo } = parsed;
313
314 // Guard against double-import before we spin up a clone subprocess.
315 const targetName = sanitizeRepoName(ghRepo);
316 const [existing] = await db
317 .select()
318 .from(repositories)
319 .where(
320 and(
321 eq(repositories.ownerId, user.id),
322 eq(repositories.name, targetName)
323 )
324 )
325 .limit(1);
326 if (existing) {
327 return c.redirect(
328 `/import?error=${encodeURIComponent(
329 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
330 )}`
331 );
332 }
bdbd0deClaude333
334 try {
335 // Fetch repo info
336 const res = await fetch(
337 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
338 {
339 headers: {
340 Accept: "application/vnd.github.v3+json",
341 "User-Agent": "gluecron/1.0",
342 },
343 }
344 );
345
346 if (!res.ok) {
80bed05Claude347 return c.redirect(
348 `/import?error=${encodeURIComponent(
349 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
350 )}`
351 );
bdbd0deClaude352 }
353
354 const ghRepoData: GitHubRepo = await res.json();
355
356 await importSingleRepo(user, ghRepoData, null);
357
80bed05Claude358 return c.redirect(`/${user.username}/${sanitizeRepoName(ghRepoData.name)}`);
bdbd0deClaude359 } catch (err) {
360 console.error("[import] error:", err);
361 return c.redirect(
362 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
363 );
364 }
365});
366
367// ─── CORE IMPORT FUNCTION ────────────────────────────────────
368
369async function importSingleRepo(
370 user: { id: string; username: string },
371 ghRepo: GitHubRepo,
372 token: string | null
373): Promise<void> {
80bed05Claude374 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude375 const destPath = join(
376 config.gitReposPath,
377 user.username,
80bed05Claude378 `${safeName}.git`
bdbd0deClaude379 );
380
381 // Ensure parent directory exists
382 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
383
384 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude385 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude386
387 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
388
389 const proc = Bun.spawn(
390 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
391 {
392 stdout: "pipe",
393 stderr: "pipe",
394 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
395 }
396 );
397 const stderr = await new Response(proc.stderr).text();
398 const exitCode = await proc.exited;
399
400 if (exitCode !== 0) {
80bed05Claude401 // Never echo the token back in an error message.
402 const sanitized = token
403 ? stderr.replaceAll(token, "***")
404 : stderr;
405 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude406 }
407
408 // Insert into database
409 await db.insert(repositories).values({
80bed05Claude410 name: safeName,
bdbd0deClaude411 ownerId: user.id,
412 description: ghRepo.description,
413 isPrivate: ghRepo.private,
414 defaultBranch: ghRepo.default_branch || "main",
415 diskPath: destPath,
416 starCount: 0,
417 });
418
419 console.log(`[import] ${ghRepo.full_name} imported successfully`);
420}
421
422export default importRoutes;