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.tsxBlame498 lines · 3 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"
4127ecfClaude88 style="display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px; text-decoration: none; color: inherit"
14c3cc8Claude89 >
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"
4127ecfClaude119 style="display: none; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px"
80bed05Claude120 >
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>
2316901Claude132 <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>
2316901Claude154 <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>
f390cfaCC LABS App168 <div style="margin-top: 8px">
169 <input
170 type="password"
171 name="github_token"
172 placeholder="Optional: GitHub PAT to also migrate Actions secret names"
173 aria-label="Optional GitHub personal access token for secrets migration"
174 autocomplete="off"
175 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px; font-family: var(--font-mono); width: 100%"
176 />
177 </div>
bdbd0deClaude178 </form>
179 </div>
180
181 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
182 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
183 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
184 Use a GitHub personal access token to import private repositories too.
185 Generate one at github.com → Settings → Developer settings → Personal access tokens.
186 </p>
2316901Claude187 <form method="post" action="/import/github/user" data-import-form>
bdbd0deClaude188 <div class="form-group">
189 <input
190 type="text"
191 name="github_username"
192 required
193 placeholder="GitHub username"
2c3ba6ecopilot-swe-agent[bot]194 aria-label="GitHub username"
bdbd0deClaude195 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
196 />
197 </div>
198 <div class="form-group">
199 <input
200 type="password"
201 name="github_token"
80bed05Claude202 required
bdbd0deClaude203 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
2c3ba6ecopilot-swe-agent[bot]204 aria-label="GitHub personal access token"
bdbd0deClaude205 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%"
206 />
207 </div>
208 <button type="submit" class="btn btn-primary">
209 Import all repos (public + private)
210 </button>
211 </form>
212 </div>
213 </div>
80bed05Claude214 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude215 </Layout>
216 );
217});
218
219// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
220
a4f3e24Claude221importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude222 const user = c.get("user")!;
223 const body = await c.req.parseBody();
224 const githubUsername = String(body.github_username || "").trim();
225 const githubToken = String(body.github_token || "").trim() || null;
226
227 if (!githubUsername) {
228 return c.redirect("/import?error=GitHub+username+is+required");
229 }
230
231 try {
232 // Fetch repos from GitHub API
233 const headers: Record<string, string> = {
234 Accept: "application/vnd.github.v3+json",
235 "User-Agent": "gluecron/1.0",
236 };
237 if (githubToken) {
238 headers.Authorization = `Bearer ${githubToken}`;
239 }
240
241 const repos: GitHubRepo[] = [];
242 let page = 1;
243 while (true) {
244 const url = githubToken
245 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
246 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
247
248 const res = await fetch(url, { headers });
249 if (!res.ok) {
250 const errText = await res.text();
251 return c.redirect(
252 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
253 );
254 }
255 const batch: GitHubRepo[] = await res.json();
256 if (batch.length === 0) break;
257 repos.push(...batch);
258 page++;
259 if (page > 10) break; // safety limit: 1000 repos
260 }
261
262 if (repos.length === 0) {
263 return c.redirect("/import?error=No+repositories+found+for+this+user");
264 }
265
266 // Import each repo
267 let imported = 0;
268 let skipped = 0;
80bed05Claude269 let failed = 0;
bdbd0deClaude270
271 for (const ghRepo of repos) {
80bed05Claude272 const targetName = sanitizeRepoName(ghRepo.name);
273
274 // Check uniqueness in THIS user's namespace (owner+name is the
275 // real unique key — the previous check ignored ownerId and
276 // could skip repos other users happened to share a name with).
bdbd0deClaude277 const [existing] = await db
278 .select()
279 .from(repositories)
280 .where(
80bed05Claude281 and(
282 eq(repositories.ownerId, user.id),
283 eq(repositories.name, targetName)
284 )
bdbd0deClaude285 )
286 .limit(1);
287
80bed05Claude288 if (existing) {
bdbd0deClaude289 skipped++;
290 continue;
291 }
292
293 try {
294 await importSingleRepo(user, ghRepo, githubToken);
295 imported++;
296 } catch (err) {
80bed05Claude297 failed++;
bdbd0deClaude298 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
299 }
300 }
301
80bed05Claude302 const summary =
303 `${imported}+imported%2C+${skipped}+skipped` +
304 (failed > 0 ? `%2C+${failed}+failed` : "");
305 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude306 } catch (err) {
307 console.error("[import] error:", err);
308 return c.redirect(
309 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
310 );
311 }
312});
313
314// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
315
a4f3e24Claude316importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude317 const user = c.get("user")!;
318 const body = await c.req.parseBody();
319 const repoUrl = String(body.repo_url || "").trim();
f390cfaCC LABS App320 // Optional PAT — when supplied, used after the import succeeds to also
321 // migrate GitHub Actions secret NAMES (values are never exposed by the
322 // API) into placeholder rows the user can paste values into.
323 const optionalToken = String(body.github_token || "").trim() || null;
bdbd0deClaude324
325 if (!repoUrl) {
326 return c.redirect("/import?error=Repository+URL+is+required");
327 }
328
c63b860Claude329 // P4 — same quota gate as /new + /api/v2/repos. Imports count toward
330 // the user's plan limit.
331 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
332 const gate = await checkRepoCreateAllowed(user.id);
333 if (!gate.ok) {
334 return c.redirect(`/import?error=${encodeURIComponent(gate.reason)}`);
335 }
336
80bed05Claude337 const parsed = parseGithubUrl(repoUrl);
338 if (!parsed) {
339 return c.redirect(
340 "/import?error=" +
341 encodeURIComponent(
342 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
343 )
344 );
bdbd0deClaude345 }
346
80bed05Claude347 const { owner: ghOwner, repo: ghRepo } = parsed;
348
349 // Guard against double-import before we spin up a clone subprocess.
350 const targetName = sanitizeRepoName(ghRepo);
351 const [existing] = await db
352 .select()
353 .from(repositories)
354 .where(
355 and(
356 eq(repositories.ownerId, user.id),
357 eq(repositories.name, targetName)
358 )
359 )
360 .limit(1);
361 if (existing) {
362 return c.redirect(
363 `/import?error=${encodeURIComponent(
364 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
365 )}`
366 );
367 }
bdbd0deClaude368
369 try {
370 // Fetch repo info
f390cfaCC LABS App371 const metaHeaders: Record<string, string> = {
372 Accept: "application/vnd.github.v3+json",
373 "User-Agent": "gluecron/1.0",
374 };
375 if (optionalToken) metaHeaders.Authorization = `Bearer ${optionalToken}`;
bdbd0deClaude376 const res = await fetch(
377 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
f390cfaCC LABS App378 { headers: metaHeaders }
bdbd0deClaude379 );
380
381 if (!res.ok) {
80bed05Claude382 return c.redirect(
383 `/import?error=${encodeURIComponent(
384 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
385 )}`
386 );
bdbd0deClaude387 }
388
389 const ghRepoData: GitHubRepo = await res.json();
390
f390cfaCC LABS App391 await importSingleRepo(user, ghRepoData, optionalToken);
392
393 // Block T1 — opportunistically migrate GitHub Actions secret NAMES
394 // (values are never exposed by GitHub's API). If a token was supplied
395 // on this request, list the secret names + pre-create empty
396 // placeholders, then redirect the user to the paste-each-value
397 // checklist. Fire-and-forget semantics: any failure (no token, network
398 // error, no secrets, DB blip) collapses to "skip the checklist step"
399 // and we redirect straight to the repo home.
400 const targetName = sanitizeRepoName(ghRepoData.name);
401 let secretsRedirect: string | null = null;
402 if (optionalToken) {
403 try {
404 const [newRepoRow] = await db
405 .select({ id: repositories.id })
406 .from(repositories)
407 .where(
408 and(
409 eq(repositories.ownerId, user.id),
410 eq(repositories.name, targetName)
411 )
412 )
413 .limit(1);
414 if (newRepoRow) {
415 const { importSecretsForRepo } = await import(
416 "../lib/github-secrets-import"
417 );
418 const result = await importSecretsForRepo({
419 githubOwner: ghOwner,
420 githubRepo: ghRepo,
421 githubToken: optionalToken,
422 gluecronRepositoryId: newRepoRow.id,
423 importedByUserId: user.id,
424 });
425 if (result.imported.length > 0) {
426 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
427 }
428 }
429 } catch (err) {
430 console.error("[import] secrets migration skipped:", err);
431 }
432 }
bdbd0deClaude433
f390cfaCC LABS App434 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
bdbd0deClaude435 } catch (err) {
436 console.error("[import] error:", err);
437 return c.redirect(
438 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
439 );
440 }
441});
442
443// ─── CORE IMPORT FUNCTION ────────────────────────────────────
444
445async function importSingleRepo(
446 user: { id: string; username: string },
447 ghRepo: GitHubRepo,
448 token: string | null
449): Promise<void> {
80bed05Claude450 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude451 const destPath = join(
452 config.gitReposPath,
453 user.username,
80bed05Claude454 `${safeName}.git`
bdbd0deClaude455 );
456
457 // Ensure parent directory exists
458 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
459
460 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude461 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude462
463 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
464
465 const proc = Bun.spawn(
466 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
467 {
468 stdout: "pipe",
469 stderr: "pipe",
470 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
471 }
472 );
473 const stderr = await new Response(proc.stderr).text();
474 const exitCode = await proc.exited;
475
476 if (exitCode !== 0) {
80bed05Claude477 // Never echo the token back in an error message.
478 const sanitized = token
479 ? stderr.replaceAll(token, "***")
480 : stderr;
481 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude482 }
483
484 // Insert into database
485 await db.insert(repositories).values({
80bed05Claude486 name: safeName,
bdbd0deClaude487 ownerId: user.id,
488 description: ghRepo.description,
489 isPrivate: ghRepo.private,
490 defaultBranch: ghRepo.default_branch || "main",
491 diskPath: destPath,
492 starCount: 0,
493 });
494
495 console.log(`[import] ${ghRepo.full_name} imported successfully`);
496}
497
498export default importRoutes;