Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame431 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>
14c3cc8Claude86 <a
87 href="/import/bulk"
88 style="display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #3fb950; border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px; text-decoration: none; color: inherit"
89 >
90 <strong>Migrating a whole org? Try the bulk importer →</strong>
91 <div style="color: var(--text-muted); margin-top: 4px">
92 Paste a GitHub org name + token and clone every repo in one shot.
93 </div>
94 </a>
bdbd0deClaude95 {success && (
96 <div class="auth-success">
97 {decodeURIComponent(success)}
98 {imported && (
99 <div style="margin-top: 8px">
100 Successfully imported {decodeURIComponent(imported)} repositories.
101 </div>
102 )}
80bed05Claude103 <div style="margin-top: 10px">
104 <a href={`/${user.username}`} class="btn btn-primary" style="margin-right: 8px">
105 View my repositories
106 </a>
107 <a href="/explore" class="btn">Explore</a>
108 </div>
bdbd0deClaude109 </div>
110 )}
111 {error && (
112 <div class="auth-error">{decodeURIComponent(error)}</div>
113 )}
114
80bed05Claude115 <div
116 id="import-progress"
117 role="status"
118 aria-live="polite"
119 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"
120 >
121 <strong>Import in progress…</strong>
122 <div style="color: var(--text-muted); margin-top: 4px">
123 Cloning from GitHub. Large repositories can take 30+ seconds — don't close this tab.
124 </div>
125 </div>
126
bdbd0deClaude127 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
128 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
129 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
130 Import all public repositories from a GitHub user or organization.
131 </p>
80bed05Claude132 <form method="POST" action="/import/github/user" data-import-form>
bdbd0deClaude133 <div style="display: flex; gap: 8px">
134 <input
135 type="text"
136 name="github_username"
137 required
138 placeholder="GitHub username or org"
139 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
140 />
141 <button type="submit" class="btn btn-primary">
142 Import all repos
143 </button>
144 </div>
145 </form>
146 </div>
147
148 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
149 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
150 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
80bed05Claude151 Import a specific repository by URL (https, ssh, or owner/repo).
bdbd0deClaude152 </p>
80bed05Claude153 <form method="POST" action="/import/github/repo" data-import-form>
bdbd0deClaude154 <div style="display: flex; gap: 8px">
155 <input
156 type="text"
157 name="repo_url"
158 required
159 placeholder="https://github.com/owner/repo"
160 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
161 />
162 <button type="submit" class="btn btn-primary">
163 Import
164 </button>
165 </div>
166 </form>
167 </div>
168
169 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
170 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
171 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
172 Use a GitHub personal access token to import private repositories too.
173 Generate one at github.com → Settings → Developer settings → Personal access tokens.
174 </p>
80bed05Claude175 <form method="POST" action="/import/github/user" data-import-form>
bdbd0deClaude176 <div class="form-group">
177 <input
178 type="text"
179 name="github_username"
180 required
181 placeholder="GitHub username"
182 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
183 />
184 </div>
185 <div class="form-group">
186 <input
187 type="password"
188 name="github_token"
80bed05Claude189 required
bdbd0deClaude190 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
191 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%"
192 />
193 </div>
194 <button type="submit" class="btn btn-primary">
195 Import all repos (public + private)
196 </button>
197 </form>
198 </div>
199 </div>
80bed05Claude200 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude201 </Layout>
202 );
203});
204
205// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
206
a4f3e24Claude207importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude208 const user = c.get("user")!;
209 const body = await c.req.parseBody();
210 const githubUsername = String(body.github_username || "").trim();
211 const githubToken = String(body.github_token || "").trim() || null;
212
213 if (!githubUsername) {
214 return c.redirect("/import?error=GitHub+username+is+required");
215 }
216
217 try {
218 // Fetch repos from GitHub API
219 const headers: Record<string, string> = {
220 Accept: "application/vnd.github.v3+json",
221 "User-Agent": "gluecron/1.0",
222 };
223 if (githubToken) {
224 headers.Authorization = `Bearer ${githubToken}`;
225 }
226
227 const repos: GitHubRepo[] = [];
228 let page = 1;
229 while (true) {
230 const url = githubToken
231 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
232 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
233
234 const res = await fetch(url, { headers });
235 if (!res.ok) {
236 const errText = await res.text();
237 return c.redirect(
238 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
239 );
240 }
241 const batch: GitHubRepo[] = await res.json();
242 if (batch.length === 0) break;
243 repos.push(...batch);
244 page++;
245 if (page > 10) break; // safety limit: 1000 repos
246 }
247
248 if (repos.length === 0) {
249 return c.redirect("/import?error=No+repositories+found+for+this+user");
250 }
251
252 // Import each repo
253 let imported = 0;
254 let skipped = 0;
80bed05Claude255 let failed = 0;
bdbd0deClaude256
257 for (const ghRepo of repos) {
80bed05Claude258 const targetName = sanitizeRepoName(ghRepo.name);
259
260 // Check uniqueness in THIS user's namespace (owner+name is the
261 // real unique key — the previous check ignored ownerId and
262 // could skip repos other users happened to share a name with).
bdbd0deClaude263 const [existing] = await db
264 .select()
265 .from(repositories)
266 .where(
80bed05Claude267 and(
268 eq(repositories.ownerId, user.id),
269 eq(repositories.name, targetName)
270 )
bdbd0deClaude271 )
272 .limit(1);
273
80bed05Claude274 if (existing) {
bdbd0deClaude275 skipped++;
276 continue;
277 }
278
279 try {
280 await importSingleRepo(user, ghRepo, githubToken);
281 imported++;
282 } catch (err) {
80bed05Claude283 failed++;
bdbd0deClaude284 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
285 }
286 }
287
80bed05Claude288 const summary =
289 `${imported}+imported%2C+${skipped}+skipped` +
290 (failed > 0 ? `%2C+${failed}+failed` : "");
291 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude292 } catch (err) {
293 console.error("[import] error:", err);
294 return c.redirect(
295 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
296 );
297 }
298});
299
300// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
301
a4f3e24Claude302importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude303 const user = c.get("user")!;
304 const body = await c.req.parseBody();
305 const repoUrl = String(body.repo_url || "").trim();
306
307 if (!repoUrl) {
308 return c.redirect("/import?error=Repository+URL+is+required");
309 }
310
80bed05Claude311 const parsed = parseGithubUrl(repoUrl);
312 if (!parsed) {
313 return c.redirect(
314 "/import?error=" +
315 encodeURIComponent(
316 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
317 )
318 );
bdbd0deClaude319 }
320
80bed05Claude321 const { owner: ghOwner, repo: ghRepo } = parsed;
322
323 // Guard against double-import before we spin up a clone subprocess.
324 const targetName = sanitizeRepoName(ghRepo);
325 const [existing] = await db
326 .select()
327 .from(repositories)
328 .where(
329 and(
330 eq(repositories.ownerId, user.id),
331 eq(repositories.name, targetName)
332 )
333 )
334 .limit(1);
335 if (existing) {
336 return c.redirect(
337 `/import?error=${encodeURIComponent(
338 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
339 )}`
340 );
341 }
bdbd0deClaude342
343 try {
344 // Fetch repo info
345 const res = await fetch(
346 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
347 {
348 headers: {
349 Accept: "application/vnd.github.v3+json",
350 "User-Agent": "gluecron/1.0",
351 },
352 }
353 );
354
355 if (!res.ok) {
80bed05Claude356 return c.redirect(
357 `/import?error=${encodeURIComponent(
358 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
359 )}`
360 );
bdbd0deClaude361 }
362
363 const ghRepoData: GitHubRepo = await res.json();
364
365 await importSingleRepo(user, ghRepoData, null);
366
80bed05Claude367 return c.redirect(`/${user.username}/${sanitizeRepoName(ghRepoData.name)}`);
bdbd0deClaude368 } catch (err) {
369 console.error("[import] error:", err);
370 return c.redirect(
371 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
372 );
373 }
374});
375
376// ─── CORE IMPORT FUNCTION ────────────────────────────────────
377
378async function importSingleRepo(
379 user: { id: string; username: string },
380 ghRepo: GitHubRepo,
381 token: string | null
382): Promise<void> {
80bed05Claude383 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude384 const destPath = join(
385 config.gitReposPath,
386 user.username,
80bed05Claude387 `${safeName}.git`
bdbd0deClaude388 );
389
390 // Ensure parent directory exists
391 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
392
393 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude394 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude395
396 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
397
398 const proc = Bun.spawn(
399 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
400 {
401 stdout: "pipe",
402 stderr: "pipe",
403 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
404 }
405 );
406 const stderr = await new Response(proc.stderr).text();
407 const exitCode = await proc.exited;
408
409 if (exitCode !== 0) {
80bed05Claude410 // Never echo the token back in an error message.
411 const sanitized = token
412 ? stderr.replaceAll(token, "***")
413 : stderr;
414 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude415 }
416
417 // Insert into database
418 await db.insert(repositories).values({
80bed05Claude419 name: safeName,
bdbd0deClaude420 ownerId: user.id,
421 description: ghRepo.description,
422 isPrivate: ghRepo.private,
423 defaultBranch: ghRepo.default_branch || "main",
424 diskPath: destPath,
425 starCount: 0,
426 });
427
428 console.log(`[import] ${ghRepo.full_name} imported successfully`);
429}
430
431export default importRoutes;