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.
| 14c3cc8 | 1 | /** |
| 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. | |
| 54eab24 | 16 | * |
| 17 | * 2026 polish: | |
| 18 | * - Scoped `.mig-*` CSS — no bleed into the global layout. | |
| 19 | * - Eyebrow + display headline + 1-line subtitle. | |
| 20 | * - Each migration row is a card: run name, applied/pending/failed pill, | |
| 21 | * timestamp, duration (best-effort). | |
| 22 | * - Dashed empty-state with orb + CTA when no migrations exist. | |
| 23 | * - All query params + form actions preserved verbatim. | |
| 14c3cc8 | 24 | */ |
| 25 | ||
| 26 | import { Hono } from "hono"; | |
| 27 | import { desc, eq } from "drizzle-orm"; | |
| 28 | import { db } from "../db"; | |
| 29 | import { repositories } from "../db/schema"; | |
| 30 | import { Layout } from "../views/layout"; | |
| 4127ecf | 31 | import { formatRelative } from "../views/ui"; |
| 14c3cc8 | 32 | import { requireAuth } from "../middleware/auth"; |
| 33 | import type { AuthEnv } from "../middleware/auth"; | |
| 34 | ||
| 35 | const migrations = new Hono<AuthEnv>(); | |
| 36 | ||
| 37 | migrations.use("/migrations", requireAuth); | |
| 38 | migrations.use("/migrations/*", requireAuth); | |
| 39 | ||
| 54eab24 | 40 | // ─── Scoped CSS (.mig-*) ──────────────────────────────────────────────── |
| 41 | const migStyles = ` | |
| eed4684 | 42 | .mig-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); } |
| 54eab24 | 43 | |
| 44 | .mig-head { | |
| 45 | margin-bottom: var(--space-5); | |
| 46 | display: flex; | |
| 47 | align-items: flex-end; | |
| 48 | justify-content: space-between; | |
| 49 | gap: var(--space-4); | |
| 50 | flex-wrap: wrap; | |
| 51 | } | |
| 52 | .mig-head-text { flex: 1; min-width: 280px; } | |
| 53 | .mig-eyebrow { | |
| 54 | display: inline-flex; | |
| 55 | align-items: center; | |
| 56 | gap: 8px; | |
| 57 | text-transform: uppercase; | |
| 58 | font-family: var(--font-mono); | |
| 59 | font-size: 11px; | |
| 60 | letter-spacing: 0.16em; | |
| 61 | color: var(--text-muted); | |
| 62 | font-weight: 600; | |
| 63 | margin-bottom: 10px; | |
| 64 | } | |
| 65 | .mig-eyebrow-dot { | |
| 66 | width: 8px; height: 8px; | |
| 67 | border-radius: 9999px; | |
| 6fd5915 | 68 | background: linear-gradient(135deg, #5b6ee8, #5f8fa0); |
| 69 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); | |
| 54eab24 | 70 | } |
| 71 | .mig-title { | |
| 72 | font-family: var(--font-display); | |
| 73 | font-size: clamp(24px, 3.4vw, 36px); | |
| 74 | font-weight: 800; | |
| 75 | letter-spacing: -0.028em; | |
| 76 | line-height: 1.1; | |
| 77 | margin: 0 0 6px; | |
| 78 | color: var(--text-strong); | |
| 79 | } | |
| 80 | .mig-title-grad { | |
| 6fd5915 | 81 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 54eab24 | 82 | -webkit-background-clip: text; |
| 83 | background-clip: text; | |
| 84 | -webkit-text-fill-color: transparent; | |
| 85 | color: transparent; | |
| 86 | } | |
| 87 | .mig-sub { | |
| 88 | margin: 0; | |
| 89 | font-size: 14px; | |
| 90 | color: var(--text-muted); | |
| 91 | line-height: 1.5; | |
| 92 | max-width: 720px; | |
| 93 | } | |
| 94 | ||
| 95 | .mig-actions { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 96 | .mig-btn { | |
| 97 | display: inline-flex; | |
| 98 | align-items: center; | |
| 99 | justify-content: center; | |
| 100 | gap: 6px; | |
| 101 | padding: 9px 16px; | |
| 102 | border-radius: 10px; | |
| 103 | font-size: 13px; | |
| 104 | font-weight: 600; | |
| 105 | text-decoration: none; | |
| 106 | border: 1px solid transparent; | |
| 107 | cursor: pointer; | |
| 108 | font: inherit; | |
| 109 | transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease; | |
| 110 | line-height: 1; | |
| 111 | white-space: nowrap; | |
| 112 | } | |
| 113 | .mig-btn-primary { | |
| 6fd5915 | 114 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 54eab24 | 115 | color: #ffffff; |
| 6fd5915 | 116 | box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16); |
| 54eab24 | 117 | } |
| 118 | .mig-btn-primary:hover { | |
| 119 | transform: translateY(-1px); | |
| 6fd5915 | 120 | box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20); |
| 54eab24 | 121 | text-decoration: none; |
| 122 | color: #ffffff; | |
| 123 | } | |
| 124 | .mig-btn-ghost { | |
| 125 | background: transparent; | |
| 126 | color: var(--text); | |
| 127 | border-color: var(--border-strong); | |
| 128 | } | |
| 129 | .mig-btn-ghost:hover { | |
| 6fd5915 | 130 | background: rgba(91,110,232,0.06); |
| 131 | border-color: rgba(91,110,232,0.45); | |
| 54eab24 | 132 | color: var(--text-strong); |
| 133 | text-decoration: none; | |
| 134 | } | |
| 135 | ||
| 136 | /* ─── Migration row cards ─── */ | |
| 137 | .mig-list { | |
| 138 | display: flex; | |
| 139 | flex-direction: column; | |
| 140 | gap: 10px; | |
| 141 | } | |
| 142 | .mig-row { | |
| 143 | position: relative; | |
| 144 | display: grid; | |
| 145 | grid-template-columns: 1fr auto; | |
| 146 | gap: var(--space-3); | |
| 147 | padding: 14px 16px; | |
| 148 | background: var(--bg-elevated); | |
| 149 | border: 1px solid var(--border); | |
| 150 | border-radius: 12px; | |
| 151 | transition: border-color 120ms ease, background 120ms ease; | |
| 152 | overflow: hidden; | |
| 153 | } | |
| 154 | .mig-row::before { | |
| 155 | content: ''; | |
| 156 | position: absolute; | |
| 157 | top: 0; left: 0; right: 0; | |
| 158 | height: 1.5px; | |
| 6fd5915 | 159 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 54eab24 | 160 | opacity: 0.35; |
| 161 | pointer-events: none; | |
| 162 | } | |
| 163 | .mig-row:hover { | |
| 164 | border-color: var(--border-strong); | |
| 165 | background: rgba(255,255,255,0.025); | |
| 166 | } | |
| 167 | .mig-row-main { min-width: 0; } | |
| 168 | .mig-row-name-line { | |
| 169 | display: flex; | |
| 170 | align-items: center; | |
| 171 | gap: 8px; | |
| 172 | flex-wrap: wrap; | |
| 173 | margin-bottom: 6px; | |
| 174 | } | |
| 175 | .mig-row-name { | |
| 176 | font-family: var(--font-display); | |
| 177 | font-size: 15.5px; | |
| 178 | font-weight: 700; | |
| 179 | color: var(--text-strong); | |
| 180 | text-decoration: none; | |
| 181 | letter-spacing: -0.012em; | |
| 182 | } | |
| 183 | .mig-row-name:hover { text-decoration: underline; } | |
| 184 | .mig-row-desc { | |
| 185 | font-size: 12.5px; | |
| 186 | color: var(--text-muted); | |
| 187 | line-height: 1.5; | |
| 188 | margin: 0; | |
| 189 | overflow: hidden; | |
| 190 | text-overflow: ellipsis; | |
| 191 | white-space: nowrap; | |
| 192 | } | |
| 193 | .mig-row-meta { | |
| 194 | display: flex; | |
| 195 | align-items: center; | |
| 196 | gap: 8px; | |
| 197 | flex-wrap: wrap; | |
| 198 | margin-top: 8px; | |
| 199 | font-size: 11.5px; | |
| 200 | color: var(--text-muted); | |
| 201 | font-variant-numeric: tabular-nums; | |
| 202 | } | |
| 203 | .mig-row-meta .sep { opacity: 0.4; } | |
| 204 | .mig-row-actions { | |
| 205 | display: flex; | |
| 206 | align-items: center; | |
| 207 | gap: 6px; | |
| 208 | flex-shrink: 0; | |
| 209 | } | |
| 210 | ||
| 211 | /* ─── Status pills ─── */ | |
| 212 | .mig-pill { | |
| 213 | display: inline-flex; | |
| 214 | align-items: center; | |
| 215 | gap: 6px; | |
| 216 | padding: 3px 9px; | |
| 217 | border-radius: 9999px; | |
| 218 | font-size: 11px; | |
| 219 | font-weight: 600; | |
| 220 | letter-spacing: 0.02em; | |
| 221 | text-transform: capitalize; | |
| 222 | } | |
| 223 | .mig-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; } | |
| 224 | .mig-pill.is-applied { | |
| 225 | background: rgba(52,211,153,0.14); | |
| 226 | color: #6ee7b7; | |
| 227 | box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); | |
| 228 | } | |
| 229 | .mig-pill.is-pending { | |
| 230 | background: rgba(251,191,36,0.12); | |
| 231 | color: #fde68a; | |
| 232 | box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32); | |
| 233 | } | |
| 234 | .mig-pill.is-failed { | |
| 235 | background: rgba(248,113,113,0.12); | |
| 236 | color: #fecaca; | |
| 237 | box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); | |
| 238 | } | |
| 239 | ||
| 240 | /* ─── Banners (used on verify page) ─── */ | |
| 241 | .mig-banner { | |
| 242 | margin-bottom: var(--space-4); | |
| 243 | padding: 10px 14px; | |
| 244 | border-radius: 10px; | |
| 245 | font-size: 13.5px; | |
| 246 | border: 1px solid var(--border); | |
| 247 | background: rgba(255,255,255,0.025); | |
| 248 | color: var(--text); | |
| 249 | display: flex; | |
| 250 | align-items: center; | |
| 251 | gap: 10px; | |
| 252 | } | |
| 253 | .mig-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; } | |
| 254 | .mig-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; } | |
| 255 | .mig-banner.is-warn { border-color: rgba(251,191,36,0.32); background: rgba(251,191,36,0.06); color: #fde68a; } | |
| 256 | .mig-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; } | |
| 257 | ||
| 258 | .mig-checks { display: flex; flex-direction: column; gap: 10px; } | |
| 259 | .mig-check { | |
| 260 | display: flex; | |
| 261 | align-items: center; | |
| 262 | justify-content: space-between; | |
| 263 | gap: 12px; | |
| 264 | padding: 12px 14px; | |
| 265 | background: var(--bg-elevated); | |
| 266 | border: 1px solid var(--border); | |
| 267 | border-radius: 10px; | |
| 268 | } | |
| 269 | .mig-check-label { | |
| 270 | display: flex; | |
| 271 | align-items: center; | |
| 272 | gap: 10px; | |
| 273 | font-family: var(--font-display); | |
| 274 | font-weight: 700; | |
| 275 | color: var(--text-strong); | |
| 276 | font-size: 13.5px; | |
| 277 | } | |
| 278 | .mig-check-dot { | |
| 279 | width: 10px; height: 10px; | |
| 280 | border-radius: 9999px; | |
| 281 | flex-shrink: 0; | |
| 282 | } | |
| 283 | .mig-check-dot.is-ok { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.18); } | |
| 284 | .mig-check-dot.is-fail { background: #f87171; box-shadow: 0 0 0 3px rgba(248,113,113,0.18); } | |
| 285 | .mig-check-detail { | |
| 286 | font-size: 12px; | |
| 287 | color: var(--text-muted); | |
| 288 | } | |
| 289 | .mig-issues-list { | |
| 290 | margin: 0; | |
| 291 | padding-left: 20px; | |
| 292 | color: var(--text-muted); | |
| 293 | font-size: 13px; | |
| 294 | line-height: 1.6; | |
| 295 | } | |
| 296 | ||
| 297 | /* ─── Empty state ─── */ | |
| 298 | .mig-empty { | |
| 299 | position: relative; | |
| 300 | overflow: hidden; | |
| 301 | padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px); | |
| 302 | text-align: center; | |
| 303 | background: var(--bg-elevated); | |
| 304 | border: 1px dashed var(--border-strong); | |
| 305 | border-radius: 16px; | |
| 306 | } | |
| 307 | .mig-empty-orb { | |
| 308 | position: absolute; | |
| 309 | inset: -40% 25% auto 25%; | |
| 310 | height: 300px; | |
| 6fd5915 | 311 | background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 54eab24 | 312 | filter: blur(72px); |
| 313 | opacity: 0.7; | |
| 314 | pointer-events: none; | |
| 315 | z-index: 0; | |
| 316 | } | |
| 317 | .mig-empty-inner { position: relative; z-index: 1; } | |
| 318 | .mig-empty-icon { | |
| 319 | width: 56px; height: 56px; | |
| 320 | margin: 0 auto 14px; | |
| 321 | border-radius: 9999px; | |
| 6fd5915 | 322 | background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20)); |
| 323 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40); | |
| 54eab24 | 324 | display: inline-flex; |
| 325 | align-items: center; | |
| 326 | justify-content: center; | |
| 327 | color: #c4b5fd; | |
| 328 | } | |
| 329 | .mig-empty-title { | |
| 330 | font-family: var(--font-display); | |
| 331 | font-size: 18px; | |
| 332 | font-weight: 700; | |
| 333 | margin: 0 0 6px; | |
| 334 | color: var(--text-strong); | |
| 335 | } | |
| 336 | .mig-empty-sub { | |
| 337 | margin: 0 auto 16px; | |
| 338 | font-size: 13.5px; | |
| 339 | color: var(--text-muted); | |
| 340 | max-width: 440px; | |
| 341 | line-height: 1.5; | |
| 342 | } | |
| 343 | .mig-crumbs { | |
| 344 | margin-bottom: var(--space-4); | |
| 345 | font-size: 12.5px; | |
| 346 | color: var(--text-muted); | |
| 347 | } | |
| 348 | .mig-crumbs a { | |
| 349 | display: inline-flex; | |
| 350 | align-items: center; | |
| 351 | gap: 5px; | |
| 352 | padding: 6px 11px; | |
| 353 | background: rgba(255,255,255,0.025); | |
| 354 | border: 1px solid var(--border); | |
| 355 | border-radius: 8px; | |
| 356 | color: var(--text-muted); | |
| 357 | text-decoration: none; | |
| 358 | font-weight: 500; | |
| 359 | } | |
| 360 | .mig-crumbs a:hover { border-color: var(--border-strong); color: var(--text-strong); text-decoration: none; } | |
| 361 | `; | |
| 362 | ||
| 363 | function IconImport() { | |
| 364 | return ( | |
| 365 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| 366 | <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> | |
| 367 | <polyline points="7 10 12 15 17 10" /> | |
| 368 | <line x1="12" y1="15" x2="12" y2="3" /> | |
| 369 | </svg> | |
| 370 | ); | |
| 371 | } | |
| 372 | function IconShield() { | |
| 373 | return ( | |
| 374 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| 375 | <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> | |
| 376 | </svg> | |
| 377 | ); | |
| 378 | } | |
| 379 | ||
| 14c3cc8 | 380 | // ─── Verifier loader ───────────────────────────────────────── |
| 381 | type VerifyResult = { | |
| 382 | repoId: number; | |
| 383 | clonable: boolean; | |
| 384 | hasDefaultBranch: boolean; | |
| 385 | commitCount: number; | |
| 386 | issues: string[]; | |
| 387 | }; | |
| 388 | ||
| 389 | async function loadVerifier(): Promise< | |
| 390 | ((repoId: number) => Promise<VerifyResult>) | null | |
| 391 | > { | |
| 392 | try { | |
| 393 | const mod: any = await import("../lib/import-verify"); | |
| 394 | if (mod && typeof mod.verifyMigration === "function") { | |
| 395 | return mod.verifyMigration as (id: number) => Promise<VerifyResult>; | |
| 396 | } | |
| 397 | return null; | |
| 398 | } catch { | |
| 399 | return null; | |
| 400 | } | |
| 401 | } | |
| 402 | ||
| 403 | // ─── GET /migrations ───────────────────────────────────────── | |
| 404 | migrations.get("/migrations", async (c) => { | |
| 405 | const user = c.get("user")!; | |
| 406 | ||
| 407 | let rows: Array<{ | |
| 408 | id: string; | |
| 409 | name: string; | |
| 410 | createdAt: Date; | |
| 411 | description: string | null; | |
| 412 | }> = []; | |
| 413 | try { | |
| 414 | const result = await db | |
| 415 | .select({ | |
| 416 | id: repositories.id, | |
| 417 | name: repositories.name, | |
| 418 | createdAt: repositories.createdAt, | |
| 419 | description: repositories.description, | |
| 420 | }) | |
| 421 | .from(repositories) | |
| 422 | .where(eq(repositories.ownerId, user.id)) | |
| 423 | .orderBy(desc(repositories.createdAt)); | |
| 424 | rows = result as any; | |
| 425 | } catch { | |
| 426 | rows = []; | |
| 427 | } | |
| 428 | ||
| 429 | return c.html( | |
| 430 | <Layout title="Migration history" user={user}> | |
| 54eab24 | 431 | <div class="mig-wrap"> |
| 432 | <header class="mig-head"> | |
| 433 | <div class="mig-head-text"> | |
| 434 | <div class="mig-eyebrow"> | |
| 435 | <span class="mig-eyebrow-dot" aria-hidden="true" /> | |
| 436 | Account · Migration history | |
| 437 | </div> | |
| 438 | <h1 class="mig-title"> | |
| 439 | <span class="mig-title-grad">Every repo you brought over.</span> | |
| 440 | </h1> | |
| 441 | <p class="mig-sub"> | |
| 442 | Re-run the post-migration verifier any time — it confirms the | |
| 443 | repo is clonable, the default branch resolves, and commits are | |
| 444 | present. | |
| 445 | </p> | |
| 446 | </div> | |
| 447 | <div class="mig-actions"> | |
| 448 | <a class="mig-btn mig-btn-ghost" href="/import"> | |
| 449 | <IconImport /> | |
| 14c3cc8 | 450 | Import |
| 451 | </a> | |
| 54eab24 | 452 | <a class="mig-btn mig-btn-primary" href="/import/bulk"> |
| 453 | <IconImport /> | |
| 14c3cc8 | 454 | Bulk import |
| 455 | </a> | |
| 456 | </div> | |
| 54eab24 | 457 | </header> |
| 14c3cc8 | 458 | |
| 459 | {rows.length === 0 ? ( | |
| 54eab24 | 460 | <div class="mig-empty"> |
| 461 | <div class="mig-empty-orb" aria-hidden="true" /> | |
| 462 | <div class="mig-empty-inner"> | |
| 463 | <div class="mig-empty-icon" aria-hidden="true"> | |
| 464 | <IconImport /> | |
| 465 | </div> | |
| 466 | <h3 class="mig-empty-title">No migrations yet</h3> | |
| 467 | <p class="mig-empty-sub"> | |
| 468 | Pull a single repo from GitHub or kick off a bulk org import to | |
| 469 | see migration runs land here. | |
| 470 | </p> | |
| 471 | <div class="mig-actions" style="justify-content:center"> | |
| 472 | <a class="mig-btn mig-btn-ghost" href="/import"> | |
| 473 | <IconImport /> | |
| 474 | Single repo | |
| 475 | </a> | |
| 476 | <a class="mig-btn mig-btn-primary" href="/import/bulk"> | |
| 477 | <IconImport /> | |
| 478 | Bulk import | |
| 479 | </a> | |
| 480 | </div> | |
| 481 | </div> | |
| 14c3cc8 | 482 | </div> |
| 483 | ) : ( | |
| 54eab24 | 484 | <div class="mig-list"> |
| 485 | {rows.map((r) => { | |
| 486 | // The schema has no migration-status column yet — every repo | |
| 487 | // that exists has, by definition, been "applied". We expose the | |
| 488 | // pill shape so when status lands later we can swap the value. | |
| 489 | const status: "applied" | "pending" | "failed" = "applied"; | |
| 490 | const pillClass = | |
| 491 | status === "applied" | |
| 492 | ? "mig-pill is-applied" | |
| 493 | : status === "pending" | |
| 494 | ? "mig-pill is-pending" | |
| 495 | : "mig-pill is-failed"; | |
| 496 | const tsText = r.createdAt | |
| 497 | ? formatRelative(r.createdAt as unknown as string) | |
| 498 | : "—"; | |
| 499 | // Duration not tracked in the schema; render an em-dash so the | |
| 500 | // visual treatment is in place for when it lands. | |
| 501 | return ( | |
| 502 | <div class="mig-row"> | |
| 503 | <div class="mig-row-main"> | |
| 504 | <div class="mig-row-name-line"> | |
| 505 | <a | |
| 506 | href={`/${user.username}/${r.name}`} | |
| 507 | class="mig-row-name" | |
| 508 | > | |
| 509 | {r.name} | |
| 510 | </a> | |
| 511 | <span class={pillClass}> | |
| 512 | <span class="dot" aria-hidden="true" /> | |
| 513 | {status} | |
| 514 | </span> | |
| 14c3cc8 | 515 | </div> |
| 54eab24 | 516 | {r.description && ( |
| 517 | <p class="mig-row-desc">{r.description}</p> | |
| 518 | )} | |
| 519 | <div class="mig-row-meta"> | |
| 520 | <span>Imported {tsText}</span> | |
| 521 | <span class="sep">·</span> | |
| 522 | <span>duration —</span> | |
| 523 | </div> | |
| 524 | </div> | |
| 525 | <div class="mig-row-actions"> | |
| 526 | <a | |
| 527 | class="mig-btn mig-btn-primary" | |
| 528 | href={`/migrations/verify/${r.id}`} | |
| 529 | > | |
| 530 | <IconShield /> | |
| 531 | Verify | |
| 532 | </a> | |
| 533 | </div> | |
| 14c3cc8 | 534 | </div> |
| 54eab24 | 535 | ); |
| 536 | })} | |
| 14c3cc8 | 537 | </div> |
| 538 | )} | |
| 539 | </div> | |
| 54eab24 | 540 | <style dangerouslySetInnerHTML={{ __html: migStyles }} /> |
| 14c3cc8 | 541 | </Layout> |
| 542 | ); | |
| 543 | }); | |
| 544 | ||
| 545 | // ─── GET /migrations/verify/:repoId ────────────────────────── | |
| 546 | migrations.get("/migrations/verify/:repoId", async (c) => { | |
| 547 | const user = c.get("user")!; | |
| 548 | const repoId = c.req.param("repoId"); | |
| 549 | ||
| 550 | let repo: | |
| 551 | | { | |
| 552 | id: string; | |
| 553 | name: string; | |
| 554 | ownerId: string; | |
| 555 | defaultBranch: string; | |
| 556 | } | |
| 557 | | null = null; | |
| 558 | try { | |
| 559 | const [row] = await db | |
| 560 | .select({ | |
| 561 | id: repositories.id, | |
| 562 | name: repositories.name, | |
| 563 | ownerId: repositories.ownerId, | |
| 564 | defaultBranch: repositories.defaultBranch, | |
| 565 | }) | |
| 566 | .from(repositories) | |
| 567 | .where(eq(repositories.id, repoId)) | |
| 568 | .limit(1); | |
| 569 | repo = (row as any) || null; | |
| 570 | } catch { | |
| 571 | repo = null; | |
| 572 | } | |
| 573 | ||
| 574 | if (!repo) { | |
| 575 | return c.html( | |
| 576 | <Layout title="Verify migration" user={user}> | |
| 54eab24 | 577 | <div class="mig-wrap"> |
| 578 | <div class="mig-crumbs"> | |
| 579 | <a href="/migrations">← Back to migration history</a> | |
| 580 | </div> | |
| 581 | <header class="mig-head"> | |
| 582 | <div class="mig-head-text"> | |
| 583 | <div class="mig-eyebrow"> | |
| 584 | <span class="mig-eyebrow-dot" aria-hidden="true" /> | |
| 585 | Verify migration | |
| 586 | </div> | |
| 587 | <h1 class="mig-title"> | |
| 588 | <span class="mig-title-grad">Repository not found.</span> | |
| 589 | </h1> | |
| 590 | <p class="mig-sub"> | |
| 591 | The repo ID didn't resolve — it may have been deleted or the | |
| 592 | URL is stale. | |
| 593 | </p> | |
| 594 | </div> | |
| 595 | </header> | |
| 14c3cc8 | 596 | </div> |
| 54eab24 | 597 | <style dangerouslySetInnerHTML={{ __html: migStyles }} /> |
| 14c3cc8 | 598 | </Layout>, |
| 599 | 404 | |
| 600 | ); | |
| 601 | } | |
| 602 | ||
| 603 | if (repo.ownerId !== user.id) { | |
| 604 | return c.html( | |
| 605 | <Layout title="Verify migration" user={user}> | |
| 54eab24 | 606 | <div class="mig-wrap"> |
| 607 | <div class="mig-crumbs"> | |
| 608 | <a href="/migrations">← Back to migration history</a> | |
| 609 | </div> | |
| 610 | <header class="mig-head"> | |
| 611 | <div class="mig-head-text"> | |
| 612 | <div class="mig-eyebrow"> | |
| 613 | <span class="mig-eyebrow-dot" aria-hidden="true" /> | |
| 614 | Forbidden | |
| 615 | </div> | |
| 616 | <h1 class="mig-title"> | |
| 617 | <span class="mig-title-grad">Not your repo.</span> | |
| 618 | </h1> | |
| 619 | <p class="mig-sub"> | |
| 620 | You can only verify repositories you own. | |
| 621 | </p> | |
| 622 | </div> | |
| 623 | </header> | |
| 14c3cc8 | 624 | </div> |
| 54eab24 | 625 | <style dangerouslySetInnerHTML={{ __html: migStyles }} /> |
| 14c3cc8 | 626 | </Layout>, |
| 627 | 403 | |
| 628 | ); | |
| 629 | } | |
| 630 | ||
| 631 | const verify = await loadVerifier(); | |
| 632 | let result: VerifyResult | null = null; | |
| 633 | let verifierError: string | null = null; | |
| 634 | if (!verify) { | |
| 635 | verifierError = | |
| 636 | "Verifier not available. The import-verify module is not installed yet."; | |
| 637 | } else { | |
| 638 | try { | |
| 639 | result = await verify(repo.id as unknown as number); | |
| 640 | } catch (err: any) { | |
| 641 | verifierError = | |
| 642 | "Verifier failed: " + (err && err.message ? err.message : String(err)); | |
| 643 | } | |
| 644 | } | |
| 645 | ||
| 646 | return c.html( | |
| 647 | <Layout title={`Verify ${repo.name}`} user={user}> | |
| 54eab24 | 648 | <div class="mig-wrap"> |
| 649 | <div class="mig-crumbs"> | |
| 650 | <a href="/migrations">← Back to migration history</a> | |
| 14c3cc8 | 651 | </div> |
| 54eab24 | 652 | <header class="mig-head"> |
| 653 | <div class="mig-head-text"> | |
| 654 | <div class="mig-eyebrow"> | |
| 655 | <span class="mig-eyebrow-dot" aria-hidden="true" /> | |
| 656 | Migration · Verify run | |
| 657 | </div> | |
| 658 | <h1 class="mig-title"> | |
| 659 | <span class="mig-title-grad"> | |
| 660 | {user.username}/{repo.name} | |
| 661 | </span> | |
| 662 | </h1> | |
| 663 | <p class="mig-sub"> | |
| 664 | Re-runs the post-migration verifier. We confirm clonability, the | |
| 665 | default branch, and the commit count. | |
| 666 | </p> | |
| 667 | </div> | |
| 668 | <div class="mig-actions"> | |
| 669 | <a | |
| 670 | class="mig-btn mig-btn-primary" | |
| 671 | href={`/migrations/verify/${repo.id}`} | |
| 672 | > | |
| 673 | <IconShield /> | |
| 674 | Re-run | |
| 675 | </a> | |
| 676 | <a class="mig-btn mig-btn-ghost" href="/migrations"> | |
| 677 | Back | |
| 678 | </a> | |
| 679 | </div> | |
| 680 | </header> | |
| 14c3cc8 | 681 | |
| 682 | {verifierError && ( | |
| 54eab24 | 683 | <div class="mig-banner is-warn" role="status"> |
| 684 | <span class="mig-banner-dot" aria-hidden="true" /> | |
| 14c3cc8 | 685 | {verifierError} |
| 686 | </div> | |
| 687 | )} | |
| 688 | ||
| 689 | {result && ( | |
| 54eab24 | 690 | <div class="mig-checks"> |
| 691 | <div class="mig-check"> | |
| 692 | <div class="mig-check-label"> | |
| 693 | <span | |
| 694 | class={ | |
| 695 | "mig-check-dot " + | |
| 696 | (result.clonable ? "is-ok" : "is-fail") | |
| 697 | } | |
| 698 | aria-hidden="true" | |
| 699 | /> | |
| 700 | Clonable | |
| 14c3cc8 | 701 | </div> |
| 54eab24 | 702 | <div class="mig-check-detail"> |
| 703 | {result.clonable | |
| 704 | ? "Repository responds to git clone" | |
| 705 | : "Clone failed"} | |
| 14c3cc8 | 706 | </div> |
| 707 | </div> | |
| 54eab24 | 708 | <div class="mig-check"> |
| 709 | <div class="mig-check-label"> | |
| 710 | <span | |
| 711 | class={ | |
| 712 | "mig-check-dot " + | |
| 713 | (result.hasDefaultBranch ? "is-ok" : "is-fail") | |
| 714 | } | |
| 715 | aria-hidden="true" | |
| 716 | /> | |
| 717 | Default branch | |
| 14c3cc8 | 718 | </div> |
| 54eab24 | 719 | <div class="mig-check-detail"> |
| 14c3cc8 | 720 | {result.hasDefaultBranch |
| 721 | ? `Found ${repo.defaultBranch}` | |
| 722 | : `Missing ${repo.defaultBranch}`} | |
| 723 | </div> | |
| 724 | </div> | |
| 54eab24 | 725 | <div class="mig-check"> |
| 726 | <div class="mig-check-label"> | |
| 727 | <span | |
| 728 | class={ | |
| 729 | "mig-check-dot " + | |
| 730 | (result.commitCount > 0 ? "is-ok" : "is-fail") | |
| 731 | } | |
| 732 | aria-hidden="true" | |
| 733 | /> | |
| 734 | Commits | |
| 14c3cc8 | 735 | </div> |
| 54eab24 | 736 | <div class="mig-check-detail"> |
| 14c3cc8 | 737 | {result.commitCount} commit |
| 738 | {result.commitCount === 1 ? "" : "s"} | |
| 739 | </div> | |
| 740 | </div> | |
| 741 | {result.issues && result.issues.length > 0 && ( | |
| 54eab24 | 742 | <div class="mig-check" style="flex-direction:column;align-items:stretch;gap:8px"> |
| 743 | <div class="mig-check-label"> | |
| 744 | <span class="mig-check-dot is-fail" aria-hidden="true" /> | |
| 745 | Issues | |
| 14c3cc8 | 746 | </div> |
| 54eab24 | 747 | <ul class="mig-issues-list"> |
| 14c3cc8 | 748 | {result.issues.map((i) => ( |
| 749 | <li>{i}</li> | |
| 750 | ))} | |
| 751 | </ul> | |
| 752 | </div> | |
| 753 | )} | |
| 754 | {(!result.issues || result.issues.length === 0) && | |
| 755 | result.clonable && | |
| 756 | result.hasDefaultBranch && | |
| 757 | result.commitCount > 0 && ( | |
| 54eab24 | 758 | <div class="mig-banner is-ok" role="status"> |
| 759 | <span class="mig-banner-dot" aria-hidden="true" /> | |
| 760 | All checks passed. | |
| 14c3cc8 | 761 | </div> |
| 762 | )} | |
| 763 | </div> | |
| 764 | )} | |
| 765 | </div> | |
| 54eab24 | 766 | <style dangerouslySetInnerHTML={{ __html: migStyles }} /> |
| 14c3cc8 | 767 | </Layout> |
| 768 | ); | |
| 769 | }); | |
| 770 | ||
| 771 | export default migrations; |