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.
3 files changed+68−11609a27a3957b8c44f67d187e9104f1b07cdfdda3
3 changed files+68−11
Modifiedsrc/routes/import-bulk.tsx+38−4View fileUnifiedSplit
@@ -494,11 +494,45 @@ async function fetchOrgRepos(
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
@@ -109,10 +109,18 @@ importSecretsRoutes.get(
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
@@ -21,6 +21,7 @@ import {
2121 parseGithubUrl,
2222 sanitizeRepoName,
2323 buildCloneUrl,
24 scrubSecrets,
2425} from "../lib/import-helper";
2526
2627const importRoutes = new Hono<AuthEnv>();
@@ -747,7 +748,15 @@ importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) =>
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
@@ -916,15 +925,21 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
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