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

migrations.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.

migrations.tsxBlame364 lines · 1 contributor
14c3cc8Claude1/**
2 * Migration history — tracks repos imported from GitHub (bulk org import + single
3 * repo import) and lets owners re-run the post-migration verifier on demand.
4 *
5 * The `repositories` table does NOT currently carry an `importedAt` /
6 * `importSource` / `mirrorUpstreamUrl` column (see `src/db/schema.ts`), so
7 * we fall back to a best-effort derivation: list every repo owned by the
8 * current user and surface `createdAt` as the "imported at" timestamp. When
9 * the schema eventually grows an `importedAt` column we can switch the
10 * filter to `isNotNull(repositories.importedAt)` without changing the UI.
11 *
12 * The verifier itself lives in `src/lib/import-verify.ts` and is being
13 * supplied by a parallel agent. We load it via dynamic import inside a
14 * try/catch so a missing module produces a helpful "verifier not available"
15 * note instead of a 500.
16 */
17
18import { Hono } from "hono";
19import { desc, eq } from "drizzle-orm";
20import { db } from "../db";
21import { repositories } from "../db/schema";
22import { Layout } from "../views/layout";
23import { requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const migrations = new Hono<AuthEnv>();
27
28migrations.use("/migrations", requireAuth);
29migrations.use("/migrations/*", requireAuth);
30
31// ─── Verifier loader ─────────────────────────────────────────
32//
33// The verifier is optional at app boot — a parallel agent owns the file.
34// We load it dynamically so this route works whether or not the module
35// is present on disk. The expected interface is:
36//
37// export async function verifyMigration(repoId: number): Promise<{
38// repoId: number;
39// clonable: boolean;
40// hasDefaultBranch: boolean;
41// commitCount: number;
42// issues: string[];
43// }>
44//
45type VerifyResult = {
46 repoId: number;
47 clonable: boolean;
48 hasDefaultBranch: boolean;
49 commitCount: number;
50 issues: string[];
51};
52
53async function loadVerifier(): Promise<
54 ((repoId: number) => Promise<VerifyResult>) | null
55> {
56 try {
57 // eslint-disable-next-line @typescript-eslint/no-var-requires
58 const mod: any = await import("../lib/import-verify");
59 if (mod && typeof mod.verifyMigration === "function") {
60 return mod.verifyMigration as (id: number) => Promise<VerifyResult>;
61 }
62 return null;
63 } catch {
64 return null;
65 }
66}
67
68// ─── GET /migrations ─────────────────────────────────────────
69migrations.get("/migrations", async (c) => {
70 const user = c.get("user")!;
71
72 // Best-effort listing: all repos owned by the user, newest first.
73 // When an `importedAt` column is added later, narrow this WHERE to
74 // `and(eq(ownerId, user.id), isNotNull(importedAt))`.
75 let rows: Array<{
76 id: string;
77 name: string;
78 createdAt: Date;
79 description: string | null;
80 }> = [];
81 try {
82 const result = await db
83 .select({
84 id: repositories.id,
85 name: repositories.name,
86 createdAt: repositories.createdAt,
87 description: repositories.description,
88 })
89 .from(repositories)
90 .where(eq(repositories.ownerId, user.id))
91 .orderBy(desc(repositories.createdAt));
92 rows = result as any;
93 } catch {
94 rows = [];
95 }
96
97 return c.html(
98 <Layout title="Migration history" user={user}>
99 <div style="max-width: 900px; margin: 0 auto; padding: 24px">
100 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
101 <h1 style="margin:0">Migration history</h1>
102 <div style="display:flex;gap:8px">
103 <a class="btn" href="/import">
104 Import
105 </a>
106 <a class="btn" href="/import/bulk">
107 Bulk import
108 </a>
109 </div>
110 </div>
111
112 <p style="color: var(--text-muted); margin-bottom: 16px">
113 Repositories you've migrated to gluecron. Use <strong>Verify</strong>
114 {" "}to re-run the post-migration check (clonability, default branch,
115 commit count).
116 </p>
117
118 {rows.length === 0 ? (
119 <div class="panel-empty" style="padding: 32px; text-align: center">
120 You haven't migrated any repos yet. Try{" "}
121 <a href="/import">/import</a> or{" "}
122 <a href="/import/bulk">/import/bulk</a>.
123 </div>
124 ) : (
125 <div class="panel">
126 <div
127 class="panel-item"
128 style="font-weight:600;background:var(--bg-subtle)"
129 >
130 <div style="flex:2;min-width:0">Repo</div>
131 <div style="flex:2;min-width:0">Source</div>
132 <div style="flex:1;min-width:0">Imported at</div>
133 <div style="width:120px;text-align:right">Action</div>
134 </div>
135 {rows.map((r) => (
136 <div class="panel-item">
137 <div style="flex:2;min-width:0;overflow:hidden;text-overflow:ellipsis">
138 <a href={`/${user.username}/${r.name}`}>
139 <strong>{r.name}</strong>
140 </a>
141 {r.description && (
142 <div
143 style="font-size:12px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
144 >
145 {r.description}
146 </div>
147 )}
148 </div>
149 <div
150 style="flex:2;min-width:0;font-size:12px;color:var(--text-muted);font-family:var(--font-mono);overflow:hidden;text-overflow:ellipsis"
151 >
152 {/* Source URL column — we don't persist the upstream URL
153 yet, so show a neutral placeholder. */}
154
155 </div>
156 <div style="flex:1;min-width:0;font-size:12px;color:var(--text-muted)">
157 {r.createdAt
158 ? new Date(r.createdAt).toLocaleString()
159 : "—"}
160 </div>
161 <div style="width:120px;text-align:right">
162 <a
163 class="btn btn-primary"
164 href={`/migrations/verify/${r.id}`}
165 >
166 Verify
167 </a>
168 </div>
169 </div>
170 ))}
171 </div>
172 )}
173 </div>
174 </Layout>
175 );
176});
177
178// ─── GET /migrations/verify/:repoId ──────────────────────────
179migrations.get("/migrations/verify/:repoId", async (c) => {
180 const user = c.get("user")!;
181 const repoId = c.req.param("repoId");
182
183 // Ownership check: the verifier must only run for repos this user owns.
184 let repo:
185 | {
186 id: string;
187 name: string;
188 ownerId: string;
189 defaultBranch: string;
190 }
191 | null = null;
192 try {
193 const [row] = await db
194 .select({
195 id: repositories.id,
196 name: repositories.name,
197 ownerId: repositories.ownerId,
198 defaultBranch: repositories.defaultBranch,
199 })
200 .from(repositories)
201 .where(eq(repositories.id, repoId))
202 .limit(1);
203 repo = (row as any) || null;
204 } catch {
205 repo = null;
206 }
207
208 if (!repo) {
209 return c.html(
210 <Layout title="Verify migration" user={user}>
211 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
212 <h1>Repository not found</h1>
213 <p style="color:var(--text-muted)">
214 <a href="/migrations">Back to migration history</a>
215 </p>
216 </div>
217 </Layout>,
218 404
219 );
220 }
221
222 if (repo.ownerId !== user.id) {
223 return c.html(
224 <Layout title="Verify migration" user={user}>
225 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
226 <h1>Forbidden</h1>
227 <p style="color:var(--text-muted)">
228 You can only verify repositories you own.
229 </p>
230 <p>
231 <a href="/migrations">Back to migration history</a>
232 </p>
233 </div>
234 </Layout>,
235 403
236 );
237 }
238
239 const verify = await loadVerifier();
240 let result: VerifyResult | null = null;
241 let verifierError: string | null = null;
242 if (!verify) {
243 verifierError =
244 "Verifier not available. The import-verify module is not installed yet.";
245 } else {
246 try {
247 // Schema stores repo id as uuid string; the verifier interface
248 // types it as `number` but many callers pass through strings. Cast
249 // defensively so we don't crash on either shape.
250 result = await verify(repo.id as unknown as number);
251 } catch (err: any) {
252 verifierError =
253 "Verifier failed: " + (err && err.message ? err.message : String(err));
254 }
255 }
256
257 const indicator = (ok: boolean) => (
258 <span
259 style={`display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;background:${
260 ok ? "var(--green, #2ea043)" : "var(--red, #f85149)"
261 }`}
262 />
263 );
264
265 return c.html(
266 <Layout title={`Verify ${repo.name}`} user={user}>
267 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
268 <div style="margin-bottom: 12px">
269 <a href="/migrations" style="font-size:12px">
270 ← Migration history
271 </a>
272 </div>
273 <h1 style="margin:0 0 4px 0">Verify migration</h1>
274 <div style="color:var(--text-muted);font-family:var(--font-mono);margin-bottom:16px">
275 {user.username}/{repo.name}
276 </div>
277
278 {verifierError && (
279 <div
280 class="panel-empty"
281 style="padding:16px;border-left:3px solid var(--red, #f85149)"
282 >
283 {verifierError}
284 </div>
285 )}
286
287 {result && (
288 <div class="panel">
289 <div class="panel-item">
290 <div style="flex:1">
291 {indicator(result.clonable)}
292 <strong>Clonable</strong>
293 </div>
294 <div style="color:var(--text-muted);font-size:12px">
295 {result.clonable ? "Repository responds to git clone" : "Clone failed"}
296 </div>
297 </div>
298 <div class="panel-item">
299 <div style="flex:1">
300 {indicator(result.hasDefaultBranch)}
301 <strong>Default branch</strong>
302 </div>
303 <div style="color:var(--text-muted);font-size:12px">
304 {result.hasDefaultBranch
305 ? `Found ${repo.defaultBranch}`
306 : `Missing ${repo.defaultBranch}`}
307 </div>
308 </div>
309 <div class="panel-item">
310 <div style="flex:1">
311 {indicator(result.commitCount > 0)}
312 <strong>Commits</strong>
313 </div>
314 <div style="color:var(--text-muted);font-size:12px">
315 {result.commitCount} commit
316 {result.commitCount === 1 ? "" : "s"}
317 </div>
318 </div>
319 {result.issues && result.issues.length > 0 && (
320 <div
321 class="panel-item"
322 style="flex-direction:column;align-items:stretch;gap:4px"
323 >
324 <div>
325 {indicator(false)}
326 <strong>Issues</strong>
327 </div>
328 <ul style="margin:0;padding-left:20px;color:var(--text-muted);font-size:13px">
329 {result.issues.map((i) => (
330 <li>{i}</li>
331 ))}
332 </ul>
333 </div>
334 )}
335 {(!result.issues || result.issues.length === 0) &&
336 result.clonable &&
337 result.hasDefaultBranch &&
338 result.commitCount > 0 && (
339 <div class="panel-item">
340 <div style="flex:1;color:var(--green, #2ea043)">
341 All checks passed.
342 </div>
343 </div>
344 )}
345 </div>
346 )}
347
348 <div style="margin-top:16px;display:flex;gap:8px">
349 <a
350 class="btn btn-primary"
351 href={`/migrations/verify/${repo.id}`}
352 >
353 Re-run verification
354 </a>
355 <a class="btn" href="/migrations">
356 Back
357 </a>
358 </div>
359 </div>
360 </Layout>
361 );
362});
363
364export default migrations;