fix(import): bulk import falls back to user accounts when the name is not an org #3526
1 changed file+104−47
Modifiedsrc/routes/import-bulk.tsx+104−47View fileUnifiedSplit
@@ -509,62 +509,119 @@ async function fetchOrgRepos(
509509 Authorization: `Bearer ${token}`,
510510 };
511511
512 const repos: GitHubRepo[] = [];
513 let page = 1;
514 while (repos.length < MAX_REPOS) {
515 const url = `https://api.github.com/orgs/${encodeURIComponent(
516 org
517 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&type=all`;
518 const res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
519 if (!res.ok) {
520 // Distinguish the three real failure modes the operator can act on
521 // instead of dumping a raw GitHub body that hides the actual cause.
522 let detail = "";
523 try {
524 const body = await res.json();
525 detail = body?.message ? ` — ${String(body.message)}` : "";
526 } catch {
527 /* non-JSON body */
528 }
529 if (res.status === 404) {
530 throw new Error(
531 `Organization "${org}" not found on GitHub (404)${detail}. Check the spelling.`
532 );
533 }
534 if (res.status === 401) {
535 throw new Error(
536 `GitHub rejected the token (401)${detail}. The PAT is invalid or expired — mint a new one at github.com/settings/tokens.`
537 );
512 const paginate = async (urlForPage: (page: number) => string) => {
513 const repos: GitHubRepo[] = [];
514 let page = 1;
515 while (repos.length < MAX_REPOS) {
516 const res = await fetch(urlForPage(page), {
517 headers,
518 signal: AbortSignal.timeout(30_000),
519 });
520 if (!res.ok) {
521 // Distinguish the real failure modes the operator can act on
522 // instead of dumping a raw GitHub body that hides the actual cause.
523 let detail = "";
524 try {
525 const body = await res.json();
526 detail = body?.message ? ` — ${String(body.message)}` : "";
527 } catch {
528 /* non-JSON body */
529 }
530 if (res.status === 404) {
531 throw new NotFoundOnGitHub(
532 `"${org}" not found on GitHub (404)${detail}. Check the spelling.`
533 );
534 }
535 if (res.status === 401) {
536 throw new Error(
537 `GitHub rejected the token (401)${detail}. The PAT is invalid or expired — mint a new one at github.com/settings/tokens.`
538 );
539 }
540 if (res.status === 403) {
541 throw new Error(
542 `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.`
543 );
544 }
545 throw new Error(`GitHub API error (${res.status})${detail}`);
538546 }
539 if (res.status === 403) {
540 throw new Error(
541 `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.`
547 let batch: GitHubRepo[];
548 try {
549 batch = (await res.json()) as GitHubRepo[];
550 } catch (err) {
551 // A malformed batch shouldn't kill the whole bulk import. Log it
552 // (without token leak) and stop pagination — the operator will see
553 // whatever we managed to collect so far.
554 console.error(
555 `[import-bulk] non-JSON response on page ${page} for ${org}:`,
556 err instanceof Error ? err.message : err
542557 );
558 break;
543559 }
544 throw new Error(`GitHub API error (${res.status})${detail}`);
560 if (!Array.isArray(batch) || batch.length === 0) break;
561 repos.push(...batch);
562 if (batch.length < GITHUB_PER_PAGE) break;
563 page++;
564 if (page > 10) break; // hard page ceiling: 1000 entries, we cap earlier anyway
545565 }
546 let batch: GitHubRepo[];
547 try {
548 batch = (await res.json()) as GitHubRepo[];
549 } catch (err) {
550 // A malformed batch shouldn't kill the whole bulk import. Log it
551 // (without token leak) and stop pagination — the operator will see
552 // whatever we managed to collect so far.
553 console.error(
554 `[import-bulk] non-JSON response on page ${page} for org ${org}:`,
555 err instanceof Error ? err.message : err
566 return repos.slice(0, MAX_REPOS);
567 };
568
569 try {
570 return await paginate(
571 (page) =>
572 `https://api.github.com/orgs/${encodeURIComponent(
573 org
574 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&type=all`
575 );
576 } catch (err) {
577 if (!(err instanceof NotFoundOnGitHub)) throw err;
578 }
579
580 // The name isn't an org — most personal migrations (including the owner's
581 // own account) live under a user, and GitHub 404s /orgs for those. When
582 // the name is the token's own login, /user/repos?affiliation=owner is the
583 // only listing that includes private repos; for anyone else's account,
584 // /users/{name}/repos returns their public repos.
585 let tokenLogin = "";
586 try {
587 const me = await fetch("https://api.github.com/user", {
588 headers,
589 signal: AbortSignal.timeout(30_000),
590 });
591 if (me.ok) {
592 tokenLogin = String(((await me.json()) as { login?: string })?.login || "");
593 }
594 } catch {
595 /* fall through to the public listing */
596 }
597
598 if (tokenLogin.toLowerCase() === org.toLowerCase()) {
599 return paginate(
600 (page) =>
601 `https://api.github.com/user/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&affiliation=owner`
602 );
603 }
604
605 try {
606 return await paginate(
607 (page) =>
608 `https://api.github.com/users/${encodeURIComponent(
609 org
610 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}`
611 );
612 } catch (err) {
613 if (err instanceof NotFoundOnGitHub) {
614 throw new Error(
615 `"${org}" is neither an organization nor a user on GitHub (404). Check the spelling.`
556616 );
557 break;
558617 }
559 if (!Array.isArray(batch) || batch.length === 0) break;
560 repos.push(...batch);
561 if (batch.length < GITHUB_PER_PAGE) break;
562 page++;
563 if (page > 10) break; // hard page ceiling: 1000 entries, we cap earlier anyway
618 throw err;
564619 }
565 return repos.slice(0, MAX_REPOS);
566620}
567621
622/** Marks a 404 from GitHub so the org → user fallback can catch precisely it. */
623class NotFoundOnGitHub extends Error {}
624
568625function matchesVisibility(repo: GitHubRepo, v: Visibility): boolean {
569626 if (v === "both") return true;
570627 if (v === "public") return repo.private === false;
571628
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts