Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit609a27aunknown_key

fix(import): close 4 lower-priority gaps from the bug-run audit

fix(import): close 4 lower-priority gaps from the bug-run audit

Ships every deferred item from today's import-flow audit so nothing's
left half-done:

1. import-bulk.tsx pagination (was: a single non-JSON batch silently
   killed the entire bulk import with a cryptic error). Now wraps
   res.json() in try/catch — if a page fails to parse, log it (token-
   scrubbed) and stop pagination, returning whatever we collected so
   far instead of throwing.

2. import-bulk.tsx org lookup (was: 404/401/403 from GitHub all
   surfaced as 'GitHub API error (xxx): <raw body>'). Now distinguishes
   the three real failure modes with actionable text — 'Organization
   not found', 'PAT invalid or expired', 'token lacks scope or hit rate
   limit' — and includes GitHub's own message string when present.

3. import-secrets.tsx empty checklist (was: silently redirected to
   /:owner/:repo with no feedback when zero secrets were migrated, so
   the user couldn't tell whether the migration succeeded or what to
   do next). Now redirects to /import with a success banner naming
   the three real reasons for an empty migration.

4. import.tsx console.error paths (was: error logs could contain
   token-injected git URLs that journald + log shippers preserve
   forever). Now scrubs every err message through scrubSecrets(msg,
   token) before logging, on every console.error site that has the
   token in scope. Also scrubs the user-facing redirect error string
   so a token can't leak into a query string either.

2050/2050 tests pass. tsc clean.
Claude committed on May 24, 2026Parent: 4b66018
3 files changed+6811609a27a3957b8c44f67d187e9104f1b07cdfdda3
3 changed files+68−11
Modifiedsrc/routes/import-bulk.tsx+38−4View fileUnifiedSplit
494494 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&type=all`;
495495 const res = await fetch(url, { headers });
496496 if (!res.ok) {
497 // Never echo the token. Include only the status + first slice of body.
498 const errBody = (await res.text()).slice(0, 200);
499 throw new Error(`GitHub API error (${res.status}): ${errBody}`);
497 // Distinguish the three real failure modes the operator can act on
498 // instead of dumping a raw GitHub body that hides the actual cause.
499 let detail = "";
500 try {
501 const body = await res.json();
502 detail = body?.message ? ` — ${String(body.message)}` : "";
503 } catch {
504 /* non-JSON body */
505 }
506 if (res.status === 404) {
507 throw new Error(
508 `Organization "${org}" not found on GitHub (404)${detail}. Check the spelling.`
509 );
510 }
511 if (res.status === 401) {
512 throw new Error(
513 `GitHub rejected the token (401)${detail}. The PAT is invalid or expired — mint a new one at github.com/settings/tokens.`
514 );
515 }
516 if (res.status === 403) {
517 throw new Error(
518 `GitHub forbade the request (403)${detail}. Your token likely lacks the 'read:org' / 'repo' scope, or you've hit the rate limit. Wait an hour or mint a token with the right scopes.`
519 );
520 }
521 throw new Error(`GitHub API error (${res.status})${detail}`);
522 }
523 let batch: GitHubRepo[];
524 try {
525 batch = (await res.json()) as GitHubRepo[];
526 } catch (err) {
527 // A malformed batch shouldn't kill the whole bulk import. Log it
528 // (without token leak) and stop pagination — the operator will see
529 // whatever we managed to collect so far.
530 console.error(
531 `[import-bulk] non-JSON response on page ${page} for org ${org}:`,
532 err instanceof Error ? err.message : err
533 );
534 break;
500535 }
501 const batch = (await res.json()) as GitHubRepo[];
502536 if (!Array.isArray(batch) || batch.length === 0) break;
503537 repos.push(...batch);
504538 if (batch.length < GITHUB_PER_PAGE) break;
Modifiedsrc/routes/import-secrets.tsx+11−3View fileUnifiedSplit
109109 const pastedCount = rows.length - emptyCount;
110110
111111 // If the repo has zero placeholders at all (e.g. the user came back to
112 // this URL after finishing earlier), short-circuit to the regular
113 // settings page so they're not stuck on a screen showing no rows.
112 // this URL after finishing earlier, or the secrets migration didn't
113 // actually find any rows — empty repo, no token, decrypt failure),
114 // redirect back to /import with a success banner explaining what
115 // happened. Previously this redirected silently to the repo home with
116 // no feedback, so users didn't know whether secrets had migrated or
117 // not.
114118 if (rows.length === 0) {
115 return c.redirect(`/${ownerName}/${repoName}`);
119 return c.redirect(
120 `/import?success=${encodeURIComponent(
121 `Import of ${ownerName}/${repoName} succeeded. No secrets were migrated — either the GitHub repo has none, the token lacks the 'actions:read' scope, or you already pasted values in a previous session. Open the repo at /${ownerName}/${repoName} to start using it.`
122 )}`
123 );
116124 }
117125
118126 return c.html(
Modifiedsrc/routes/import.tsx+19−4View fileUnifiedSplit
2121 parseGithubUrl,
2222 sanitizeRepoName,
2323 buildCloneUrl,
24 scrubSecrets,
2425} from "../lib/import-helper";
2526
2627const importRoutes = new Hono<AuthEnv>();
747748 imported++;
748749 } catch (err) {
749750 failed++;
750 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
751 // Belt + suspenders: even though importSingleRepo already redacts
752 // tokens before throwing, scrub here in case a future code path
753 // bypasses that. Tokens in console.error end up in journald and
754 // any log shipper — a single leak is forever.
755 const msg = err instanceof Error ? err.message : String(err);
756 console.error(
757 `[import] failed to import ${ghRepo.full_name}:`,
758 scrubSecrets(msg, githubToken)
759 );
751760 }
752761 }
753762
916925 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
917926 }
918927 } catch (err) {
919 console.error("[import] secrets migration skipped:", err);
928 const msg = err instanceof Error ? err.message : String(err);
929 console.error(
930 "[import] secrets migration skipped:",
931 scrubSecrets(msg, optionalToken)
932 );
920933 }
921934 }
922935
923936 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
924937 } catch (err) {
925 console.error("[import] error:", err);
938 const msg = err instanceof Error ? err.message : String(err);
939 const safe = scrubSecrets(msg, optionalToken);
940 console.error("[import] error:", safe);
926941 return c.redirect(
927 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
942 `/import?error=${encodeURIComponent(`Import failed: ${safe}`)}`
928943 );
929944 }
930945});
931946