Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ Then type one PURL query per line. Entering a blank line or using Ctrl+C on a bl
| `--repo <name>` | Single repository scope in the form `owner/name` (mutually exclusive with `--enterprise`/`--org` when syncing) |
| `--base-url <url>` | GitHub Enterprise Server REST base URL (e.g. `https://ghe.example.com/api/v3`) |
| `--concurrency <n>` | Parallel SBOM fetches (default 5) |
| `--exclude-archived` | Exclude archived repositories before SBOM collection |
| `--sbom-delay <ms>` | Delay between SBOM fetch requests (default 3000) |
| `--light-delay <ms>` | Delay between lightweight metadata requests (default 100) |
| `--sbom-cache <dir>` | Directory to read/write per‑repo SBOM JSON; required for SBOM syncing and offline use |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions src/sbomCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, { name: string; pushed_at?: string; updated_at?: string; default_branch?: string }[]> = {};
const orgRepoMap: Record<string, { name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }[]> = {};
let totalRepos = 0;

if (!this.opts.repo) {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions src/test-exclude-archived.ts
Original file line number Diff line number Diff line change
@@ -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<typeof makeMockOctokit> }).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);
});