1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | /**
* Post-import onboarding for a team migrating from GitHub.
*
* `POST /import/bulk` cloned repositories and inserted rows, and stopped
* there. A team that migrated an org therefore landed on a set of bare
* repositories with no branch protection, no labels, no CODEOWNERS, no gate
* settings, and no evidence that any of it was worth doing. The platform's
* entire value proposition — that it finds problems in your code — was
* something they had to go and discover for themselves, one repo at a time.
*
* This runs the work for them at import time and returns a report:
*
* 1. bootstrapRepository() green defaults: gates on, branch
* protection, labels, CODEOWNERS.
* 2. runSecretAndSecurityScan() the actual proof — committed secrets and
* security findings in the code they just
* brought over.
*
* Everything here is best-effort by design. A migration that imported 40
* repositories must not be reported as a failure because the scanner choked
* on one of them, so each repo's outcome is captured independently and a
* thrown error becomes a per-repo `error` field rather than a rejected
* promise.
*
* Concurrency is bounded (DB_FANOUT_LIMIT). Scanning is IO-heavy — a tree
* walk plus blob reads per repo — and an unbounded Promise.all over a
* 100-repo org would open 100 concurrent walks against the same disk and
* connection pool.
*/
import { mapWithConcurrency, DB_FANOUT_LIMIT } from "./concurrency";
/** One repository's post-import outcome. */
export interface MigrationRepoReport {
owner: string;
name: string;
/** Green-default bootstrap applied (settings, protection, labels). */
bootstrapped: boolean;
/** Number of branch-protection rules / labels created, for the summary. */
labelsCreated: number;
protectionCreated: boolean;
/** Committed secrets found in the imported code. */
secretsFound: number;
/** Security findings (vulnerable patterns) in the imported code. */
securityIssues: number;
/** Code files embedded into the semantic index. */
filesIndexed: number;
/** Human-readable scan detail, already scrubbed by the scanner. */
scanDetail: string;
/** Set when this repo's onboarding failed; the others still ran. */
error?: string;
}
export interface MigrationReport {
repos: MigrationRepoReport[];
/** Totals, precomputed so the view stays markup. */
totalRepos: number;
totalSecrets: number;
totalSecurityIssues: number;
reposBootstrapped: number;
/** Total code files embedded — what makes semantic search work at all. */
totalFilesIndexed: number;
reposWithFindings: number;
}
/**
* Bootstrap and scan every freshly imported repository.
*
* `repos` is the subset of a bulk import that actually landed — callers pass
* only successful imports, since there is nothing to scan otherwise.
*/
export async function runMigrationOnboarding(
repos: Array<{ id: string; owner: string; name: string; defaultBranch?: string | null }>,
ownerUserId: string
): Promise<MigrationReport> {
const results = await mapWithConcurrency(
repos,
DB_FANOUT_LIMIT,
async (repo): Promise<MigrationRepoReport> => {
const base: MigrationRepoReport = {
owner: repo.owner,
name: repo.name,
bootstrapped: false,
labelsCreated: 0,
protectionCreated: false,
secretsFound: 0,
securityIssues: 0,
filesIndexed: 0,
scanDetail: "",
};
const branch = repo.defaultBranch || "main";
// 1. Green defaults. Skips the welcome issue — an imported repo has
// real history and real issues; a "welcome" issue on top of 400
// migrated ones is noise, not onboarding.
try {
const { bootstrapRepository } = await import("./repo-bootstrap");
const r = await bootstrapRepository({
repositoryId: repo.id,
ownerUserId,
defaultBranch: branch,
skipWelcomeIssue: true,
});
base.bootstrapped = true;
base.labelsCreated = r.labelsCreated ?? 0;
base.protectionCreated = r.protectionCreated ?? false;
} catch (err) {
base.error = `bootstrap: ${err instanceof Error ? err.message : String(err)}`;
}
// 2. The proof. Scan the code they just brought over.
try {
const { runSecretAndSecurityScan } = await import("./gate");
const scan = await runSecretAndSecurityScan(
repo.owner,
repo.name,
`refs/heads/${branch}`,
branch,
{ scanSecrets: true, scanSecurity: true }
);
base.secretsFound = scan.secrets.length;
base.securityIssues = scan.securityIssues.length;
base.scanDetail = [scan.secretResult.details, scan.securityResult.details]
.filter(Boolean)
.join(" · ");
} catch (err) {
const msg = `scan: ${err instanceof Error ? err.message : String(err)}`;
base.error = base.error ? `${base.error}; ${msg}` : msg;
}
// 3. Semantic index. indexChangedFiles() is called only from the git
// post-receive hook, and a bulk import CLONES rather than pushes —
// so without this every imported repo had an empty index and
// semantic search, repo chat and "ask this repo" all returned
// nothing. Silently, because an empty index looks exactly like
// "no matches".
try {
const { indexExistingRepo } = await import("./index-existing-repo");
const idx = await indexExistingRepo({
repositoryId: repo.id,
owner: repo.owner,
repoName: repo.name,
ref: branch,
commitSha: branch,
});
base.filesIndexed = idx.indexed;
if (idx.error) {
base.error = base.error ? `${base.error}; index: ${idx.error}` : `index: ${idx.error}`;
}
} catch (err) {
const msg = `index: ${err instanceof Error ? err.message : String(err)}`;
base.error = base.error ? `${base.error}; ${msg}` : msg;
}
return base;
}
);
return {
repos: results,
totalRepos: results.length,
totalSecrets: results.reduce((n, r) => n + r.secretsFound, 0),
totalSecurityIssues: results.reduce((n, r) => n + r.securityIssues, 0),
totalFilesIndexed: results.reduce((n, r) => n + r.filesIndexed, 0),
reposBootstrapped: results.filter((r) => r.bootstrapped).length,
reposWithFindings: results.filter(
(r) => r.secretsFound > 0 || r.securityIssues > 0
).length,
};
}
|