CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
actions-importer.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.
| bb2dea9 | 1 | /** |
| 2 | * GitHub Actions Importer — stateless YAML converter. | |
| 3 | * | |
| 4 | * Routes: | |
| 5 | * GET /import/actions — landing page with paste/upload form | |
| 6 | * POST /import/actions — parse & convert, show split-panel results | |
| 7 | * GET /import/actions/guide — migration guide | |
| 8 | * | |
| 9 | * No DB tables needed — the converter is fully stateless. | |
| 10 | * | |
| 11 | * Conversion logic: | |
| 12 | * - actions/checkout@* → stripped (Gluecron clones automatically) | |
| 13 | * - actions/setup-node@* → NODE_VERSION env var | |
| 14 | * - actions/setup-python@* → PYTHON_VERSION env var | |
| 15 | * - actions/setup-java@* → JAVA_VERSION env var | |
| 16 | * - actions/setup-go@* → GO_VERSION env var | |
| 17 | * - npm test / run / build → GateTest `run:` step | |
| 18 | * - ${{ secrets.FOO }} → $FOO | |
| 19 | * - ${{ github.* }} → stripped / noted as unsupported | |
| 20 | */ | |
| 21 | ||
| 22 | import { Hono } from "hono"; | |
| 23 | import { Layout } from "../views/layout"; | |
| 24 | import { softAuth } from "../middleware/auth"; | |
| 25 | import type { AuthEnv } from "../middleware/auth"; | |
| 26 | ||
| 27 | const actionsImporterRoutes = new Hono<AuthEnv>(); | |
| 28 | actionsImporterRoutes.use("*", softAuth); | |
| 29 | ||
| 30 | // ─── Types ─────────────────────────────────────────────────────────────────── | |
| 31 | ||
| 32 | interface GateStep { | |
| 33 | name: string; | |
| 34 | run: string; | |
| 35 | env?: Record<string, string>; | |
| 36 | on: string[]; | |
| 37 | } | |
| 38 | ||
| 39 | interface ConversionResult { | |
| 40 | gates: GateStep[]; | |
| 41 | supported: SupportedMapping[]; | |
| 42 | unsupported: UnsupportedItem[]; | |
| 43 | gatesYaml: string; | |
| 44 | } | |
| 45 | ||
| 46 | interface SupportedMapping { | |
| 47 | from: string; | |
| 48 | to: string; | |
| 49 | } | |
| 50 | ||
| 51 | interface UnsupportedItem { | |
| 52 | action: string; | |
| 53 | reason: string; | |
| 54 | } | |
| 55 | ||
| 56 | // ─── Scoped CSS (.ai-*) ────────────────────────────────────────────────────── | |
| 57 | ||
| 58 | const importerStyles = ` | |
| 59 | .ai-wrap { | |
| 60 | max-width: 1320px; | |
| 61 | margin: 0 auto; | |
| 62 | padding: var(--space-6) var(--space-4) var(--space-10); | |
| 63 | } | |
| 64 | ||
| 65 | /* ─── Hero ─── */ | |
| 66 | .ai-hero { | |
| 67 | position: relative; | |
| 68 | margin-bottom: var(--space-8); | |
| 69 | padding: var(--space-8) var(--space-8); | |
| 70 | background: var(--bg-elevated); | |
| 71 | border: 1px solid var(--border); | |
| 72 | border-radius: 18px; | |
| 73 | overflow: hidden; | |
| 74 | text-align: center; | |
| 75 | } | |
| 76 | .ai-hero::before { | |
| 77 | content: ''; | |
| 78 | position: absolute; | |
| 79 | top: 0; left: 0; right: 0; | |
| 80 | height: 2px; | |
| 6fd5915 | 81 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| bb2dea9 | 82 | pointer-events: none; |
| 83 | } | |
| 84 | .ai-hero-orb { | |
| 85 | position: absolute; | |
| 86 | top: -80px; right: -80px; | |
| 87 | width: 320px; height: 320px; | |
| 6fd5915 | 88 | background: radial-gradient(circle, rgba(91,110,232,0.18) 0%, rgba(95,143,160,0.09) 45%, transparent 70%); |
| bb2dea9 | 89 | filter: blur(60px); |
| 90 | pointer-events: none; | |
| 91 | z-index: 0; | |
| 92 | } | |
| 93 | .ai-hero-orb2 { | |
| 94 | position: absolute; | |
| 95 | bottom: -60px; left: -60px; | |
| 96 | width: 260px; height: 260px; | |
| 6fd5915 | 97 | background: radial-gradient(circle, rgba(95,143,160,0.14) 0%, transparent 70%); |
| bb2dea9 | 98 | filter: blur(55px); |
| 99 | pointer-events: none; | |
| 100 | z-index: 0; | |
| 101 | } | |
| 102 | .ai-hero-content { position: relative; z-index: 1; } | |
| 103 | .ai-eyebrow { | |
| 104 | display: inline-flex; | |
| 105 | align-items: center; | |
| 106 | gap: 8px; | |
| 107 | font-family: var(--font-mono); | |
| 108 | font-size: 11px; | |
| 109 | font-weight: 500; | |
| 110 | letter-spacing: 0.14em; | |
| 111 | text-transform: uppercase; | |
| 112 | color: var(--accent); | |
| 113 | margin-bottom: var(--space-3); | |
| 114 | } | |
| 115 | .ai-hero h1 { | |
| 116 | font-size: clamp(28px, 4vw, 48px); | |
| 117 | font-family: var(--font-display); | |
| 118 | font-weight: 700; | |
| 119 | letter-spacing: -0.03em; | |
| 120 | line-height: 1.1; | |
| 121 | margin-bottom: var(--space-3); | |
| 122 | color: var(--text-strong); | |
| 123 | } | |
| 124 | .ai-hero-sub { | |
| 125 | font-size: 17px; | |
| 126 | color: var(--text-muted); | |
| 127 | max-width: 600px; | |
| 128 | margin: 0 auto var(--space-6); | |
| 129 | line-height: 1.6; | |
| 130 | } | |
| 131 | .ai-grad { | |
| 6fd5915 | 132 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| bb2dea9 | 133 | -webkit-background-clip: text; |
| 134 | -webkit-text-fill-color: transparent; | |
| 135 | background-clip: text; | |
| 136 | } | |
| 137 | ||
| 138 | /* ─── Form card ─── */ | |
| 139 | .ai-form-card { | |
| 140 | background: var(--bg-elevated); | |
| 141 | border: 1px solid var(--border); | |
| 142 | border-radius: 14px; | |
| 143 | padding: var(--space-6); | |
| 144 | margin-bottom: var(--space-6); | |
| 145 | } | |
| 146 | .ai-form-label { | |
| 147 | display: block; | |
| 148 | font-size: 13px; | |
| 149 | font-weight: 600; | |
| 150 | color: var(--text-strong); | |
| 151 | margin-bottom: var(--space-2); | |
| 152 | } | |
| 153 | .ai-form-hint { | |
| 154 | font-size: 12px; | |
| 155 | color: var(--text-muted); | |
| 156 | margin-bottom: var(--space-3); | |
| 157 | } | |
| 158 | .ai-textarea { | |
| 159 | width: 100%; | |
| 160 | min-height: 280px; | |
| 161 | padding: var(--space-3) var(--space-4); | |
| 162 | background: var(--bg-tertiary); | |
| 163 | border: 1px solid var(--border); | |
| 164 | border-radius: var(--r-md); | |
| 165 | color: var(--text); | |
| 166 | font-family: var(--font-mono); | |
| 167 | font-size: 13px; | |
| 168 | line-height: 1.6; | |
| 169 | resize: vertical; | |
| 170 | transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease); | |
| 171 | } | |
| 172 | .ai-textarea:focus { | |
| 173 | outline: none; | |
| 174 | border-color: var(--border-focus); | |
| 175 | box-shadow: var(--ring); | |
| 176 | } | |
| 177 | .ai-textarea::placeholder { color: var(--text-faint); } | |
| 178 | ||
| 179 | .ai-upload-zone { | |
| 180 | border: 2px dashed var(--border); | |
| 181 | border-radius: var(--r-md); | |
| 182 | padding: var(--space-5); | |
| 183 | text-align: center; | |
| 184 | color: var(--text-muted); | |
| 185 | font-size: 13px; | |
| 186 | cursor: pointer; | |
| 187 | transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease); | |
| 188 | margin-top: var(--space-3); | |
| 189 | } | |
| 190 | .ai-upload-zone:hover { | |
| 191 | border-color: var(--accent); | |
| 6fd5915 | 192 | background: rgba(91,110,232,0.04); |
| bb2dea9 | 193 | } |
| 194 | .ai-upload-zone input[type=file] { | |
| 195 | display: none; | |
| 196 | } | |
| 197 | ||
| 198 | .ai-or-sep { | |
| 199 | display: flex; | |
| 200 | align-items: center; | |
| 201 | gap: var(--space-3); | |
| 202 | margin: var(--space-4) 0; | |
| 203 | color: var(--text-faint); | |
| 204 | font-size: 12px; | |
| 205 | } | |
| 206 | .ai-or-sep::before, .ai-or-sep::after { | |
| 207 | content: ''; | |
| 208 | flex: 1; | |
| 209 | height: 1px; | |
| 210 | background: var(--border); | |
| 211 | } | |
| 212 | ||
| 213 | .ai-submit-row { | |
| 214 | display: flex; | |
| 215 | align-items: center; | |
| 216 | gap: var(--space-3); | |
| 217 | margin-top: var(--space-5); | |
| 218 | flex-wrap: wrap; | |
| 219 | } | |
| 220 | .ai-submit-row .btn { | |
| 221 | min-width: 160px; | |
| 222 | } | |
| 223 | .ai-note { | |
| 224 | font-size: 12px; | |
| 225 | color: var(--text-muted); | |
| 226 | flex: 1; | |
| 227 | } | |
| 228 | .ai-note a { color: var(--text-link); } | |
| 229 | ||
| 230 | /* ─── Split panel (results) ─── */ | |
| 231 | .ai-split { | |
| 232 | display: grid; | |
| 233 | grid-template-columns: 1fr 1fr; | |
| 234 | gap: var(--space-4); | |
| 235 | margin-bottom: var(--space-6); | |
| 236 | } | |
| 237 | @media (max-width: 900px) { | |
| 238 | .ai-split { grid-template-columns: 1fr; } | |
| 239 | } | |
| 240 | .ai-panel { | |
| 241 | background: var(--bg-elevated); | |
| 242 | border: 1px solid var(--border); | |
| 243 | border-radius: 14px; | |
| 244 | overflow: hidden; | |
| 245 | display: flex; | |
| 246 | flex-direction: column; | |
| 247 | } | |
| 248 | .ai-panel-header { | |
| 249 | display: flex; | |
| 250 | align-items: center; | |
| 251 | justify-content: space-between; | |
| 252 | padding: var(--space-3) var(--space-4); | |
| 253 | border-bottom: 1px solid var(--border); | |
| 254 | background: var(--bg-tertiary); | |
| 255 | gap: var(--space-3); | |
| 256 | flex-shrink: 0; | |
| 257 | } | |
| 258 | .ai-panel-title { | |
| 259 | font-size: 12px; | |
| 260 | font-weight: 600; | |
| 261 | color: var(--text-muted); | |
| 262 | font-family: var(--font-mono); | |
| 263 | letter-spacing: 0.05em; | |
| 264 | text-transform: uppercase; | |
| 265 | } | |
| 266 | .ai-panel-badge { | |
| 267 | font-size: 11px; | |
| 268 | padding: 2px 8px; | |
| 269 | border-radius: 9999px; | |
| 270 | font-weight: 600; | |
| 271 | background: var(--bg-surface); | |
| 272 | color: var(--text-muted); | |
| 273 | border: 1px solid var(--border); | |
| 274 | font-family: var(--font-mono); | |
| 275 | } | |
| 276 | .ai-panel-badge.badge-green { | |
| 277 | background: rgba(52,211,153,0.1); | |
| 278 | color: var(--green); | |
| 279 | border-color: rgba(52,211,153,0.25); | |
| 280 | } | |
| 281 | .ai-code-block { | |
| 282 | flex: 1; | |
| 283 | margin: 0; | |
| 284 | padding: var(--space-4); | |
| 285 | overflow: auto; | |
| 286 | font-family: var(--font-mono); | |
| 287 | font-size: 12.5px; | |
| 288 | line-height: 1.7; | |
| 289 | color: var(--text); | |
| 290 | background: transparent; | |
| 291 | white-space: pre; | |
| 292 | max-height: 520px; | |
| 293 | } | |
| 294 | .ai-panel-actions { | |
| 295 | padding: var(--space-3) var(--space-4); | |
| 296 | border-top: 1px solid var(--border); | |
| 297 | display: flex; | |
| 298 | gap: var(--space-2); | |
| 299 | background: var(--bg-tertiary); | |
| 300 | flex-shrink: 0; | |
| 301 | flex-wrap: wrap; | |
| 302 | } | |
| 303 | .ai-panel-actions .btn { | |
| 304 | font-size: 12px; | |
| 305 | padding: 6px 12px; | |
| 306 | } | |
| 307 | ||
| 308 | /* ─── Mappings list ─── */ | |
| 309 | .ai-results-grid { | |
| 310 | display: grid; | |
| 311 | grid-template-columns: 1fr 1fr; | |
| 312 | gap: var(--space-4); | |
| 313 | margin-bottom: var(--space-6); | |
| 314 | } | |
| 315 | @media (max-width: 768px) { | |
| 316 | .ai-results-grid { grid-template-columns: 1fr; } | |
| 317 | } | |
| 318 | .ai-card { | |
| 319 | background: var(--bg-elevated); | |
| 320 | border: 1px solid var(--border); | |
| 321 | border-radius: 12px; | |
| 322 | overflow: hidden; | |
| 323 | } | |
| 324 | .ai-card-head { | |
| 325 | display: flex; | |
| 326 | align-items: center; | |
| 327 | gap: var(--space-2); | |
| 328 | padding: var(--space-3) var(--space-4); | |
| 329 | border-bottom: 1px solid var(--border); | |
| 330 | background: var(--bg-tertiary); | |
| 331 | font-size: 13px; | |
| 332 | font-weight: 600; | |
| 333 | color: var(--text-strong); | |
| 334 | } | |
| 335 | .ai-card-head svg { flex-shrink: 0; } | |
| 336 | .ai-card-body { padding: var(--space-3) 0; } | |
| 337 | .ai-map-row { | |
| 338 | display: flex; | |
| 339 | align-items: flex-start; | |
| 340 | gap: var(--space-3); | |
| 341 | padding: var(--space-2) var(--space-4); | |
| 342 | } | |
| 343 | .ai-map-row:hover { background: var(--bg-hover); } | |
| 344 | .ai-map-icon { | |
| 345 | width: 18px; | |
| 346 | height: 18px; | |
| 347 | border-radius: 50%; | |
| 348 | flex-shrink: 0; | |
| 349 | display: flex; | |
| 350 | align-items: center; | |
| 351 | justify-content: center; | |
| 352 | font-size: 10px; | |
| 353 | font-weight: 700; | |
| 354 | margin-top: 1px; | |
| 355 | } | |
| 356 | .ai-map-icon.ok { background: rgba(52,211,153,0.15); color: var(--green); } | |
| 357 | .ai-map-icon.warn { background: rgba(251,191,36,0.15); color: var(--yellow); } | |
| 358 | .ai-map-from { | |
| 359 | font-family: var(--font-mono); | |
| 360 | font-size: 12px; | |
| 361 | color: var(--text-muted); | |
| 362 | word-break: break-all; | |
| 363 | } | |
| 364 | .ai-map-arrow { | |
| 365 | color: var(--text-faint); | |
| 366 | font-size: 11px; | |
| 367 | flex-shrink: 0; | |
| 368 | margin-top: 2px; | |
| 369 | } | |
| 370 | .ai-map-to { | |
| 371 | font-family: var(--font-mono); | |
| 372 | font-size: 12px; | |
| 373 | color: var(--text-strong); | |
| 374 | word-break: break-all; | |
| 375 | } | |
| 376 | .ai-map-content { flex: 1; } | |
| 377 | .ai-map-reason { | |
| 378 | font-size: 11.5px; | |
| 379 | color: var(--text-muted); | |
| 380 | margin-top: 2px; | |
| 381 | } | |
| 382 | ||
| 383 | /* ─── Empty state ─── */ | |
| 384 | .ai-empty { | |
| 385 | padding: var(--space-6) var(--space-4); | |
| 386 | text-align: center; | |
| 387 | color: var(--text-faint); | |
| 388 | font-size: 13px; | |
| 389 | } | |
| 390 | ||
| 391 | /* ─── Auto-import note ─── */ | |
| 392 | .ai-auto-note { | |
| 393 | display: flex; | |
| 394 | align-items: flex-start; | |
| 395 | gap: var(--space-3); | |
| 396 | padding: var(--space-4) var(--space-5); | |
| 6fd5915 | 397 | background: rgba(91,110,232,0.07); |
| 398 | border: 1px solid rgba(91,110,232,0.22); | |
| bb2dea9 | 399 | border-radius: 10px; |
| 400 | margin-bottom: var(--space-6); | |
| 401 | } | |
| 402 | .ai-auto-note-icon { | |
| 403 | width: 32px; | |
| 404 | height: 32px; | |
| 405 | border-radius: 8px; | |
| 6fd5915 | 406 | background: rgba(91,110,232,0.18); |
| bb2dea9 | 407 | display: flex; |
| 408 | align-items: center; | |
| 409 | justify-content: center; | |
| 410 | flex-shrink: 0; | |
| 411 | color: var(--accent); | |
| 412 | } | |
| 413 | .ai-auto-note-text { | |
| 414 | font-size: 13.5px; | |
| 415 | color: var(--text); | |
| 416 | line-height: 1.55; | |
| 417 | } | |
| 418 | .ai-auto-note-text strong { color: var(--text-strong); } | |
| 419 | ||
| 420 | /* ─── Try again ─── */ | |
| 421 | .ai-try-again { | |
| 422 | margin-bottom: var(--space-6); | |
| 423 | display: flex; | |
| 424 | align-items: center; | |
| 425 | gap: var(--space-3); | |
| 426 | } | |
| 427 | ||
| 428 | /* ─── Guide ─── */ | |
| 429 | .ai-guide-wrap { | |
| 430 | max-width: 860px; | |
| 431 | margin: 0 auto; | |
| 432 | padding: var(--space-6) var(--space-4) var(--space-12); | |
| 433 | } | |
| 434 | .ai-guide-wrap h2 { | |
| 435 | font-size: 20px; | |
| 436 | margin: var(--space-8) 0 var(--space-3); | |
| 437 | padding-top: var(--space-4); | |
| 438 | border-top: 1px solid var(--border); | |
| 439 | color: var(--text-strong); | |
| 440 | } | |
| 441 | .ai-guide-wrap h2:first-of-type { border-top: none; margin-top: var(--space-5); } | |
| 442 | .ai-guide-wrap p { | |
| 443 | font-size: 15px; | |
| 444 | line-height: 1.7; | |
| 445 | color: var(--text); | |
| 446 | margin-bottom: var(--space-4); | |
| 447 | } | |
| 448 | .ai-guide-wrap ul, .ai-guide-wrap ol { | |
| 449 | padding-left: var(--space-6); | |
| 450 | margin-bottom: var(--space-4); | |
| 451 | } | |
| 452 | .ai-guide-wrap li { | |
| 453 | font-size: 15px; | |
| 454 | line-height: 1.7; | |
| 455 | color: var(--text); | |
| 456 | margin-bottom: var(--space-1); | |
| 457 | } | |
| 458 | .ai-guide-wrap code { | |
| 459 | font-family: var(--font-mono); | |
| 460 | font-size: 13px; | |
| 461 | background: var(--bg-tertiary); | |
| 462 | border: 1px solid var(--border-subtle); | |
| 463 | padding: 2px 6px; | |
| 464 | border-radius: var(--r-sm); | |
| 465 | } | |
| 466 | .ai-guide-pre { | |
| 467 | background: var(--bg-tertiary); | |
| 468 | border: 1px solid var(--border); | |
| 469 | border-radius: var(--r-md); | |
| 470 | padding: var(--space-4); | |
| 471 | font-family: var(--font-mono); | |
| 472 | font-size: 13px; | |
| 473 | line-height: 1.7; | |
| 474 | overflow-x: auto; | |
| 475 | margin-bottom: var(--space-5); | |
| 476 | white-space: pre; | |
| 477 | } | |
| 478 | .ai-step-list { | |
| 479 | list-style: none; | |
| 480 | padding: 0; | |
| 481 | counter-reset: step; | |
| 482 | display: flex; | |
| 483 | flex-direction: column; | |
| 484 | gap: var(--space-4); | |
| 485 | margin-bottom: var(--space-6); | |
| 486 | } | |
| 487 | .ai-step-item { | |
| 488 | display: flex; | |
| 489 | gap: var(--space-4); | |
| 490 | align-items: flex-start; | |
| 491 | counter-increment: step; | |
| 492 | } | |
| 493 | .ai-step-num { | |
| 494 | width: 28px; | |
| 495 | height: 28px; | |
| 496 | border-radius: 50%; | |
| 497 | background: var(--accent-gradient); | |
| 498 | color: #fff; | |
| 499 | font-size: 13px; | |
| 500 | font-weight: 700; | |
| 501 | display: flex; | |
| 502 | align-items: center; | |
| 503 | justify-content: center; | |
| 504 | flex-shrink: 0; | |
| 505 | } | |
| 506 | .ai-step-body { flex: 1; padding-top: 3px; } | |
| 507 | .ai-step-title { | |
| 508 | font-weight: 600; | |
| 509 | color: var(--text-strong); | |
| 510 | font-size: 15px; | |
| 511 | margin-bottom: var(--space-1); | |
| 512 | } | |
| 513 | .ai-step-desc { | |
| 514 | font-size: 13.5px; | |
| 515 | color: var(--text-muted); | |
| 516 | line-height: 1.55; | |
| 517 | } | |
| 518 | ||
| 519 | /* Buttons */ | |
| 520 | .btn { | |
| 521 | display: inline-flex; | |
| 522 | align-items: center; | |
| 523 | gap: 6px; | |
| 524 | padding: 9px 16px; | |
| 525 | border-radius: var(--r); | |
| 526 | font-size: 14px; | |
| 527 | font-weight: 600; | |
| 528 | font-family: var(--font-sans); | |
| 529 | border: 1px solid transparent; | |
| 530 | cursor: pointer; | |
| 531 | transition: all var(--t-fast) var(--ease); | |
| 532 | text-decoration: none; | |
| 533 | line-height: 1.3; | |
| 534 | } | |
| 535 | .btn-primary { | |
| 6fd5915 | 536 | background: linear-gradient(135deg, #5b6ee8 0%, #7059e0 100%); |
| bb2dea9 | 537 | color: #fff; |
| 6fd5915 | 538 | border-color: rgba(91,110,232,0.6); |
| 539 | box-shadow: 0 2px 8px rgba(91,110,232,0.30); | |
| bb2dea9 | 540 | } |
| 541 | .btn-primary:hover { | |
| 6fd5915 | 542 | background: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 100%); |
| 543 | box-shadow: 0 4px 14px rgba(91,110,232,0.40); | |
| bb2dea9 | 544 | text-decoration: none; |
| 545 | color: #fff; | |
| 546 | } | |
| 547 | .btn-secondary { | |
| 548 | background: var(--bg-tertiary); | |
| 549 | color: var(--text); | |
| 550 | border-color: var(--border); | |
| 551 | } | |
| 552 | .btn-secondary:hover { background: var(--bg-hover); border-color: var(--border-strong); text-decoration: none; color: var(--text-strong); } | |
| 553 | .btn-ghost { | |
| 554 | background: transparent; | |
| 555 | color: var(--text-muted); | |
| 556 | border-color: transparent; | |
| 557 | } | |
| 558 | .btn-ghost:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; } | |
| 559 | `; | |
| 560 | ||
| 561 | // ─── Conversion logic ───────────────────────────────────────────────────────── | |
| 562 | ||
| 563 | /** Known GitHub Actions that map to Gluecron env vars. */ | |
| 564 | const SETUP_ACTIONS: Record<string, { envKey: string; envValue: string; label: string }> = { | |
| 565 | "actions/setup-node": { | |
| 566 | envKey: "NODE_VERSION", | |
| 567 | envValue: "20", | |
| 568 | label: 'NODE_VERSION: "20"', | |
| 569 | }, | |
| 570 | "actions/setup-python": { | |
| 571 | envKey: "PYTHON_VERSION", | |
| 572 | envValue: "3.11", | |
| 573 | label: 'PYTHON_VERSION: "3.11"', | |
| 574 | }, | |
| 575 | "actions/setup-java": { | |
| 576 | envKey: "JAVA_VERSION", | |
| 577 | envValue: "21", | |
| 578 | label: 'JAVA_VERSION: "21"', | |
| 579 | }, | |
| 580 | "actions/setup-go": { | |
| 581 | envKey: "GO_VERSION", | |
| 582 | envValue: "1.22", | |
| 583 | label: 'GO_VERSION: "1.22"', | |
| 584 | }, | |
| 585 | "actions/setup-ruby": { | |
| 586 | envKey: "RUBY_VERSION", | |
| 587 | envValue: "3.3", | |
| 588 | label: 'RUBY_VERSION: "3.3"', | |
| 589 | }, | |
| 590 | "actions/setup-dotnet": { | |
| 591 | envKey: "DOTNET_VERSION", | |
| 592 | envValue: "8.0", | |
| 593 | label: 'DOTNET_VERSION: "8.0"', | |
| 594 | }, | |
| 595 | }; | |
| 596 | ||
| 597 | /** Well-known run commands → gate names. */ | |
| 598 | const RUN_NAME_MAP: Array<{ pattern: RegExp; name: string }> = [ | |
| 599 | { pattern: /\bnpm\s+test\b/, name: "Test" }, | |
| 600 | { pattern: /\bnpm\s+run\s+test\b/, name: "Test" }, | |
| 601 | { pattern: /\bnpm\s+run\s+lint\b/, name: "Lint" }, | |
| 602 | { pattern: /\bnpm\s+run\s+build\b/, name: "Build" }, | |
| 603 | { pattern: /\bpnpm\s+test\b/, name: "Test" }, | |
| 604 | { pattern: /\bpnpm\s+run\s+test\b/, name: "Test" }, | |
| 605 | { pattern: /\bpnpm\s+run\s+lint\b/, name: "Lint" }, | |
| 606 | { pattern: /\bpnpm\s+run\s+build\b/, name: "Build" }, | |
| 607 | { pattern: /\byarn\s+test\b/, name: "Test" }, | |
| 608 | { pattern: /\byarn\s+lint\b/, name: "Lint" }, | |
| 609 | { pattern: /\byarn\s+build\b/, name: "Build" }, | |
| 610 | { pattern: /\bpython\s+-m\s+pytest\b/, name: "Test" }, | |
| 611 | { pattern: /\bpytest\b/, name: "Test" }, | |
| 612 | { pattern: /\bflake8\b/, name: "Lint" }, | |
| 613 | { pattern: /\bruff\b/, name: "Lint" }, | |
| 614 | { pattern: /\bblack\b/, name: "Lint" }, | |
| 615 | { pattern: /\bgo\s+test\b/, name: "Test" }, | |
| 616 | { pattern: /\bgo\s+build\b/, name: "Build" }, | |
| 617 | { pattern: /\bmvn\s+test\b/, name: "Test" }, | |
| 618 | { pattern: /\bmvn\s+package\b/, name: "Build" }, | |
| 619 | { pattern: /\bgradle\b.*test\b/, name: "Test" }, | |
| 620 | { pattern: /\bgradle\b.*build\b/, name: "Build" }, | |
| 621 | { pattern: /\bcargo\s+test\b/, name: "Test" }, | |
| 622 | { pattern: /\bcargo\s+build\b/, name: "Build" }, | |
| 623 | { pattern: /\bcargo\s+clippy\b/, name: "Lint" }, | |
| 624 | ]; | |
| 625 | ||
| 626 | /** Actions that are silently dropped (no gate produced). */ | |
| 627 | const IGNORED_ACTIONS = new Set([ | |
| 628 | "actions/checkout", | |
| 629 | "actions/upload-artifact", | |
| 630 | "actions/download-artifact", | |
| 631 | "actions/cache", | |
| 632 | "actions/github-script", | |
| 633 | ]); | |
| 634 | ||
| 635 | /** | |
| 636 | * Very lightweight YAML line-by-line parser specifically for GitHub Actions | |
| 637 | * workflow files. Does NOT need a full YAML parser — we extract jobs, steps, | |
| 638 | * `uses:` and `run:` keys, and the `on:` trigger events. | |
| 639 | */ | |
| 640 | function convertActionsYaml(raw: string): ConversionResult { | |
| 641 | const lines = raw.split("\n"); | |
| 642 | ||
| 643 | // ── Pass 1: collect trigger events from `on:` block ────────────────────── | |
| 644 | const triggerEvents: string[] = []; | |
| 645 | let inOn = false; | |
| 646 | for (const line of lines) { | |
| 647 | const trimmed = line.trim(); | |
| 648 | if (/^on\s*:/.test(trimmed)) { | |
| 649 | inOn = true; | |
| 650 | // Inline form: `on: [push, pull_request]` | |
| 651 | const inlineMatch = trimmed.match(/^on\s*:\s*\[([^\]]+)\]/); | |
| 652 | if (inlineMatch) { | |
| 653 | for (const e of inlineMatch[1].split(",")) triggerEvents.push(e.trim()); | |
| 654 | inOn = false; | |
| 655 | } | |
| 656 | continue; | |
| 657 | } | |
| 658 | if (inOn) { | |
| 659 | if (/^\S/.test(line) && !/^\s*-/.test(line) && !trimmed.startsWith("#")) { | |
| 660 | // New top-level key — we've left the `on:` block | |
| 661 | inOn = false; | |
| 662 | continue; | |
| 663 | } | |
| 664 | const evtMatch = trimmed.match(/^-?\s*(\w[\w_-]*)(?:\s*:)?$/); | |
| 665 | if (evtMatch) triggerEvents.push(evtMatch[1]); | |
| 666 | } | |
| 667 | } | |
| 668 | ||
| 669 | const events = | |
| 670 | triggerEvents.length > 0 | |
| 671 | ? triggerEvents.filter((e) => ["push", "pull_request", "schedule", "workflow_dispatch"].includes(e)) | |
| 672 | : ["push", "pull_request"]; | |
| 673 | const finalEvents = events.length > 0 ? events : ["push", "pull_request"]; | |
| 674 | ||
| 675 | // ── Pass 2: parse jobs → steps ──────────────────────────────────────────── | |
| 676 | const gates: GateStep[] = []; | |
| 677 | const supported: SupportedMapping[] = []; | |
| 678 | const unsupported: UnsupportedItem[] = []; | |
| 679 | const seenGateNames = new Map<string, number>(); | |
| 680 | ||
| 681 | // Track per-job env vars (from setup actions) | |
| 682 | let currentJobEnv: Record<string, string> = {}; | |
| 683 | const pendingRunSteps: Array<{ run: string; stepName: string }> = []; | |
| 684 | ||
| 685 | let inJobs = false; | |
| 686 | let inSteps = false; | |
| 687 | let currentUses = ""; | |
| 688 | let currentRun = ""; | |
| 689 | let currentStepName = ""; | |
| 690 | let stepIndent = -1; | |
| 691 | let stepsIndent = -1; | |
| 692 | ||
| 693 | const flushRunStep = (runCmd: string, stepName: string) => { | |
| 694 | if (!runCmd.trim()) return; | |
| 695 | // Replace ${{ secrets.FOO }} → $FOO | |
| 696 | const converted = runCmd.replace(/\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}/g, "\\$$1"); | |
| 697 | ||
| 698 | // Determine gate name | |
| 699 | let gateName = stepName || ""; | |
| 700 | if (!gateName) { | |
| 701 | for (const { pattern, name } of RUN_NAME_MAP) { | |
| 702 | if (pattern.test(converted)) { | |
| 703 | gateName = name; | |
| 704 | break; | |
| 705 | } | |
| 706 | } | |
| 707 | } | |
| 708 | if (!gateName) gateName = "CI"; | |
| 709 | ||
| 710 | // Deduplicate gate names | |
| 711 | const existing = seenGateNames.get(gateName) ?? 0; | |
| 712 | seenGateNames.set(gateName, existing + 1); | |
| 713 | const uniqueName = existing === 0 ? gateName : `${gateName} ${existing + 1}`; | |
| 714 | ||
| 715 | const gate: GateStep = { | |
| 716 | name: uniqueName, | |
| 717 | run: converted.trim(), | |
| 718 | on: finalEvents, | |
| 719 | }; | |
| 720 | if (Object.keys(currentJobEnv).length > 0) { | |
| 721 | gate.env = { ...currentJobEnv }; | |
| 722 | } | |
| 723 | gates.push(gate); | |
| 724 | ||
| 725 | // Track supported mapping for secrets | |
| 726 | if (runCmd.includes("${{")) { | |
| 727 | const secretMatches = runCmd.matchAll(/\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}/g); | |
| 728 | for (const m of secretMatches) { | |
| 729 | supported.push({ from: `\${{ secrets.${m[1]} }}`, to: `\$${m[1]}` }); | |
| 730 | } | |
| 731 | } | |
| 732 | }; | |
| 733 | ||
| 734 | const flushUses = (uses: string) => { | |
| 735 | const action = uses.split("@")[0]; | |
| 736 | if (IGNORED_ACTIONS.has(action)) { | |
| 737 | if (action === "actions/checkout") { | |
| 738 | supported.push({ | |
| 739 | from: uses, | |
| 740 | to: "Automatic (Gluecron clones before every gate run)", | |
| 741 | }); | |
| 742 | } | |
| 743 | return; | |
| 744 | } | |
| 745 | if (SETUP_ACTIONS[action]) { | |
| 746 | const mapping = SETUP_ACTIONS[action]; | |
| 747 | currentJobEnv[mapping.envKey] = mapping.envValue; | |
| 748 | supported.push({ from: uses, to: mapping.label }); | |
| 749 | return; | |
| 750 | } | |
| 751 | // Unknown action — surface as unsupported | |
| 752 | unsupported.push({ | |
| 753 | action: uses, | |
| 754 | reason: "Third-party action — check the action's README and replicate the equivalent shell commands.", | |
| 755 | }); | |
| 756 | }; | |
| 757 | ||
| 758 | for (let i = 0; i < lines.length; i++) { | |
| 759 | const line = lines[i]; | |
| 760 | const trimmed = line.trim(); | |
| 761 | if (!trimmed || trimmed.startsWith("#")) continue; | |
| 762 | ||
| 763 | const indent = line.match(/^(\s*)/)?.[1].length ?? 0; | |
| 764 | ||
| 765 | // Top-level `jobs:` marker | |
| 766 | if (/^jobs\s*:/.test(trimmed) && indent === 0) { | |
| 767 | inJobs = true; | |
| 768 | inSteps = false; | |
| 769 | continue; | |
| 770 | } | |
| 771 | ||
| 772 | if (!inJobs) continue; | |
| 773 | ||
| 774 | // Job-level key (2-space or 4-space indent) — new job resets env + steps | |
| 775 | if (indent <= 2 && /^[a-z_-]+\s*:/i.test(trimmed) && trimmed !== "steps:") { | |
| 776 | // Flush any pending run from previous job | |
| 777 | if (currentRun) { | |
| 778 | flushRunStep(currentRun, currentStepName); | |
| 779 | currentRun = ""; | |
| 780 | currentStepName = ""; | |
| 781 | } | |
| 782 | currentJobEnv = {}; | |
| 783 | inSteps = false; | |
| 784 | stepsIndent = -1; | |
| 785 | stepIndent = -1; | |
| 786 | continue; | |
| 787 | } | |
| 788 | ||
| 789 | // `steps:` block | |
| 790 | if (/^steps\s*:/.test(trimmed)) { | |
| 791 | inSteps = true; | |
| 792 | stepsIndent = indent; | |
| 793 | if (currentRun) { | |
| 794 | flushRunStep(currentRun, currentStepName); | |
| 795 | currentRun = ""; | |
| 796 | currentStepName = ""; | |
| 797 | } | |
| 798 | continue; | |
| 799 | } | |
| 800 | ||
| 801 | if (!inSteps) continue; | |
| 802 | ||
| 803 | // New step marker (list item `- `) | |
| 804 | if (trimmed.startsWith("- ") || trimmed === "-") { | |
| 805 | // Flush previous step | |
| 806 | if (currentUses) { | |
| 807 | flushUses(currentUses); | |
| 808 | currentUses = ""; | |
| 809 | } | |
| 810 | if (currentRun) { | |
| 811 | flushRunStep(currentRun, currentStepName); | |
| 812 | currentRun = ""; | |
| 813 | } | |
| 814 | currentStepName = ""; | |
| 815 | stepIndent = indent; | |
| 816 | ||
| 817 | const restOfLine = trimmed.slice(2).trim(); | |
| 818 | if (restOfLine.startsWith("name:")) { | |
| 819 | currentStepName = restOfLine.replace(/^name\s*:\s*/, "").replace(/^['"]|['"]$/g, ""); | |
| 820 | } else if (restOfLine.startsWith("uses:")) { | |
| 821 | currentUses = restOfLine.replace(/^uses\s*:\s*/, "").trim(); | |
| 822 | } else if (restOfLine.startsWith("run:")) { | |
| 823 | currentRun = restOfLine.replace(/^run\s*:\s*/, ""); | |
| 824 | } | |
| 825 | continue; | |
| 826 | } | |
| 827 | ||
| 828 | // Continuation lines within a step | |
| 829 | if (stepIndent >= 0 && indent > stepIndent) { | |
| 830 | if (/^name\s*:/i.test(trimmed)) { | |
| 831 | currentStepName = trimmed.replace(/^name\s*:\s*/i, "").replace(/^['"]|['"]$/g, ""); | |
| 832 | } else if (/^uses\s*:/i.test(trimmed)) { | |
| 833 | currentUses = trimmed.replace(/^uses\s*:\s*/i, "").trim(); | |
| 834 | } else if (/^run\s*:/i.test(trimmed)) { | |
| 835 | currentRun = trimmed.replace(/^run\s*:\s*/i, ""); | |
| 836 | } else if (currentRun && /^\|/.test(trimmed)) { | |
| 837 | // multiline run block marker — the run content is on following lines | |
| 838 | currentRun = ""; | |
| 839 | } else if (currentRun !== "" && trimmed && !trimmed.includes(":")) { | |
| 840 | // Continuation of multiline run | |
| 841 | currentRun += "\n" + trimmed; | |
| 842 | } else if (currentRun && /^\$\{\{/.test(trimmed) || (currentRun && trimmed.match(/^[a-z]/))) { | |
| 843 | // looks like a run continuation | |
| 844 | currentRun += " " + trimmed; | |
| 845 | } | |
| 846 | } | |
| 847 | } | |
| 848 | ||
| 849 | // Flush last step | |
| 850 | if (currentUses) flushUses(currentUses); | |
| 851 | if (currentRun) flushRunStep(currentRun, currentStepName); | |
| 852 | ||
| 853 | // ── Check for unsupported github.* expressions ──────────────────────────── | |
| 854 | const githubExprCount = (raw.match(/\$\{\{\s*github\./g) || []).length; | |
| 855 | if (githubExprCount > 0) { | |
| 856 | unsupported.push({ | |
| 857 | action: "${{ github.* }} expressions (" + githubExprCount + " found)", | |
| 858 | reason: "GitHub context variables have no direct Gluecron equivalent. Use environment variables or gate output for dynamic values.", | |
| 859 | }); | |
| 860 | } | |
| 861 | ||
| 862 | // ── Check for env.* expressions ────────────────────────────────────────── | |
| 863 | const envExprCount = (raw.match(/\$\{\{\s*env\./g) || []).length; | |
| 864 | if (envExprCount > 0) { | |
| 865 | unsupported.push({ | |
| 866 | action: "${{ env.* }} expressions (" + envExprCount + " found)", | |
| 867 | reason: "Gluecron uses plain shell environment variables. Replace with the equivalent $VAR_NAME syntax.", | |
| 868 | }); | |
| 869 | } | |
| 870 | ||
| 871 | // ── Dedup supported mappings ────────────────────────────────────────────── | |
| 872 | const seenFrom = new Set<string>(); | |
| 873 | const dedupedSupported = supported.filter((s) => { | |
| 874 | if (seenFrom.has(s.from)) return false; | |
| 875 | seenFrom.add(s.from); | |
| 876 | return true; | |
| 877 | }); | |
| 878 | ||
| 879 | // ── Serialize to gates.yml ──────────────────────────────────────────────── | |
| 880 | let gatesYaml = "# .gluecron/gates.yml\n# Generated by Gluecron GitHub Actions Importer\n\n"; | |
| 881 | if (gates.length === 0) { | |
| 882 | gatesYaml += "gates: []\n\n# No convertible run steps were found.\n# Paste a workflow with `run:` steps to generate gates.\n"; | |
| 883 | } else { | |
| 884 | gatesYaml += "gates:\n"; | |
| 885 | for (const gate of gates) { | |
| 886 | gatesYaml += ` - name: "${gate.name}"\n`; | |
| 887 | gatesYaml += ` on: [${gate.on.join(", ")}]\n`; | |
| 888 | if (gate.env && Object.keys(gate.env).length > 0) { | |
| 889 | gatesYaml += " env:\n"; | |
| 890 | for (const [k, v] of Object.entries(gate.env)) { | |
| 891 | gatesYaml += ` ${k}: "${v}"\n`; | |
| 892 | } | |
| 893 | } | |
| 894 | // Multi-line run uses YAML block scalar | |
| 895 | if (gate.run.includes("\n")) { | |
| 896 | gatesYaml += " run: |\n"; | |
| 897 | for (const runLine of gate.run.split("\n")) { | |
| 898 | gatesYaml += ` ${runLine}\n`; | |
| 899 | } | |
| 900 | } else { | |
| 901 | gatesYaml += ` run: "${gate.run.replace(/"/g, '\\"')}"\n`; | |
| 902 | } | |
| 903 | } | |
| 904 | } | |
| 905 | ||
| 906 | return { gates, supported: dedupedSupported, unsupported, gatesYaml }; | |
| 907 | } | |
| 908 | ||
| 909 | // ─── Routes ────────────────────────────────────────────────────────────────── | |
| 910 | ||
| 911 | // GET /import/actions — landing form | |
| 912 | actionsImporterRoutes.get("/import/actions", (c) => { | |
| 913 | const user = c.get("user"); | |
| 914 | return c.html( | |
| 915 | <Layout | |
| 916 | title="GitHub Actions Importer — gluecron" | |
| 917 | user={user} | |
| 918 | description="Convert your GitHub Actions workflows to Gluecron gates.yml in seconds. Paste YAML or upload a workflow file." | |
| 919 | > | |
| 920 | <style dangerouslySetInnerHTML={{ __html: importerStyles }} /> | |
| 921 | <div class="ai-wrap"> | |
| 922 | <ImporterHero /> | |
| 923 | <ImporterForm /> | |
| 924 | <AutoImportNote /> | |
| 925 | </div> | |
| 926 | </Layout>, | |
| 927 | ); | |
| 928 | }); | |
| 929 | ||
| 930 | // POST /import/actions — convert and show results | |
| 931 | actionsImporterRoutes.post("/import/actions", async (c) => { | |
| 932 | const user = c.get("user"); | |
| 933 | ||
| 934 | const formData = await c.req.parseBody(); | |
| 935 | let rawYaml = (formData["yaml"] as string | undefined) ?? ""; | |
| 936 | ||
| 937 | // File upload support — browser sends as Blob | |
| 938 | if (!rawYaml && formData["file"]) { | |
| 939 | const file = formData["file"] as File | undefined; | |
| 940 | if (file && typeof file.text === "function") { | |
| 941 | rawYaml = await file.text(); | |
| 942 | } | |
| 943 | } | |
| 944 | ||
| 945 | rawYaml = rawYaml.trim(); | |
| 946 | ||
| 947 | if (!rawYaml) { | |
| 948 | return c.html( | |
| 949 | <Layout title="GitHub Actions Importer — gluecron" user={user}> | |
| 950 | <style dangerouslySetInnerHTML={{ __html: importerStyles }} /> | |
| 951 | <div class="ai-wrap"> | |
| 952 | <ImporterHero /> | |
| 953 | <div | |
| 954 | style="padding:16px 20px;border-radius:10px;background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);color:var(--red);font-size:14px;margin-bottom:24px" | |
| 955 | > | |
| 956 | No YAML provided. Please paste a workflow or upload a file. | |
| 957 | </div> | |
| 958 | <ImporterForm /> | |
| 959 | </div> | |
| 960 | </Layout>, | |
| 961 | 400, | |
| 962 | ); | |
| 963 | } | |
| 964 | ||
| 965 | const result = convertActionsYaml(rawYaml); | |
| 966 | ||
| 967 | return c.html( | |
| 968 | <Layout title="Converted — GitHub Actions Importer" user={user}> | |
| 969 | <style dangerouslySetInnerHTML={{ __html: importerStyles }} /> | |
| 970 | <div class="ai-wrap"> | |
| 971 | <ImporterHero compact /> | |
| 972 | <div class="ai-try-again"> | |
| 973 | <a href="/import/actions" class="btn btn-secondary"> | |
| 974 | ← Try another workflow | |
| 975 | </a> | |
| 976 | <a href="/import/actions/guide" class="btn btn-ghost"> | |
| 977 | Migration guide | |
| 978 | </a> | |
| 979 | </div> | |
| 980 | <AutoImportNote /> | |
| 981 | <ResultsSplitPanel original={rawYaml} result={result} /> | |
| 982 | <ResultsMappings result={result} /> | |
| 983 | </div> | |
| 984 | </Layout>, | |
| 985 | ); | |
| 986 | }); | |
| 987 | ||
| 988 | // GET /import/actions/guide — migration guide | |
| 989 | actionsImporterRoutes.get("/import/actions/guide", (c) => { | |
| 990 | const user = c.get("user"); | |
| 991 | return c.html( | |
| 992 | <Layout | |
| 993 | title="GitHub Actions → Gluecron Migration Guide" | |
| 994 | user={user} | |
| 995 | description="Step-by-step guide for migrating your GitHub Actions CI/CD pipelines to Gluecron gates." | |
| 996 | > | |
| 997 | <style dangerouslySetInnerHTML={{ __html: importerStyles }} /> | |
| 998 | <div class="ai-guide-wrap"> | |
| 999 | <MigrationGuide /> | |
| 1000 | </div> | |
| 1001 | </Layout>, | |
| 1002 | ); | |
| 1003 | }); | |
| 1004 | ||
| 1005 | // ─── JSX Components ────────────────────────────────────────────────────────── | |
| 1006 | ||
| 1007 | function ImporterHero({ compact }: { compact?: boolean }) { | |
| 1008 | if (compact) { | |
| 1009 | return ( | |
| 1010 | <div style="margin-bottom:var(--space-5)"> | |
| 1011 | <div class="ai-eyebrow">GitHub Actions Importer</div> | |
| 1012 | <h1 style="font-size:24px;font-family:var(--font-display);font-weight:700;color:var(--text-strong);margin:0"> | |
| 1013 | Conversion results | |
| 1014 | </h1> | |
| 1015 | </div> | |
| 1016 | ); | |
| 1017 | } | |
| 1018 | return ( | |
| 1019 | <div class="ai-hero"> | |
| 1020 | <div class="ai-hero-orb" aria-hidden="true" /> | |
| 1021 | <div class="ai-hero-orb2" aria-hidden="true" /> | |
| 1022 | <div class="ai-hero-content"> | |
| 1023 | <div class="ai-eyebrow">Migration tools</div> | |
| 1024 | <h1> | |
| 1025 | Convert GitHub Actions to{" "} | |
| 1026 | <span class="ai-grad">Gluecron gates</span> | |
| 1027 | </h1> | |
| 1028 | <p class="ai-hero-sub"> | |
| 1029 | Paste your <code>.github/workflows/*.yml</code> file and get an | |
| 1030 | equivalent <code>.gluecron/gates.yml</code> in seconds. The #1 | |
| 1031 | migration blocker — eliminated. | |
| 1032 | </p> | |
| 1033 | <div style="display:flex;align-items:center;justify-content:center;gap:var(--space-3);flex-wrap:wrap"> | |
| 1034 | <a href="#converter-form" class="btn btn-primary"> | |
| 1035 | Start converting | |
| 1036 | </a> | |
| 1037 | <a href="/import/actions/guide" class="btn btn-secondary"> | |
| 1038 | Migration guide | |
| 1039 | </a> | |
| 1040 | </div> | |
| 1041 | </div> | |
| 1042 | </div> | |
| 1043 | ); | |
| 1044 | } | |
| 1045 | ||
| 1046 | function AutoImportNote() { | |
| 1047 | return ( | |
| 1048 | <div class="ai-auto-note"> | |
| 1049 | <div class="ai-auto-note-icon" aria-hidden="true"> | |
| 1050 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 1051 | <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> | |
| 1052 | <polyline points="17 8 12 3 7 8" /> | |
| 1053 | <line x1="12" y1="3" x2="12" y2="15" /> | |
| 1054 | </svg> | |
| 1055 | </div> | |
| 1056 | <div class="ai-auto-note-text"> | |
| 1057 | <strong>Import a repo from GitHub and this conversion runs automatically.</strong>{" "} | |
| 1058 | When you use{" "} | |
| 1059 | <a href="/import">Import from GitHub</a>, Gluecron detects your | |
| 1060 | workflow files and generates a <code>.gluecron/gates.yml</code> for | |
| 1061 | you — no manual copy-paste required. | |
| 1062 | </div> | |
| 1063 | </div> | |
| 1064 | ); | |
| 1065 | } | |
| 1066 | ||
| 1067 | function ImporterForm() { | |
| 1068 | return ( | |
| 1069 | <div class="ai-form-card" id="converter-form"> | |
| b271465 | 1070 | <form method="post" action="/import/actions" enctype="multipart/form-data"> |
| bb2dea9 | 1071 | <label class="ai-form-label" for="yaml-input"> |
| 1072 | Paste your GitHub Actions workflow YAML | |
| 1073 | </label> | |
| 1074 | <p class="ai-form-hint"> | |
| 1075 | Paste the contents of a <code>.github/workflows/*.yml</code> file | |
| 1076 | below. Secrets, setup steps, and run commands are all handled. | |
| 1077 | </p> | |
| 1078 | <textarea | |
| 1079 | id="yaml-input" | |
| 1080 | name="yaml" | |
| 1081 | class="ai-textarea" | |
| 1082 | placeholder={`name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n - run: npm ci\n - run: npm test\n - run: npm run lint`} | |
| 1083 | spellcheck={false} | |
| 1084 | autocomplete="off" | |
| 1085 | /> | |
| 1086 | ||
| 1087 | <div class="ai-or-sep">or upload a file</div> | |
| 1088 | ||
| 1089 | <label class="ai-upload-zone" for="file-upload" tabIndex={0}> | |
| 1090 | <svg | |
| 1091 | width="22" | |
| 1092 | height="22" | |
| 1093 | viewBox="0 0 24 24" | |
| 1094 | fill="none" | |
| 1095 | stroke="currentColor" | |
| 1096 | stroke-width="1.8" | |
| 1097 | stroke-linecap="round" | |
| 1098 | stroke-linejoin="round" | |
| 1099 | style="margin-bottom:8px;color:var(--text-faint)" | |
| 1100 | > | |
| 1101 | <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> | |
| 1102 | <polyline points="17 8 12 3 7 8" /> | |
| 1103 | <line x1="12" y1="3" x2="12" y2="15" /> | |
| 1104 | </svg> | |
| 1105 | <div> | |
| 1106 | Drop a <code>.yml</code> workflow file here, or{" "} | |
| 1107 | <span style="color:var(--accent);text-decoration:underline">browse</span> | |
| 1108 | </div> | |
| 1109 | <div style="font-size:11px;color:var(--text-faint);margin-top:4px"> | |
| 1110 | YAML files only — max 512 KB | |
| 1111 | </div> | |
| 1112 | <input | |
| 1113 | id="file-upload" | |
| 1114 | type="file" | |
| 1115 | name="file" | |
| 1116 | accept=".yml,.yaml,text/yaml,application/yaml" | |
| 1117 | /> | |
| 1118 | </label> | |
| 1119 | ||
| 1120 | <div class="ai-submit-row"> | |
| 1121 | <button type="submit" class="btn btn-primary"> | |
| 1122 | Convert to Gluecron gates.yml | |
| 1123 | </button> | |
| 1124 | <span class="ai-note"> | |
| 1125 | No data is stored. The conversion runs entirely on the server and | |
| 1126 | the result is returned immediately.{" "} | |
| 1127 | <a href="/import/actions/guide">Read the migration guide →</a> | |
| 1128 | </span> | |
| 1129 | </div> | |
| 1130 | </form> | |
| 1131 | </div> | |
| 1132 | ); | |
| 1133 | } | |
| 1134 | ||
| 1135 | function ResultsSplitPanel({ original, result }: { original: string; result: ConversionResult }) { | |
| 1136 | const outputId = "ai-gates-output"; | |
| 1137 | const inputId = "ai-original-input"; | |
| 1138 | const gateCount = result.gates.length; | |
| 1139 | ||
| 1140 | return ( | |
| 1141 | <div class="ai-split"> | |
| 1142 | {/* Left: original */} | |
| 1143 | <div class="ai-panel"> | |
| 1144 | <div class="ai-panel-header"> | |
| 1145 | <span class="ai-panel-title">.github/workflows/ci.yml</span> | |
| 1146 | <span class="ai-panel-badge">Original</span> | |
| 1147 | </div> | |
| 1148 | <pre class="ai-code-block" id={inputId}>{original}</pre> | |
| 1149 | </div> | |
| 1150 | ||
| 1151 | {/* Right: converted */} | |
| 1152 | <div class="ai-panel"> | |
| 1153 | <div class="ai-panel-header"> | |
| 1154 | <span class="ai-panel-title">.gluecron/gates.yml</span> | |
| 1155 | <span class={`ai-panel-badge${gateCount > 0 ? " badge-green" : ""}`}> | |
| 1156 | {gateCount} {gateCount === 1 ? "gate" : "gates"} converted | |
| 1157 | </span> | |
| 1158 | </div> | |
| 1159 | <pre class="ai-code-block" id={outputId}>{result.gatesYaml}</pre> | |
| 1160 | <div class="ai-panel-actions"> | |
| 1161 | <button | |
| 1162 | type="button" | |
| 1163 | class="btn btn-secondary" | |
| 1164 | onclick={`(function(){ | |
| 1165 | var el = document.getElementById('${outputId}'); | |
| 1166 | if (!el) return; | |
| 1167 | navigator.clipboard && navigator.clipboard.writeText(el.textContent || '').then(function(){ | |
| 1168 | var b = event.currentTarget; | |
| 1169 | var orig = b.textContent; | |
| 1170 | b.textContent = 'Copied!'; | |
| 1171 | setTimeout(function(){ b.textContent = orig; }, 1800); | |
| 1172 | }); | |
| 1173 | })()`} | |
| 1174 | > | |
| 1175 | Copy to clipboard | |
| 1176 | </button> | |
| 1177 | <button | |
| 1178 | type="button" | |
| 1179 | class="btn btn-ghost" | |
| 1180 | onclick={`(function(){ | |
| 1181 | var el = document.getElementById('${outputId}'); | |
| 1182 | if (!el) return; | |
| 1183 | var blob = new Blob([el.textContent || ''], { type: 'text/yaml' }); | |
| 1184 | var url = URL.createObjectURL(blob); | |
| 1185 | var a = document.createElement('a'); | |
| 1186 | a.href = url; | |
| 1187 | a.download = 'gates.yml'; | |
| 1188 | document.body.appendChild(a); | |
| 1189 | a.click(); | |
| 1190 | setTimeout(function(){ document.body.removeChild(a); URL.revokeObjectURL(url); }, 500); | |
| 1191 | })()`} | |
| 1192 | > | |
| 1193 | Download as .gluecron/gates.yml | |
| 1194 | </button> | |
| 1195 | </div> | |
| 1196 | </div> | |
| 1197 | </div> | |
| 1198 | ); | |
| 1199 | } | |
| 1200 | ||
| 1201 | function ResultsMappings({ result }: { result: ConversionResult }) { | |
| 1202 | return ( | |
| 1203 | <div class="ai-results-grid"> | |
| 1204 | {/* Supported mappings */} | |
| 1205 | <div class="ai-card"> | |
| 1206 | <div class="ai-card-head"> | |
| 1207 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 1208 | <polyline points="20 6 9 17 4 12" /> | |
| 1209 | </svg> | |
| 1210 | Supported mappings ({result.supported.length}) | |
| 1211 | </div> | |
| 1212 | <div class="ai-card-body"> | |
| 1213 | {result.supported.length === 0 ? ( | |
| 1214 | <div class="ai-empty">No mapped actions detected in this workflow.</div> | |
| 1215 | ) : ( | |
| 1216 | result.supported.map((m) => ( | |
| 1217 | <div class="ai-map-row"> | |
| 1218 | <span class="ai-map-icon ok" aria-label="Supported">✓</span> | |
| 1219 | <div class="ai-map-content"> | |
| 1220 | <div class="ai-map-from">{m.from}</div> | |
| 1221 | <div style="display:flex;align-items:center;gap:6px;margin-top:2px"> | |
| 1222 | <span class="ai-map-arrow">→</span> | |
| 1223 | <div class="ai-map-to">{m.to}</div> | |
| 1224 | </div> | |
| 1225 | </div> | |
| 1226 | </div> | |
| 1227 | )) | |
| 1228 | )} | |
| 1229 | </div> | |
| 1230 | </div> | |
| 1231 | ||
| 1232 | {/* Unsupported / manual attention */} | |
| 1233 | <div class="ai-card"> | |
| 1234 | <div class="ai-card-head"> | |
| 1235 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--yellow)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 1236 | <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> | |
| 1237 | <line x1="12" y1="9" x2="12" y2="13" /> | |
| 1238 | <line x1="12" y1="17" x2="12.01" y2="17" /> | |
| 1239 | </svg> | |
| 1240 | Needs manual attention ({result.unsupported.length}) | |
| 1241 | </div> | |
| 1242 | <div class="ai-card-body"> | |
| 1243 | {result.unsupported.length === 0 ? ( | |
| 1244 | <div class="ai-empty" style="color:var(--green)"> | |
| 1245 | All actions were handled automatically. | |
| 1246 | </div> | |
| 1247 | ) : ( | |
| 1248 | result.unsupported.map((u) => ( | |
| 1249 | <div class="ai-map-row"> | |
| 1250 | <span class="ai-map-icon warn" aria-label="Needs attention">!</span> | |
| 1251 | <div class="ai-map-content"> | |
| 1252 | <div class="ai-map-from">{u.action}</div> | |
| 1253 | <div class="ai-map-reason">{u.reason}</div> | |
| 1254 | </div> | |
| 1255 | </div> | |
| 1256 | )) | |
| 1257 | )} | |
| 1258 | </div> | |
| 1259 | </div> | |
| 1260 | </div> | |
| 1261 | ); | |
| 1262 | } | |
| 1263 | ||
| 1264 | function MigrationGuide() { | |
| 1265 | return ( | |
| 1266 | <> | |
| 1267 | <div class="ai-eyebrow" style="margin-bottom:var(--space-3)">Migration guide</div> | |
| 1268 | <h1 style="font-size:clamp(28px,4vw,40px);font-family:var(--font-display);font-weight:700;letter-spacing:-0.03em;line-height:1.1;margin-bottom:var(--space-3);color:var(--text-strong)"> | |
| 1269 | GitHub Actions → Gluecron gates | |
| 1270 | </h1> | |
| 1271 | <p style="font-size:16px;color:var(--text-muted);margin-bottom:var(--space-8);max-width:620px"> | |
| 1272 | Everything you need to know to migrate your CI/CD pipelines from | |
| 1273 | GitHub Actions to Gluecron’s gate system — with zero downtime and | |
| 1274 | full automation coverage. | |
| 1275 | </p> | |
| 1276 | ||
| 1277 | <h2>Overview</h2> | |
| 1278 | <p> | |
| 1279 | Gluecron replaces GitHub Actions workflows with a lightweight{" "} | |
| 1280 | <code>.gluecron/gates.yml</code> file checked into your repository. | |
| 1281 | Gates run on push and pull request events — just like GitHub Actions — | |
| 1282 | but without the YAML boilerplate, the runner billing, or the | |
| 1283 | marketplace dependency risk. | |
| 1284 | </p> | |
| 1285 | <p> | |
| 1286 | Key differences from GitHub Actions: | |
| 1287 | </p> | |
| 1288 | <ul> | |
| 1289 | <li> | |
| 1290 | <strong>No checkout step needed.</strong> Gluecron clones your repo | |
| 1291 | automatically before every gate run. | |
| 1292 | </li> | |
| 1293 | <li> | |
| 1294 | <strong>No runner OS selection.</strong> All gates run in a managed, | |
| 1295 | clean environment per push. | |
| 1296 | </li> | |
| 1297 | <li> | |
| 1298 | <strong>Secrets are plain env vars.</strong>{" "} | |
| 1299 | <code>{"${{ secrets.MY_TOKEN }}"}</code> becomes{" "} | |
| 1300 | <code>$MY_TOKEN</code> — set in your repo’s Secrets settings. | |
| 1301 | </li> | |
| 1302 | <li> | |
| 1303 | <strong>No matrix builds yet.</strong> Run multiple gates instead of | |
| 1304 | a matrix strategy. | |
| 1305 | </li> | |
| 1306 | </ul> | |
| 1307 | ||
| 1308 | <h2>Quick start</h2> | |
| 1309 | <ol class="ai-step-list"> | |
| 1310 | <li class="ai-step-item"> | |
| 1311 | <div class="ai-step-num">1</div> | |
| 1312 | <div class="ai-step-body"> | |
| 1313 | <div class="ai-step-title">Paste your workflow into the importer</div> | |
| 1314 | <div class="ai-step-desc"> | |
| 1315 | Head to{" "} | |
| 1316 | <a href="/import/actions">/import/actions</a> and paste your{" "} | |
| 1317 | <code>.github/workflows/*.yml</code> file. The converter handles | |
| 1318 | secrets, setup actions, and run commands automatically. | |
| 1319 | </div> | |
| 1320 | </div> | |
| 1321 | </li> | |
| 1322 | <li class="ai-step-item"> | |
| 1323 | <div class="ai-step-num">2</div> | |
| 1324 | <div class="ai-step-body"> | |
| 1325 | <div class="ai-step-title">Download the generated gates.yml</div> | |
| 1326 | <div class="ai-step-desc"> | |
| 1327 | Click “Download as .gluecron/gates.yml” and commit | |
| 1328 | the file to the root of your repo at{" "} | |
| 1329 | <code>.gluecron/gates.yml</code>. | |
| 1330 | </div> | |
| 1331 | </div> | |
| 1332 | </li> | |
| 1333 | <li class="ai-step-item"> | |
| 1334 | <div class="ai-step-num">3</div> | |
| 1335 | <div class="ai-step-body"> | |
| 1336 | <div class="ai-step-title">Migrate your secrets</div> | |
| 1337 | <div class="ai-step-desc"> | |
| 1338 | Go to your repo’s <strong>Settings → Secrets</strong> and | |
| 1339 | add each secret that your workflow referenced. The names stay the | |
| 1340 | same — only the reference syntax changes. | |
| 1341 | </div> | |
| 1342 | </div> | |
| 1343 | </li> | |
| 1344 | <li class="ai-step-item"> | |
| 1345 | <div class="ai-step-num">4</div> | |
| 1346 | <div class="ai-step-body"> | |
| 1347 | <div class="ai-step-title">Push and verify</div> | |
| 1348 | <div class="ai-step-desc"> | |
| 1349 | Push a commit and watch your gates run in real time under{" "} | |
| 1350 | <strong>/:owner/:repo/push/:sha</strong>. Green gates on the | |
| 1351 | first push means you’re done. | |
| 1352 | </div> | |
| 1353 | </div> | |
| 1354 | </li> | |
| 1355 | </ol> | |
| 1356 | ||
| 1357 | <h2>Gates YAML reference</h2> | |
| 1358 | <p> | |
| 1359 | A complete <code>.gluecron/gates.yml</code> looks like this: | |
| 1360 | </p> | |
| 1361 | <pre class="ai-guide-pre">{`gates: | |
| 1362 | - name: "Test" | |
| 1363 | on: [push, pull_request] | |
| 1364 | env: | |
| 1365 | NODE_VERSION: "20" | |
| 1366 | run: "npm test" | |
| 1367 | ||
| 1368 | - name: "Lint" | |
| 1369 | on: [push, pull_request] | |
| 1370 | run: "npm run lint" | |
| 1371 | ||
| 1372 | - name: "Build" | |
| 1373 | on: [push] | |
| 1374 | run: "npm run build"`} | |
| 1375 | </pre> | |
| 1376 | ||
| 1377 | <h2>Action mapping reference</h2> | |
| 1378 | <p> | |
| 1379 | The following GitHub Actions are automatically converted: | |
| 1380 | </p> | |
| 1381 | ||
| 1382 | <div style="overflow-x:auto;margin-bottom:var(--space-6)"> | |
| 1383 | <table style="width:100%;border-collapse:collapse;font-size:13.5px"> | |
| 1384 | <thead> | |
| 1385 | <tr style="border-bottom:1px solid var(--border)"> | |
| 1386 | <th style="text-align:left;padding:10px 12px;color:var(--text-muted);font-weight:600">GitHub Actions</th> | |
| 1387 | <th style="text-align:left;padding:10px 12px;color:var(--text-muted);font-weight:600">Gluecron equivalent</th> | |
| 1388 | </tr> | |
| 1389 | </thead> | |
| 1390 | <tbody> | |
| 1391 | {[ | |
| 1392 | ["actions/checkout@v3", "Automatic — no step needed"], | |
| 1393 | ["actions/setup-node@v3 (node-version: 20)", 'NODE_VERSION: "20" env var'], | |
| 1394 | ["actions/setup-python@v3 (python-version: 3.11)", 'PYTHON_VERSION: "3.11" env var'], | |
| 1395 | ["actions/setup-java@v3", 'JAVA_VERSION: "21" env var'], | |
| 1396 | ["actions/setup-go@v3", 'GO_VERSION: "1.22" env var'], | |
| 1397 | ["actions/setup-ruby@v3", 'RUBY_VERSION: "3.3" env var'], | |
| 1398 | ["actions/setup-dotnet@v3", 'DOTNET_VERSION: "8.0" env var'], | |
| 1399 | ["actions/cache@v3", "No equivalent — cache is persistent across runs"], | |
| 1400 | ["actions/upload-artifact@v3", "No equivalent — use external storage or release assets"], | |
| 1401 | ['${{ secrets.MY_TOKEN }}', "$MY_TOKEN (plain env var)"], | |
| 1402 | ].map(([from, to]) => ( | |
| 1403 | <tr style="border-bottom:1px solid var(--border-subtle)"> | |
| 1404 | <td style="padding:10px 12px;font-family:var(--font-mono);font-size:12.5px;color:var(--text-muted)">{from}</td> | |
| 1405 | <td style="padding:10px 12px;font-family:var(--font-mono);font-size:12.5px;color:var(--text-strong)">{to}</td> | |
| 1406 | </tr> | |
| 1407 | ))} | |
| 1408 | </tbody> | |
| 1409 | </table> | |
| 1410 | </div> | |
| 1411 | ||
| 1412 | <h2>Manual steps</h2> | |
| 1413 | <p> | |
| 1414 | Some GitHub Actions patterns need manual adaptation: | |
| 1415 | </p> | |
| 1416 | <ul> | |
| 1417 | <li> | |
| 1418 | <strong>Matrix builds</strong> — create separate named gates instead. | |
| 1419 | E.g. replace a <code>node: [18, 20, 22]</code> matrix with three | |
| 1420 | gates named “Test Node 18”, “Test Node 20”, | |
| 1421 | “Test Node 22”. | |
| 1422 | </li> | |
| 1423 | <li> | |
| 1424 | <strong>Workflow-level env / outputs</strong> — use plain shell | |
| 1425 | variables or a shared setup script sourced by multiple gates. | |
| 1426 | </li> | |
| 1427 | <li> | |
| 1428 | <strong>if: conditions</strong> — Gluecron gates run on every | |
| 1429 | matched event. Conditional logic should live inside the run script. | |
| 1430 | </li> | |
| 1431 | <li> | |
| 1432 | <strong>Third-party marketplace actions</strong> — read the action | |
| 1433 | README and replicate the equivalent shell commands in a{" "} | |
| 1434 | <code>run:</code> step. | |
| 1435 | </li> | |
| 1436 | <li> | |
| 1437 | <strong>Scheduled workflows (<code>schedule:</code>)</strong> — use | |
| 1438 | Gluecron’s cron gate trigger once available, or migrate to a | |
| 1439 | lightweight external scheduler. | |
| 1440 | </li> | |
| 1441 | </ul> | |
| 1442 | ||
| 1443 | <h2>Need help?</h2> | |
| 1444 | <p> | |
| 1445 | Open an issue on any of your repos or reach us at{" "} | |
| 1446 | <a href="/help">the help page</a>. The{" "} | |
| 1447 | <a href="/import/actions">Actions Importer</a> handles the common 80% | |
| 1448 | — for complex workflows, the Gluecron team can review your YAML and | |
| 1449 | provide a hand-crafted gates.yml. | |
| 1450 | </p> | |
| 1451 | ||
| 1452 | <div style="margin-top:var(--space-8);display:flex;gap:var(--space-3);flex-wrap:wrap"> | |
| 1453 | <a href="/import/actions" class="btn btn-primary"> | |
| 1454 | Open the importer | |
| 1455 | </a> | |
| 1456 | <a href="/import" class="btn btn-secondary"> | |
| 1457 | Import a repo from GitHub | |
| 1458 | </a> | |
| 1459 | </div> | |
| 1460 | </> | |
| 1461 | ); | |
| 1462 | } | |
| 1463 | ||
| 1464 | export default actionsImporterRoutes; |