diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cda4a1..71d3b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2026-03-26] - 0.2.1 - Archived repository filtering + +Added: + +- SBOM collection can now exclude archived repositories before queuing fetches via `--exclude-archived`. +- This reduces unnecessary API calls and prevents archived repositories from affecting SBOM fetch totals/results when exclusion is enabled. + ## [2025-12-09] – 0.2.0 - Branch scanning and dependency submission Added: diff --git a/README.md b/README.md index 52e5bea..92d69a6 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,7 @@ Then type one PURL query per line. Entering a blank line or using Ctrl+C on a bl | `--repo ` | Single repository scope in the form `owner/name` (mutually exclusive with `--enterprise`/`--org` when syncing) | | `--base-url ` | GitHub Enterprise Server REST base URL (e.g. `https://ghe.example.com/api/v3`) | | `--concurrency ` | Parallel SBOM fetches (default 5) | +| `--exclude-archived` | Exclude archived repositories before SBOM collection | | `--sbom-delay ` | Delay between SBOM fetch requests (default 3000) | | `--light-delay ` | Delay between lightweight metadata requests (default 100) | | `--sbom-cache ` | Directory to read/write per‑repo SBOM JSON; required for SBOM syncing and offline use | diff --git a/package.json b/package.json index e580780..abd4350 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start": "node dist/cli.js", "dev": "tsx src/cli.ts", "lint": "eslint . --ext .ts --max-warnings=0", - "test": "node dist/test-fixture-match.js && node dist/test-branch-search.js" + "test": "node dist/test-fixture-match.js && node dist/test-branch-search.js && node dist/test-exclude-archived.js" }, "engines": { "node": ">=18.0.0" diff --git a/src/cli.ts b/src/cli.ts index 831b22c..2770cea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ async function main() { .option("concurrency", { type: "number", default: 5 }) .option("sbom-delay", { type: "number", default: 3000, describe: "Delay (ms) between SBOM fetch requests" }) .option("light-delay", { type: "number", default: 100, describe: "Delay (ms) between lightweight metadata requests (org/repo listing, commit head checks)" }) + .option("exclude-archived", { type: "boolean", default: false, describe: "Exclude archived repositories before SBOM collection" }) .option("sbom-cache", { type: "string", describe: "Directory to read/write cached SBOM JSON files" }) .option("purl", { type: "array", describe: "One or more PURL strings to search (supports suffix * wildcard after slash)" }) .option("sync-sboms", { type: "boolean", default: false, describe: "Fetch SBOMs from GitHub (write to --sbom-cache if provided) instead of offline-only" }) @@ -130,6 +131,7 @@ async function main() { concurrency: argv.concurrency as number, delayMsBetweenRepos: argv["sbom-delay"] as number, lightDelayMs: argv["light-delay"] as number, + excludeArchived: argv["exclude-archived"] as boolean, loadFromDir: argv["sbom-cache"] as string | undefined, syncSboms: argv.syncSboms as boolean, showProgressBar: argv.progress as boolean, diff --git a/src/sbomCollector.ts b/src/sbomCollector.ts index 0e1a684..d6b0162 100644 --- a/src/sbomCollector.ts +++ b/src/sbomCollector.ts @@ -18,6 +18,7 @@ export interface CollectorOptions { ghes?: boolean; // Is this a GHES instance? concurrency?: number; // parallel repo SBOM fetches includePrivate?: boolean; + excludeArchived?: boolean; // when true, skip archived repos during listing delayMsBetweenRepos?: number; lightDelayMs?: number; // delay for lightweight (non-SBOM) requests loadFromDir?: string; // optional pre-existing serialized SBOM directory @@ -82,6 +83,7 @@ export class SbomCollector { baseUrl: o.baseUrl, concurrency: o.concurrency ?? 5, includePrivate: o.includePrivate ?? true, + excludeArchived: o.excludeArchived ?? false, delayMsBetweenRepos: o.delayMsBetweenRepos ?? 5000, lightDelayMs: o.lightDelayMs ?? 500, loadFromDir: o.loadFromDir, @@ -179,7 +181,7 @@ export class SbomCollector { this.summary.orgs = orgs; // Pre-list all repos if showing progress bar so we know the total upfront - const orgRepoMap: Record = {}; + const orgRepoMap: Record = {}; let totalRepos = 0; if (!this.opts.repo) { @@ -432,10 +434,10 @@ export class SbomCollector { } } - private async listOrgRepos(org: string): Promise<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string }[]> { + private async listOrgRepos(org: string): Promise<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }[]> { if (!this.octokit) throw new Error("No Octokit instance"); - interface RepoMeta { name: string; pushed_at?: string; updated_at?: string; default_branch?: string } + interface RepoMeta { name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean } const repos: RepoMeta[] = []; const per_page = 100; let page = 1; @@ -446,9 +448,10 @@ export class SbomCollector { await new Promise(r => setTimeout(r, this.opts.lightDelayMs)); - const items = resp.data as Array<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string }>; + const items = resp.data as Array<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }>; for (const r of items) { - repos.push({ name: r.name, pushed_at: r.pushed_at, updated_at: r.updated_at, default_branch: r.default_branch }); + if (this.opts.excludeArchived && r.archived) continue; + repos.push({ name: r.name, pushed_at: r.pushed_at, updated_at: r.updated_at, default_branch: r.default_branch, archived: r.archived }); } if (items.length < per_page) done = true; else page++; } catch (e) { diff --git a/src/test-exclude-archived.ts b/src/test-exclude-archived.ts new file mode 100644 index 0000000..69e8565 --- /dev/null +++ b/src/test-exclude-archived.ts @@ -0,0 +1,81 @@ +import { SbomCollector } from "./sbomCollector.js"; + +type RequestArgs = { org?: string; owner?: string; repo?: string; per_page?: number; page?: number }; + +function makeMockOctokit() { + return { + async request(route: string, _args: RequestArgs) { + if (route === "GET /orgs/{org}/repos") { + return { + data: [ + { name: "active-repo", archived: false, default_branch: "main", pushed_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }, + { name: "archived-repo", archived: true, default_branch: "main", pushed_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z" } + ] + }; + } + if (route === "GET /repos/{owner}/{repo}/dependency-graph/sbom") { + return { + data: { + sbom: { + packages: [ + { name: "chalk", version: "5.6.1", purl: "pkg:npm/chalk@5.6.1" } + ] + } + }, + headers: {} + }; + } + throw new Error(`Unexpected route in test mock: ${route}`); + } + }; +} + +async function collectWithExcludeArchived(excludeArchived: boolean) { + const collector = new SbomCollector({ + token: undefined, + org: "example-org", + syncSboms: true, + quiet: true, + lightDelayMs: 0, + delayMsBetweenRepos: 0, + excludeArchived + }); + + (collector as unknown as { octokit: ReturnType }).octokit = makeMockOctokit(); + const sboms = await collector.collect(); + return { sboms, summary: collector.getSummary() }; +} + +async function main() { + const included = await collectWithExcludeArchived(false); + const excluded = await collectWithExcludeArchived(true); + + if (included.summary.repositoryCount !== 2) { + console.error(`Expected repositoryCount=2 without exclude flag, got ${included.summary.repositoryCount}`); + process.exit(1); + } + if (included.sboms.length !== 2) { + console.error(`Expected 2 SBOM results without exclude flag, got ${included.sboms.length}`); + process.exit(1); + } + + if (excluded.summary.repositoryCount !== 1) { + console.error(`Expected repositoryCount=1 with exclude flag, got ${excluded.summary.repositoryCount}`); + process.exit(1); + } + if (excluded.sboms.length !== 1) { + console.error(`Expected 1 SBOM result with exclude flag, got ${excluded.sboms.length}`); + process.exit(1); + } + if (excluded.sboms[0]?.repo !== "example-org/active-repo") { + console.error(`Expected only active repo in results, got ${excluded.sboms.map(s => s.repo).join(", ")}`); + process.exit(1); + } + + process.stdout.write("Exclude archived test passed.\n"); +} + +main().catch(e => { + console.error(e); + process.exit(1); +});