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.tsxBlame435 lines · 2 contributors
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"
2c3ba6ecopilot-swe-agent[bot]139 aria-label="GitHub username or org"
bdbd0deClaude140 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
141 />
142 <button type="submit" class="btn btn-primary">
143 Import all repos
144 </button>
145 </div>
146 </form>
147 </div>
148
149 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
150 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
151 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
80bed05Claude152 Import a specific repository by URL (https, ssh, or owner/repo).
bdbd0deClaude153 </p>
80bed05Claude154 <form method="POST" action="/import/github/repo" data-import-form>
bdbd0deClaude155 <div style="display: flex; gap: 8px">
156 <input
157 type="text"
158 name="repo_url"
159 required
160 placeholder="https://github.com/owner/repo"
2c3ba6ecopilot-swe-agent[bot]161 aria-label="Repository URL"
bdbd0deClaude162 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
163 />
164 <button type="submit" class="btn btn-primary">
165 Import
166 </button>
167 </div>
168 </form>
169 </div>
170
171 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
172 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
173 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
174 Use a GitHub personal access token to import private repositories too.
175 Generate one at github.com → Settings → Developer settings → Personal access tokens.
176 </p>
80bed05Claude177 <form method="POST" action="/import/github/user" data-import-form>
bdbd0deClaude178 <div class="form-group">
179 <input
180 type="text"
181 name="github_username"
182 required
183 placeholder="GitHub username"
2c3ba6ecopilot-swe-agent[bot]184 aria-label="GitHub username"
bdbd0deClaude185 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
186 />
187 </div>
188 <div class="form-group">
189 <input
190 type="password"
191 name="github_token"
80bed05Claude192 required
bdbd0deClaude193 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
2c3ba6ecopilot-swe-agent[bot]194 aria-label="GitHub personal access token"
bdbd0deClaude195 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%"
196 />
197 </div>
198 <button type="submit" class="btn btn-primary">
199 Import all repos (public + private)
200 </button>
201 </form>
202 </div>
203 </div>
80bed05Claude204 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude205 </Layout>
206 );
207});
208
209// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
210
a4f3e24Claude211importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude212 const user = c.get("user")!;
213 const body = await c.req.parseBody();
214 const githubUsername = String(body.github_username || "").trim();
215 const githubToken = String(body.github_token || "").trim() || null;
216
217 if (!githubUsername) {
218 return c.redirect("/import?error=GitHub+username+is+required");
219 }
220
221 try {
222 // Fetch repos from GitHub API
223 const headers: Record<string, string> = {
224 Accept: "application/vnd.github.v3+json",
225 "User-Agent": "gluecron/1.0",
226 };
227 if (githubToken) {
228 headers.Authorization = `Bearer ${githubToken}`;
229 }
230
231 const repos: GitHubRepo[] = [];
232 let page = 1;
233 while (true) {
234 const url = githubToken
235 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
236 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
237
238 const res = await fetch(url, { headers });
239 if (!res.ok) {
240 const errText = await res.text();
241 return c.redirect(
242 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
243 );
244 }
245 const batch: GitHubRepo[] = await res.json();
246 if (batch.length === 0) break;
247 repos.push(...batch);
248 page++;
249 if (page > 10) break; // safety limit: 1000 repos
250 }
251
252 if (repos.length === 0) {
253 return c.redirect("/import?error=No+repositories+found+for+this+user");
254 }
255
256 // Import each repo
257 let imported = 0;
258 let skipped = 0;
80bed05Claude259 let failed = 0;
bdbd0deClaude260
261 for (const ghRepo of repos) {
80bed05Claude262 const targetName = sanitizeRepoName(ghRepo.name);
263
264 // Check uniqueness in THIS user's namespace (owner+name is the
265 // real unique key — the previous check ignored ownerId and
266 // could skip repos other users happened to share a name with).
bdbd0deClaude267 const [existing] = await db
268 .select()
269 .from(repositories)
270 .where(
80bed05Claude271 and(
272 eq(repositories.ownerId, user.id),
273 eq(repositories.name, targetName)
274 )
bdbd0deClaude275 )
276 .limit(1);
277
80bed05Claude278 if (existing) {
bdbd0deClaude279 skipped++;
280 continue;
281 }
282
283 try {
284 await importSingleRepo(user, ghRepo, githubToken);
285 imported++;
286 } catch (err) {
80bed05Claude287 failed++;
bdbd0deClaude288 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
289 }
290 }
291
80bed05Claude292 const summary =
293 `${imported}+imported%2C+${skipped}+skipped` +
294 (failed > 0 ? `%2C+${failed}+failed` : "");
295 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude296 } catch (err) {
297 console.error("[import] error:", err);
298 return c.redirect(
299 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
300 );
301 }
302});
303
304// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
305
a4f3e24Claude306importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude307 const user = c.get("user")!;
308 const body = await c.req.parseBody();
309 const repoUrl = String(body.repo_url || "").trim();
310
311 if (!repoUrl) {
312 return c.redirect("/import?error=Repository+URL+is+required");
313 }
314
80bed05Claude315 const parsed = parseGithubUrl(repoUrl);
316 if (!parsed) {
317 return c.redirect(
318 "/import?error=" +
319 encodeURIComponent(
320 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
321 )
322 );
bdbd0deClaude323 }
324
80bed05Claude325 const { owner: ghOwner, repo: ghRepo } = parsed;
326
327 // Guard against double-import before we spin up a clone subprocess.
328 const targetName = sanitizeRepoName(ghRepo);
329 const [existing] = await db
330 .select()
331 .from(repositories)
332 .where(
333 and(
334 eq(repositories.ownerId, user.id),
335 eq(repositories.name, targetName)
336 )
337 )
338 .limit(1);
339 if (existing) {
340 return c.redirect(
341 `/import?error=${encodeURIComponent(
342 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
343 )}`
344 );
345 }
bdbd0deClaude346
347 try {
348 // Fetch repo info
349 const res = await fetch(
350 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
351 {
352 headers: {
353 Accept: "application/vnd.github.v3+json",
354 "User-Agent": "gluecron/1.0",
355 },
356 }
357 );
358
359 if (!res.ok) {
80bed05Claude360 return c.redirect(
361 `/import?error=${encodeURIComponent(
362 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
363 )}`
364 );
bdbd0deClaude365 }
366
367 const ghRepoData: GitHubRepo = await res.json();
368
369 await importSingleRepo(user, ghRepoData, null);
370
80bed05Claude371 return c.redirect(`/${user.username}/${sanitizeRepoName(ghRepoData.name)}`);
bdbd0deClaude372 } catch (err) {
373 console.error("[import] error:", err);
374 return c.redirect(
375 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
376 );
377 }
378});
379
380// ─── CORE IMPORT FUNCTION ────────────────────────────────────
381
382async function importSingleRepo(
383 user: { id: string; username: string },
384 ghRepo: GitHubRepo,
385 token: string | null
386): Promise<void> {
80bed05Claude387 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude388 const destPath = join(
389 config.gitReposPath,
390 user.username,
80bed05Claude391 `${safeName}.git`
bdbd0deClaude392 );
393
394 // Ensure parent directory exists
395 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
396
397 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude398 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude399
400 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
401
402 const proc = Bun.spawn(
403 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
404 {
405 stdout: "pipe",
406 stderr: "pipe",
407 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
408 }
409 );
410 const stderr = await new Response(proc.stderr).text();
411 const exitCode = await proc.exited;
412
413 if (exitCode !== 0) {
80bed05Claude414 // Never echo the token back in an error message.
415 const sanitized = token
416 ? stderr.replaceAll(token, "***")
417 : stderr;
418 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude419 }
420
421 // Insert into database
422 await db.insert(repositories).values({
80bed05Claude423 name: safeName,
bdbd0deClaude424 ownerId: user.id,
425 description: ghRepo.description,
426 isPrivate: ghRepo.private,
427 defaultBranch: ghRepo.default_branch || "main",
428 diskPath: destPath,
429 starCount: 0,
430 });
431
432 console.log(`[import] ${ghRepo.full_name} imported successfully`);
433}
434
435export default importRoutes;