Commitbdbd0deunknown_key
feat: Automatic GitHub import — one-click migration
feat: Automatic GitHub import — one-click migration Three import modes: 1. Import by username: fetches ALL public repos from a GitHub user/org via GitHub API, clones them bare with --mirror (all branches, all tags, full history), inserts into gluecron DB 2. Import single repo: paste a GitHub URL, imports that repo 3. Import with token: use a GitHub PAT to import private repos too (token injected into clone URL, never stored) Paginates GitHub API (up to 1000 repos per user). Skips repos that already exist. Descriptions and visibility preserved from GitHub. Available at /import in the nav for logged-in users. https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
3 files changed+343−0bdbd0de93be906993e1cba37799ea4eac0434935
3 changed files+343−0
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -26,6 +26,7 @@ import healthRoutes from "./routes/health";
2626import insightRoutes from "./routes/insights";
2727import dashboardRoutes from "./routes/dashboard";
2828import legalRoutes from "./routes/legal";
29import importRoutes from "./routes/import";
2930import webRoutes from "./routes/web";
3031import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
3132import { csrfToken, csrfProtect } from "./middleware/csrf";
@@ -154,6 +155,9 @@ app.route("/", dashboardRoutes);
154155// Legal pages (terms, privacy, AUP)
155156app.route("/", legalRoutes);
156157
158// GitHub import / migration
159app.route("/", importRoutes);
160
157161// Explore page
158162app.route("/", exploreRoutes);
159163
Addedsrc/routes/import.tsx+336−0View fileUnifiedSplit
@@ -0,0 +1,336 @@
1/**
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";
10import { eq } from "drizzle-orm";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { config } from "../lib/config";
17import { mkdir } from "fs/promises";
18import { join } from "path";
19
20const importRoutes = new Hono<AuthEnv>();
21
22importRoutes.use("*", softAuth);
23
24interface GitHubRepo {
25 name: string;
26 full_name: string;
27 description: string | null;
28 private: boolean;
29 clone_url: string;
30 default_branch: string;
31 stargazers_count: number;
32 fork: boolean;
33 language: string | null;
34}
35
36// ─── IMPORT PAGE ─────────────────────────────────────────────
37
38importRoutes.get("/import", requireAuth, async (c) => {
39 const user = c.get("user")!;
40 const success = c.req.query("success");
41 const error = c.req.query("error");
42 const imported = c.req.query("imported");
43
44 return c.html(
45 <Layout title="Import from GitHub" user={user}>
46 <div style="max-width: 700px">
47 <h2 style="margin-bottom: 4px">Import from GitHub</h2>
48 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
49 Migrate your repositories from GitHub to gluecron automatically.
50 All branches, all history, all code — one click.
51 </p>
52 {success && (
53 <div class="auth-success">
54 {decodeURIComponent(success)}
55 {imported && (
56 <div style="margin-top: 8px">
57 Successfully imported {decodeURIComponent(imported)} repositories.
58 </div>
59 )}
60 </div>
61 )}
62 {error && (
63 <div class="auth-error">{decodeURIComponent(error)}</div>
64 )}
65
66 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
67 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
68 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
69 Import all public repositories from a GitHub user or organization.
70 </p>
71 <form method="POST" action="/import/github/user">
72 <div style="display: flex; gap: 8px">
73 <input
74 type="text"
75 name="github_username"
76 required
77 placeholder="GitHub username or org"
78 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
79 />
80 <button type="submit" class="btn btn-primary">
81 Import all repos
82 </button>
83 </div>
84 </form>
85 </div>
86
87 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
88 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
89 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
90 Import a specific repository by URL.
91 </p>
92 <form method="POST" action="/import/github/repo">
93 <div style="display: flex; gap: 8px">
94 <input
95 type="text"
96 name="repo_url"
97 required
98 placeholder="https://github.com/owner/repo"
99 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
100 />
101 <button type="submit" class="btn btn-primary">
102 Import
103 </button>
104 </div>
105 </form>
106 </div>
107
108 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
109 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
110 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
111 Use a GitHub personal access token to import private repositories too.
112 Generate one at github.com → Settings → Developer settings → Personal access tokens.
113 </p>
114 <form method="POST" action="/import/github/user">
115 <div class="form-group">
116 <input
117 type="text"
118 name="github_username"
119 required
120 placeholder="GitHub username"
121 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
122 />
123 </div>
124 <div class="form-group">
125 <input
126 type="password"
127 name="github_token"
128 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
129 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%"
130 />
131 </div>
132 <button type="submit" class="btn btn-primary">
133 Import all repos (public + private)
134 </button>
135 </form>
136 </div>
137 </div>
138 </Layout>
139 );
140});
141
142// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
143
144importRoutes.post("/import/github/user", requireAuth, async (c) => {
145 const user = c.get("user")!;
146 const body = await c.req.parseBody();
147 const githubUsername = String(body.github_username || "").trim();
148 const githubToken = String(body.github_token || "").trim() || null;
149
150 if (!githubUsername) {
151 return c.redirect("/import?error=GitHub+username+is+required");
152 }
153
154 try {
155 // Fetch repos from GitHub API
156 const headers: Record<string, string> = {
157 Accept: "application/vnd.github.v3+json",
158 "User-Agent": "gluecron/1.0",
159 };
160 if (githubToken) {
161 headers.Authorization = `Bearer ${githubToken}`;
162 }
163
164 const repos: GitHubRepo[] = [];
165 let page = 1;
166 while (true) {
167 const url = githubToken
168 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
169 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
170
171 const res = await fetch(url, { headers });
172 if (!res.ok) {
173 const errText = await res.text();
174 return c.redirect(
175 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
176 );
177 }
178 const batch: GitHubRepo[] = await res.json();
179 if (batch.length === 0) break;
180 repos.push(...batch);
181 page++;
182 if (page > 10) break; // safety limit: 1000 repos
183 }
184
185 if (repos.length === 0) {
186 return c.redirect("/import?error=No+repositories+found+for+this+user");
187 }
188
189 // Import each repo
190 let imported = 0;
191 let skipped = 0;
192
193 for (const ghRepo of repos) {
194 // Check if already exists
195 const [existing] = await db
196 .select()
197 .from(repositories)
198 .where(
199 eq(repositories.name, ghRepo.name)
200 )
201 .limit(1);
202
203 if (existing && existing.ownerId === user.id) {
204 skipped++;
205 continue;
206 }
207
208 try {
209 await importSingleRepo(user, ghRepo, githubToken);
210 imported++;
211 } catch (err) {
212 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
213 }
214 }
215
216 return c.redirect(
217 `/import?success=Import+complete&imported=${imported}+imported%2C+${skipped}+skipped+(already+exist)`
218 );
219 } catch (err) {
220 console.error("[import] error:", err);
221 return c.redirect(
222 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
223 );
224 }
225});
226
227// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
228
229importRoutes.post("/import/github/repo", requireAuth, async (c) => {
230 const user = c.get("user")!;
231 const body = await c.req.parseBody();
232 const repoUrl = String(body.repo_url || "").trim();
233
234 if (!repoUrl) {
235 return c.redirect("/import?error=Repository+URL+is+required");
236 }
237
238 // Parse GitHub URL
239 const match = repoUrl.match(
240 /github\.com\/([^/]+)\/([^/.]+)/
241 );
242 if (!match) {
243 return c.redirect("/import?error=Invalid+GitHub+URL");
244 }
245
246 const [, ghOwner, ghRepo] = match;
247
248 try {
249 // Fetch repo info
250 const res = await fetch(
251 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
252 {
253 headers: {
254 Accept: "application/vnd.github.v3+json",
255 "User-Agent": "gluecron/1.0",
256 },
257 }
258 );
259
260 if (!res.ok) {
261 return c.redirect("/import?error=Repository+not+found+on+GitHub");
262 }
263
264 const ghRepoData: GitHubRepo = await res.json();
265
266 await importSingleRepo(user, ghRepoData, null);
267
268 return c.redirect(
269 `/${user.username}/${ghRepoData.name}`
270 );
271 } catch (err) {
272 console.error("[import] error:", err);
273 return c.redirect(
274 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
275 );
276 }
277});
278
279// ─── CORE IMPORT FUNCTION ────────────────────────────────────
280
281async function importSingleRepo(
282 user: { id: string; username: string },
283 ghRepo: GitHubRepo,
284 token: string | null
285): Promise<void> {
286 const destPath = join(
287 config.gitReposPath,
288 user.username,
289 `${ghRepo.name}.git`
290 );
291
292 // Ensure parent directory exists
293 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
294
295 // Clone bare from GitHub (with token if provided for private repos)
296 let cloneUrl = ghRepo.clone_url;
297 if (token) {
298 // Inject token into URL for private repo access
299 cloneUrl = cloneUrl.replace(
300 "https://github.com/",
301 `https://${token}@github.com/`
302 );
303 }
304
305 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
306
307 const proc = Bun.spawn(
308 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
309 {
310 stdout: "pipe",
311 stderr: "pipe",
312 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
313 }
314 );
315 const stderr = await new Response(proc.stderr).text();
316 const exitCode = await proc.exited;
317
318 if (exitCode !== 0) {
319 throw new Error(`git clone failed: ${stderr}`);
320 }
321
322 // Insert into database
323 await db.insert(repositories).values({
324 name: ghRepo.name,
325 ownerId: user.id,
326 description: ghRepo.description,
327 isPrivate: ghRepo.private,
328 defaultBranch: ghRepo.default_branch || "main",
329 diskPath: destPath,
330 starCount: 0,
331 });
332
333 console.log(`[import] ${ghRepo.full_name} imported successfully`);
334}
335
336export default importRoutes;
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
@@ -63,6 +63,9 @@ export const Layout: FC<
6363 <a href="/dashboard" class="nav-link" style="font-weight: 600">
6464 Dashboard
6565 </a>
66 <a href="/import" class="nav-link">
67 Import
68 </a>
6669 <a href="/new" class="btn btn-sm btn-primary">
6770 + New
6871 </a>
6972