diff --git a/README.md b/README.md index 846f2ad..4eed135 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,50 @@ projects, which the dotnet CLI can't build. Requires Node.js 22.12 or newer. +## Status across repositories + +`repokeeper github status` looks at every repository of a GitHub user or organization and lists the +ones that need attention. It runs from any folder and writes nothing: + +```text +$ repokeeper github status --owner vannt-dev +repo prs alerts ci unreleased graph +aspnet 0 0 none no tags stale (2) +dynamic-field-kit 0 0 ok 6 since v1.12.0 (1 feat/fix) ok +web-ban-hang 0 1 medium none no tags ok + +stale dependency graph (GitHub still reads old versions; a commit touching the manifest makes GitHub re-read it): + aspnet: nuget Microsoft.Owin 3.0.1 in the graph, 4.2.2 in OnlineShop/packages.config + aspnet: nuget bootstrap 3.0.0 in the graph, 3.4.1 in OnlineShop/packages.config + +55 of 58 repositories clean, 3 need attention +``` + +For each repository that is not archived (forks only with `--forks`) it reads: + +- **prs**: open pull requests, with the combined state of their check runs. +- **alerts**: open Dependabot alerts by severity; `off` when Dependabot alerts are disabled. +- **ci**: the latest finished run of each workflow on the default branch. Dependabot's update jobs, + Pages deployments, pull request runs and cancelled or skipped runs are left out; `none` when nothing + ran. A failure is listed with its date and link, since it may be an old run of a workflow that no + longer runs on that branch. +- **unreleased**: commits on the default branch since the latest release (or the highest version + tag), and how many of them are `feat`, `fix`, `perf`, `revert` or breaking, the commits + release-please would release. Only those count as needing attention. +- **graph**: whether GitHub's dependency graph is stale. It compares the npm and NuGet versions of the + graph's SBOM with the lockfiles on the default branch (`package-lock.json`, `npm-shrinkwrap.json`, + `pnpm-lock.yaml`, `packages.config`) and reports a package the graph names at a version no lockfile + contains while a lockfile has it at another one. GitHub then still raises Dependabot alerts for the + old version, and they never close; any commit that touches the manifest makes GitHub read it again. + +`--all` lists every repository, `--json` prints the whole result for a script, and `--exit-code` +exits 1 when a repository needs attention, for a scheduled workflow. `--owner` defaults to the user +the token belongs to; for another owner only public repositories are listed unless the owner is an +organization the token can read. The token comes from `GITHUB_TOKEN` or `gh auth token`; reading +Dependabot alerts needs a token with access to them (the `GITHUB_TOKEN` of a workflow sees only its +own repository). A repository that cannot be read, or hits the rate limit, is reported under +`could not check` and the others carry on. + ## Guides | Guide | What it covers | diff --git a/src/cli.ts b/src/cli.ts index db7c6c0..9365e7c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { gitlabApplyCommand } from "./commands/gitlab.js"; import { initCommand } from "./commands/init.js"; import { PRESET_SUMMARY, PRESETS, type Preset } from "./commands/presets.js"; import type { CommandOptions } from "./commands/report.js"; +import { githubStatusCommand } from "./commands/status.js"; import { updateCommand } from "./commands/update.js"; import { STACK_IDS, type StackId } from "./config/types.js"; import { RepokeeperError, UsageError } from "./errors.js"; @@ -38,6 +39,8 @@ export const USAGE = [ " check report drift from the standard without writing (exit 1 on drift)", " update move to the standard of this repokeeper version and resync", " github apply diff the GitHub settings against .repokeeper.yml and apply them", + " github status what needs attention across an owner's repositories: open pull requests, Dependabot", + " alerts, failing CI, unreleased changes, a stale dependency graph (runs from anywhere)", " gitlab apply the same for a GitLab project: settings, branch protection, the Renovate schedule", " eject copy the reusable CI workflows into this repository and call them from there", " bump write a release's version into the stack's version files (the GitLab release job runs it)", @@ -53,7 +56,11 @@ export const USAGE = [ " -i, --interactive ask which parts to apply, naming the files of each, and confirm before writing (init)", " --relock rebuild .repokeeper/lock.json from the current files (init)", " --to write every reusable workflow into another repository's folder instead (eject)", - " --json machine-readable output (check)", + " --json machine-readable output (check, github status)", + " --owner user or organization whose repositories to read (github status; default: the token's user)", + " --all list every repository, not only those that need attention (github status)", + " --forks include forks (github status)", + " --exit-code exit 1 when a repository needs attention (github status)", " -y, --yes apply without asking (github apply, gitlab apply)", " -v, --version print the version", ].join("\n"); @@ -79,6 +86,10 @@ export async function run(argv: string[], io: Io): Promise { relock: { type: "boolean", default: false }, to: { type: "string" }, json: { type: "boolean", default: false }, + owner: { type: "string" }, + all: { type: "boolean", default: false }, + forks: { type: "boolean", default: false }, + "exit-code": { type: "boolean", default: false }, yes: { type: "boolean", short: "y", default: false }, version: { type: "boolean", short: "v", default: false }, help: { type: "boolean", short: "h", default: false }, @@ -117,6 +128,11 @@ export async function run(argv: string[], io: Io): Promise { throw new UsageError("--preset and --interactive are only for init"); } const to = values.to as string | undefined; + const owner = values.owner as string | undefined; + const status = command === "github" && positionals[1] === "status"; + if ((owner !== undefined || values.all || values.forks || values["exit-code"]) && !status) { + throw new UsageError("--owner, --all, --forks and --exit-code are only for github status"); + } if (to !== undefined && command !== "eject") throw new UsageError("--to is only for eject"); const options: CommandOptions = { dryRun: values["dry-run"] as boolean, @@ -131,6 +147,10 @@ export async function run(argv: string[], io: Io): Promise { yes: values.yes as boolean, ...(platform !== undefined ? { platform } : {}), ...(to !== undefined ? { to } : {}), + ...(owner !== undefined ? { owner } : {}), + all: values.all as boolean, + forks: values.forks as boolean, + exitCode: values["exit-code"] as boolean, ...(preset !== undefined ? { preset: preset as Preset } : {}), interactive: values.interactive as boolean, }; @@ -140,7 +160,12 @@ export async function run(argv: string[], io: Io): Promise { if (command === "eject") return await ejectCommand(io.cwd, options, io); if (command === "bump") return await bumpCommand(io.cwd, positionals[1], options, io); if (command === "github") { - if (positionals[1] !== "apply") throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]"); + if (status) return await githubStatusCommand(options, io); + if (positionals[1] !== "apply") { + throw new UsageError( + "usage: repokeeper github apply [--dry-run] [--yes] | repokeeper github status [--owner ]", + ); + } return await githubApplyCommand(io.cwd, options, io); } if (command === "gitlab") { diff --git a/src/commands/report.ts b/src/commands/report.ts index 35e3edd..36673c9 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -38,6 +38,14 @@ export interface CommandOptions { to?: string; /** Folder of each stack given as `--stack id:folder` (init). */ stackDirectories?: Partial>; + /** User or organization whose repositories to read (github status). */ + owner?: string; + /** List every repository, not only those that need attention (github status). */ + all?: boolean; + /** Include forks (github status). */ + forks?: boolean; + /** Exit 1 when a repository needs attention (github status). */ + exitCode?: boolean; /** Platform to use instead of detection from the origin remote (init). */ platform?: import("../config/types.js").PlatformId; } diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..1efb23b --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,139 @@ +import type { Io } from "../cli.js"; +import { UsageError } from "../errors.js"; +import { type GitHubApi, resolveToken, restApi } from "../github/api.js"; +import { isError, listRepos, mapLimit, type RepoStatus, repoStatus } from "../github/status.js"; +import type { CommandOptions } from "./report.js"; + +/** How many repositories are read at the same time; each one makes about ten requests. */ +const CONCURRENCY = 4; + +export const REREAD_HINT = "a commit touching the manifest makes GitHub re-read it"; + +function pullsCell(status: RepoStatus): string { + return isError(status.pulls) ? "error" : String(status.pulls.length); +} + +function alertsCell(status: RepoStatus): string { + const alerts = status.alerts; + if (isError(alerts)) return "error"; + if (alerts.state === "off") return "off"; + if (alerts.total === 0) return "0"; + return Object.entries(alerts.bySeverity) + .map(([severity, count]) => `${count} ${severity}`) + .join(", "); +} + +function ciCell(status: RepoStatus): string { + const ci = status.ci; + if (isError(ci)) return "error"; + return ci.state === "success" ? "ok" : ci.state === "failure" ? "failed" : "none"; +} + +function releaseCell(status: RepoStatus): string { + const release = status.release; + if (isError(release)) return "error"; + if (release.tag === null) return "no tags"; + const releasable = release.releasable > 0 ? ` (${release.releasable} feat/fix)` : ""; + return `${release.commits} since ${release.tag}${releasable}`; +} + +function graphCell(status: RepoStatus): string { + const graph = status.graph; + if (isError(graph)) return "error"; + if (graph.state === "stale") return `stale (${graph.packages.length})`; + if (graph.state === "no-lockfile") return "-"; + return graph.state; +} + +function table(rows: string[][]): string[] { + const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length))) ?? []; + return rows.map((row) => + row + .map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))) + .join(" ") + .trimEnd(), + ); +} + +/** The human report: a table of the repositories to show, then the details behind it. */ +export function renderStatus(statuses: RepoStatus[], all: boolean): string[] { + const attention = statuses.filter((s) => s.attention.length > 0); + const shown = all ? statuses : attention; + const lines: string[] = []; + if (shown.length > 0) { + lines.push( + ...table([ + ["repo", "prs", "alerts", "ci", "unreleased", "graph"], + ...shown.map((s) => [s.repo, pullsCell(s), alertsCell(s), ciCell(s), releaseCell(s), graphCell(s)]), + ]), + ); + } + const pulls = shown.flatMap((s) => + isError(s.pulls) ? [] : s.pulls.map((p) => [`${s.repo}#${p.number}`, p.author, `checks ${p.checks}`, p.title]), + ); + if (pulls.length > 0) lines.push("", "open pull requests:", ...table(pulls).map((line) => ` ${line}`)); + const failing = shown.flatMap((s) => + !isError(s.ci) && s.ci.state === "failure" + ? s.ci.workflows.map((w) => ` ${s.repo}: ${w.name} ${w.conclusion} on ${w.date} ${w.url}`) + : [], + ); + if (failing.length > 0) lines.push("", "failing workflows on the default branch:", ...failing); + const stale = shown.flatMap((s) => + !isError(s.graph) && s.graph.state === "stale" + ? s.graph.packages.map( + (p) => + ` ${s.repo}: ${p.ecosystem} ${p.name} ${p.version} in the graph, ${p.lockfile.join(", ")} in ${p.files.join(", ") || "the lockfile"}`, + ) + : [], + ); + if (stale.length > 0) { + lines.push("", `stale dependency graph (GitHub still reads old versions; ${REREAD_HINT}):`, ...stale); + } + const unchecked = shown.flatMap((s) => + !isError(s.graph) && s.graph.state === "unchecked" ? [` ${s.repo}: graph unchecked, ${s.graph.reason}`] : [], + ); + const errors = shown.flatMap((s) => + (["pulls", "alerts", "ci", "release", "graph"] as const).flatMap((key) => { + const value = s[key]; + return isError(value) ? [` ${s.repo}: ${key}: ${value.error}`] : []; + }), + ); + if (errors.length + unchecked.length > 0) lines.push("", "could not check:", ...errors, ...unchecked); + if (errors.some((line) => line.includes("rate limited"))) { + lines.push("note: GitHub's rate limit was reached; run again once it resets"); + } + const clean = statuses.length - attention.length; + if (lines.length > 0) lines.push(""); + lines.push( + attention.length === 0 + ? `all ${statuses.length} repositories clean` + : `${clean} of ${statuses.length} repositories clean, ${attention.length} need attention`, + ); + return lines; +} + +async function login(api: GitHubApi): Promise { + try { + const { status, data } = await api.request("GET", "/user"); + if (status === 200 && typeof data === "object" && data !== null && "login" in data) return String(data.login); + } catch {} + return undefined; +} + +/** `repokeeper github status`: what needs attention across every repository of an owner. */ +export async function githubStatusCommand(options: CommandOptions, io: Io): Promise { + const api = io.githubApi ?? restApi(await resolveToken()); + const user = await login(api); + const owner = options.owner ?? user; + if (owner === undefined) throw new UsageError("cannot tell whose repositories to read; pass --owner "); + const repos = (await listRepos(api, owner, user)).filter((repo) => !repo.archived && (options.forks || !repo.fork)); + const statuses = await mapLimit(repos, CONCURRENCY, (repo) => repoStatus(api, owner, repo)); + const needAttention = statuses.some((s) => s.attention.length > 0); + if (options.json) { + const clean = statuses.filter((s) => s.attention.length === 0).length; + io.out(JSON.stringify({ owner, clean, repositories: statuses }, null, 2)); + } else { + for (const line of renderStatus(statuses, options.all ?? false)) io.out(line); + } + return options.exitCode && needAttention ? 1 : 0; +} diff --git a/src/github/api.ts b/src/github/api.ts index 9662272..63299ed 100644 --- a/src/github/api.ts +++ b/src/github/api.ts @@ -5,6 +5,8 @@ import { ApiError } from "../errors.js"; export interface ApiResponse { status: number; data: unknown; + /** The `Link` header, which cursor-paginated lists use to name the next page. */ + link?: string; } /** The slice of the GitHub REST API `github apply` needs; tests pass a fake. */ @@ -51,7 +53,8 @@ export function restApi(token: string, base = "https://api.github.com"): GitHubA } catch { data = text; } - return { status: response.status, data }; + const link = response.headers.get("link"); + return { status: response.status, data, ...(link ? { link } : {}) }; }, }; } diff --git a/src/github/lockfiles.ts b/src/github/lockfiles.ts new file mode 100644 index 0000000..5fe88fa --- /dev/null +++ b/src/github/lockfiles.ts @@ -0,0 +1,176 @@ +import { parseAllDocuments } from "yaml"; + +/** The package ecosystems whose lockfiles `github status` reads to check GitHub's dependency graph. */ +export type Ecosystem = "npm" | "nuget"; + +/** Every version of every package the lockfiles of a repository name, keyed by `ecosystem:name`. */ +export type VersionIndex = Map>; + +/** The lockfile names `github status` reads, and the ecosystem of each. */ +export const LOCKFILES: Record = { + "package-lock.json": "npm", + "npm-shrinkwrap.json": "npm", + "pnpm-lock.yaml": "npm", + "packages.config": "nuget", +}; + +/** NuGet ids are case-insensitive and `1.17.0.0` is the same version as `1.17.0`. */ +function normalize(ecosystem: Ecosystem, name: string, version: string): { name: string; version: string } { + if (ecosystem === "npm") return { name, version }; + const [core = "", ...rest] = version.split(/(?=[-+])/); + const parts = core.split("."); + while (parts.length > 3 && parts.at(-1) === "0") parts.pop(); + while (parts.length < 3) parts.push("0"); + return { name: name.toLowerCase(), version: [parts.join("."), ...rest].join("").toLowerCase() }; +} + +export function indexKey(ecosystem: Ecosystem, name: string): string { + return `${ecosystem}:${normalize(ecosystem, name, "0").name}`; +} + +function add(index: VersionIndex, ecosystem: Ecosystem, name: string, version: string): void { + if (!name || !version) return; + const normal = normalize(ecosystem, name, version); + const key = `${ecosystem}:${normal.name}`; + const versions = index.get(key) ?? new Set(); + versions.add(normal.version); + index.set(key, versions); +} + +type Json = Record; +const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null; + +/** package-lock.json and npm-shrinkwrap.json: the `packages` map of lockfile v2/v3, or v1's nested `dependencies`. */ +function readNpmLock(text: string, index: VersionIndex): void { + const lock: unknown = JSON.parse(text); + if (!isObject(lock)) return; + if (isObject(lock.packages)) { + for (const [path, entry] of Object.entries(lock.packages)) { + const at = path.lastIndexOf("node_modules/"); + if (at < 0 || !isObject(entry) || entry.link === true || typeof entry.version !== "string") continue; + const name = typeof entry.name === "string" ? entry.name : path.slice(at + "node_modules/".length); + add(index, "npm", name, entry.version); + } + return; + } + const walk = (dependencies: unknown) => { + if (!isObject(dependencies)) return; + for (const [name, entry] of Object.entries(dependencies)) { + if (!isObject(entry)) continue; + if (typeof entry.version === "string") add(index, "npm", name, entry.version); + walk(entry.dependencies); + } + }; + walk(lock.dependencies); +} + +/** + * pnpm-lock.yaml: the keys of `packages`. pnpm 11 may write a document of its own dependencies first. Lockfile v9 writes `name@1.2.3(peer@4.0.0)`, v6 the same with a + * leading slash, v5 `/name/1.2.3_peer@4.0.0`; a scoped name has its own `@`. + */ +function readPnpmLock(text: string, index: VersionIndex): void { + for (const document of parseAllDocuments(text)) { + if (!("errors" in document)) continue; + if (document.errors.length > 0) throw document.errors[0]; + readPnpmDocument(document.toJS(), index); + } +} + +function readPnpmDocument(lock: unknown, index: VersionIndex): void { + if (!isObject(lock) || !isObject(lock.packages)) return; + const legacy = Number.parseFloat(String(lock.lockfileVersion ?? "9")) < 6; + for (const [key, entry] of Object.entries(lock.packages)) { + if (isObject(entry) && typeof entry.name === "string" && typeof entry.version === "string") { + add(index, "npm", entry.name, entry.version); + continue; + } + const plain = key.replace(/^\//, "").replace(/\(.*$/, ""); + if (legacy) { + const segments = plain.split("/"); + const scoped = plain.startsWith("@"); + const name = segments.slice(0, scoped ? 2 : 1).join("/"); + add(index, "npm", name, (segments[scoped ? 2 : 1] ?? "").replace(/_.*$/, "")); + continue; + } + const at = plain.lastIndexOf("@"); + if (at > 0) add(index, "npm", plain.slice(0, at), plain.slice(at + 1)); + } +} + +/** packages.config of a .NET Framework project: ``. */ +function readPackagesConfig(text: string, index: VersionIndex): void { + for (const [element] of text.matchAll(/]*>/g)) { + const id = /\sid\s*=\s*"([^"]+)"/.exec(element)?.[1]; + const version = /\sversion\s*=\s*"([^"]+)"/.exec(element)?.[1]; + if (id && version) add(index, "nuget", id, version); + } +} + +/** Adds the packages of one lockfile, named by its path, to the index. Throws when the file does not parse. */ +export function readLockfile(path: string, text: string, index: VersionIndex): void { + const name = path.slice(path.lastIndexOf("/") + 1); + if (name === "pnpm-lock.yaml") readPnpmLock(text, index); + else if (name === "packages.config") readPackagesConfig(text, index); + else readNpmLock(text, index); +} + +export interface GraphPackage { + ecosystem: Ecosystem; + name: string; + version: string; +} + +/** An exact version, not a range from package.json such as `^3.25.76` or `1.x`. */ +const EXACT = /^\d[0-9A-Za-z.+-]*$/; + +/** The npm and NuGet packages of a dependency graph SBOM (`GET /repos/{owner}/{repo}/dependency-graph/sbom`). */ +export function sbomPackages(sbom: unknown): GraphPackage[] { + const packages = isObject(sbom) && isObject(sbom.sbom) ? sbom.sbom.packages : undefined; + if (!Array.isArray(packages)) return []; + const seen = new Set(); + const result: GraphPackage[] = []; + for (const entry of packages) { + if (!isObject(entry) || !Array.isArray(entry.externalRefs)) continue; + for (const ref of entry.externalRefs) { + if (!isObject(ref) || ref.referenceType !== "purl" || typeof ref.referenceLocator !== "string") continue; + const match = /^pkg:(npm|nuget)\/([^?#]+)@([^@?#]+)(?:[?#].*)?$/.exec(ref.referenceLocator); + if (!match) continue; + const ecosystem = match[1] as Ecosystem; + let name: string; + let version: string; + try { + name = decodeURIComponent(match[2] ?? ""); + version = decodeURIComponent(match[3] ?? ""); + } catch { + continue; + } + if (!EXACT.test(version) || /(^|\.)[xX*](\.|$)/.test(version)) continue; + const key = `${ecosystem}:${name}@${version}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ ecosystem, name, version }); + } + } + return result; +} + +export interface StalePackage extends GraphPackage { + /** The versions the lockfiles name instead. */ + lockfile: string[]; +} + +/** + * Packages the dependency graph names at a version no lockfile contains, while a lockfile has the package at + * another version: GitHub has not re-read the manifest since it changed. A package no lockfile names at all + * may come from a manifest this check does not read, so it is not reported. + */ +export function stalePackages(graph: GraphPackage[], index: VersionIndex): StalePackage[] { + const stale: StalePackage[] = []; + for (const pkg of graph) { + const versions = index.get(indexKey(pkg.ecosystem, pkg.name)); + if (!versions || versions.size === 0) continue; + if (versions.has(normalize(pkg.ecosystem, pkg.name, pkg.version).version)) continue; + stale.push({ ...pkg, lockfile: [...versions].sort() }); + } + return stale; +} diff --git a/src/github/status.ts b/src/github/status.ts new file mode 100644 index 0000000..8901311 --- /dev/null +++ b/src/github/status.ts @@ -0,0 +1,453 @@ +import { ApiError } from "../errors.js"; +import type { GitHubApi } from "./api.js"; +import { + type GraphPackage, + indexKey, + LOCKFILES, + readLockfile, + type StalePackage, + sbomPackages, + stalePackages, + type VersionIndex, +} from "./lockfiles.js"; + +/** A repository of the owner, as the repository list names it. */ +export interface RepoRef { + name: string; + defaultBranch: string; + fork: boolean; + archived: boolean; +} + +/** One part of a repository's status, or why it could not be read. */ +export type Part = T | { error: string }; + +export type CheckState = "success" | "failure" | "pending" | "none" | "unknown"; + +export interface PullRequest { + number: number; + author: string; + title: string; + checks: CheckState; +} + +export type Alerts = { state: "off" } | { state: "on"; total: number; bySeverity: Record }; + +export interface FailedWorkflow { + name: string; + conclusion: string; + /** When the run started; an old failure may be of a workflow that no longer runs on this branch. */ + date: string; + url: string; +} + +export type Ci = { state: "none" } | { state: "success" } | { state: "failure"; workflows: FailedWorkflow[] }; + +export type Release = + | { tag: null } + | { + tag: string; + /** Commits on the default branch since the tag. */ + commits: number; + /** How many of them release-please would release: feat, fix, perf, revert, or a breaking change. */ + releasable: number; + }; + +export type Graph = + | { state: "ok" } + | { state: "no-lockfile" } + | { state: "unchecked"; reason: string } + | { state: "stale"; packages: (StalePackage & { files: string[] })[] }; + +export interface RepoStatus { + repo: string; + defaultBranch: string; + pulls: Part; + alerts: Part; + ci: Part; + release: Part; + graph: Part; + /** Why the repository needs attention; empty when it is clean. */ + attention: string[]; +} + +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +function messageOf(data: unknown): string { + if (typeof data === "object" && data !== null && "message" in data) return String(data.message); + return typeof data === "string" && data ? data.slice(0, 200) : ""; +} + +async function getPage(api: GitHubApi, path: string): Promise<{ data: T; next?: string }> { + const { status, data, link } = await api.request("GET", path); + if (status >= 200 && status < 300) { + const next = link && /]+([^>]*)>;\s*rel="next"/.exec(link)?.[1]; + return { data: data as T, ...(next ? { next } : {}) }; + } + const message = messageOf(data); + if ((status === 403 || status === 429) && /rate limit/i.test(message)) throw new HttpError(status, "rate limited"); + throw new HttpError(status, `HTTP ${status}${message ? `: ${message}` : ""}`); +} + +async function get(api: GitHubApi, path: string): Promise { + return (await getPage(api, path)).data; +} + +/** Every page of a cursor-paginated list, following the `Link` header; stops after `maxPages`. */ +async function getCursor(api: GitHubApi, path: string, maxPages = 10): Promise { + const items: T[] = []; + let next: string | undefined = `${path}${path.includes("?") ? "&" : "?"}per_page=100`; + for (let page = 0; next !== undefined && page < maxPages; page++) { + const result: { data: T[]; next?: string } = await getPage(api, next); + if (!Array.isArray(result.data)) break; + items.push(...result.data); + next = result.next; + } + return items; +} + +/** Every page of a list endpoint, 100 at a time; stops after `maxPages`. */ +async function getAll(api: GitHubApi, path: string, maxPages = 10): Promise { + const items: T[] = []; + const separator = path.includes("?") ? "&" : "?"; + for (let page = 1; page <= maxPages; page++) { + const batch = await get(api, `${path}${separator}per_page=100&page=${page}`); + if (!Array.isArray(batch)) break; + items.push(...batch); + if (batch.length < 100) break; + } + return items; +} + +/** A branch or tag name inside a path: slashes stay, everything else is escaped. */ +const segment = (ref: string) => encodeURIComponent(ref).replace(/%2F/g, "/"); + +async function part(work: () => Promise): Promise> { + try { + return await work(); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +export const isError = (value: unknown): value is { error: string } => + typeof value === "object" && value !== null && "error" in value; + +interface RawRepo { + name: string; + default_branch?: string; + fork?: boolean; + archived?: boolean; + disabled?: boolean; + owner?: { login?: string }; +} + +/** + * The owner's repositories: through `/user/repos` when the token belongs to the owner, which includes the + * private ones, otherwise the organization's or the user's public list. + */ +export async function listRepos(api: GitHubApi, owner: string, login: string | undefined): Promise { + let raw: RawRepo[]; + try { + if (login !== undefined && login.toLowerCase() === owner.toLowerCase()) { + raw = await getAll(api, "/user/repos?affiliation=owner"); + } else { + try { + raw = await getAll(api, `/orgs/${segment(owner)}/repos?type=all`); + } catch (error) { + if (!(error instanceof HttpError) || error.status !== 404) throw error; + raw = await getAll(api, `/users/${segment(owner)}/repos?type=owner`); + } + } + } catch (error) { + throw new ApiError(`cannot list the repositories of ${owner}: ${(error as Error).message}`); + } + return raw + .filter((repo) => !repo.disabled && (repo.owner?.login ?? owner).toLowerCase() === owner.toLowerCase()) + .map((repo) => ({ + name: repo.name, + defaultBranch: repo.default_branch ?? "main", + fork: repo.fork === true, + archived: repo.archived === true, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +const FAILED = new Set(["failure", "timed_out", "action_required", "startup_failure"]); + +interface CheckRun { + status: string; + conclusion: string | null; +} + +async function checkState(api: GitHubApi, base: string, sha: string): Promise { + try { + const result = await get<{ total_count: number; check_runs: CheckRun[] }>( + api, + `${base}/commits/${sha}/check-runs?per_page=100`, + ); + const runs = result.check_runs ?? []; + if (runs.length === 0) return "none"; + if (runs.some((run) => run.conclusion !== null && FAILED.has(run.conclusion))) return "failure"; + if (runs.some((run) => run.status !== "completed")) return "pending"; + return "success"; + } catch { + return "unknown"; + } +} + +async function pulls(api: GitHubApi, base: string): Promise { + const open = await getAll<{ number: number; title: string; user?: { login?: string }; head: { sha: string } }>( + api, + `${base}/pulls?state=open`, + ); + return Promise.all( + open.map(async (pull) => ({ + number: pull.number, + author: pull.user?.login ?? "?", + title: pull.title, + checks: await checkState(api, base, pull.head.sha), + })), + ); +} + +const SEVERITIES = ["critical", "high", "medium", "low"]; + +async function alerts(api: GitHubApi, base: string): Promise { + let list: { security_advisory?: { severity?: string }; security_vulnerability?: { severity?: string } }[]; + try { + list = await getCursor(api, `${base}/dependabot/alerts?state=open`); + } catch (error) { + if (error instanceof HttpError && error.status === 403 && /disabled/i.test(error.message)) return { state: "off" }; + throw error; + } + const bySeverity: Record = {}; + for (const alert of list) { + const severity = alert.security_advisory?.severity ?? alert.security_vulnerability?.severity ?? "unknown"; + bySeverity[severity] = (bySeverity[severity] ?? 0) + 1; + } + const ordered: Record = {}; + for (const severity of [...SEVERITIES, ...Object.keys(bySeverity).filter((s) => !SEVERITIES.includes(s))]) { + if (bySeverity[severity]) ordered[severity] = bySeverity[severity]; + } + return { state: "on", total: list.length, bySeverity: ordered }; +} + +interface WorkflowRun { + name?: string; + path?: string; + event?: string; + conclusion?: string | null; + created_at?: string; + html_url?: string; +} + +/** + * The latest conclusive run of each active workflow on the default branch. Dependabot's update jobs and + * Pages deployments (event `dynamic`) and pull request runs are left out, and so are cancelled or skipped + * runs, which say nothing about the code. + */ +async function ci(api: GitHubApi, base: string, branch: string): Promise { + const workflows = await get<{ workflows?: { path: string; state: string }[] }>( + api, + `${base}/actions/workflows?per_page=100`, + ); + const active = new Set((workflows.workflows ?? []).filter((w) => w.state === "active").map((w) => w.path)); + if (active.size === 0) return { state: "none" }; + const { workflow_runs: runs = [] } = await get<{ workflow_runs?: WorkflowRun[] }>( + api, + `${base}/actions/runs?branch=${encodeURIComponent(branch)}&status=completed&exclude_pull_requests=true&per_page=100`, + ); + const latest = new Map(); + for (const run of runs) { + const path = (run.path ?? "").replace(/@.*$/, ""); + if (run.event === "dynamic" || run.event?.startsWith("pull_request") || !active.has(path)) continue; + if (run.conclusion !== "success" && !FAILED.has(run.conclusion ?? "")) continue; + if (!latest.has(path)) latest.set(path, run); + } + if (latest.size === 0) return { state: "none" }; + const failed = [...latest.values()].filter((run) => run.conclusion !== "success"); + if (failed.length === 0) return { state: "success" }; + return { + state: "failure", + workflows: failed.map((run) => ({ + name: run.name ?? run.path ?? "?", + conclusion: run.conclusion ?? "?", + date: (run.created_at ?? "").slice(0, 10), + url: run.html_url ?? "", + })), + }; +} + +const VERSION = /(\d+)\.(\d+)\.(\d+)/; + +function versionOf(tag: string): number[] | undefined { + const match = VERSION.exec(tag); + return match ? match.slice(1, 4).map(Number) : undefined; +} + +function compareParts(a: number[], b: number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** What release-please releases: feat, fix, perf and revert commits, and any breaking change. */ +export function isReleasable(message: string): boolean { + const [subject = ""] = message.split("\n"); + return ( + /^(feat|fix|perf|revert)(\([^)]*\))?!?:/.test(subject) || + /^\w+(\([^)]*\))?!:/.test(subject) || + /^BREAKING[ -]CHANGE:/m.test(message) + ); +} + +async function release(api: GitHubApi, base: string, branch: string): Promise { + let tag: string | undefined; + try { + tag = (await get<{ tag_name: string }>(api, `${base}/releases/latest`)).tag_name; + } catch (error) { + if (!(error instanceof HttpError) || error.status !== 404) throw error; + } + if (tag === undefined) { + let best: number[] | undefined; + for (const { name } of await getAll<{ name: string }>(api, `${base}/tags`, 3)) { + const version = versionOf(name); + if (!version) continue; + const newer = !best || compareParts(version, best) > 0; + if (newer) { + best = version; + tag = name; + } + } + } + if (tag === undefined) return { tag: null }; + const compare = await get<{ ahead_by: number; commits?: { commit: { message: string } }[] }>( + api, + `${base}/compare/${segment(tag)}...${segment(branch)}?per_page=100`, + ); + const releasable = (compare.commits ?? []).filter((c) => isReleasable(c.commit.message)).length; + return { tag, commits: compare.ahead_by, releasable }; +} + +const MAX_LOCKFILES = 30; + +async function graph(api: GitHubApi, base: string, branch: string): Promise { + let tree: { truncated?: boolean; tree?: { path: string; type: string; sha: string }[] }; + try { + tree = await get(api, `${base}/git/trees/${segment(branch)}?recursive=1`); + } catch (error) { + // 409: the repository is empty + if (error instanceof HttpError && (error.status === 409 || error.status === 404)) return { state: "no-lockfile" }; + throw error; + } + const lockfiles = (tree.tree ?? []).filter( + (entry) => + entry.type === "blob" && + !/(^|\/)node_modules\//.test(entry.path) && + entry.path.slice(entry.path.lastIndexOf("/") + 1) in LOCKFILES, + ); + if (lockfiles.length === 0) { + return tree.truncated ? { state: "unchecked", reason: "file tree too large" } : { state: "no-lockfile" }; + } + let sbom: unknown; + try { + sbom = await get(api, `${base}/dependency-graph/sbom`); + } catch (error) { + if (error instanceof HttpError && (error.status === 404 || error.status === 403)) { + return { state: "unchecked", reason: "dependency graph unavailable" }; + } + throw error; + } + const packages: GraphPackage[] = sbomPackages(sbom); + const ecosystems = new Set(packages.map((p) => p.ecosystem)); + const index: VersionIndex = new Map(); + const perFile = new Map(); + const unread: string[] = []; + for (const file of lockfiles.slice(0, MAX_LOCKFILES)) { + const name = file.path.slice(file.path.lastIndexOf("/") + 1); + const ecosystem = LOCKFILES[name]; + if (ecosystem === undefined || !ecosystems.has(ecosystem)) continue; + try { + const blob = await get<{ content: string; encoding: string }>(api, `${base}/git/blobs/${file.sha}`); + const text = Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf8").toString("utf8"); + const own: VersionIndex = new Map(); + readLockfile(file.path, text, own); + perFile.set(file.path, own); + for (const [key, versions] of own) { + const all = index.get(key) ?? new Set(); + for (const version of versions) all.add(version); + index.set(key, all); + } + } catch { + unread.push(file.path); + } + } + const stale = stalePackages(packages, index).map((pkg) => ({ + ...pkg, + files: [...perFile].filter(([, own]) => own.has(indexKey(pkg.ecosystem, pkg.name))).map(([path]) => path), + })); + if (stale.length > 0) return { state: "stale", packages: stale }; + if (unread.length > 0) return { state: "unchecked", reason: `could not read ${unread.join(", ")}` }; + return { state: "ok" }; +} + +/** Collects the status of one repository; a part that fails carries its error instead of stopping the rest. */ +export async function repoStatus(api: GitHubApi, owner: string, repo: RepoRef): Promise { + const base = `/repos/${segment(owner)}/${segment(repo.name)}`; + const branch = repo.defaultBranch; + const [pullList, alertState, ciState, releaseState, graphState] = await Promise.all([ + part(() => pulls(api, base)), + part(() => alerts(api, base)), + part(() => ci(api, base, branch)), + part(() => release(api, base, branch)), + part(() => graph(api, base, branch)), + ]); + const attention: string[] = []; + const failed = (name: string, value: unknown) => isError(value) && attention.push(`${name} unreadable`); + failed("pull requests", pullList); + failed("alerts", alertState); + failed("ci", ciState); + failed("release", releaseState); + failed("graph", graphState); + if (!isError(pullList) && pullList.length > 0) attention.push("open pull requests"); + if (!isError(alertState) && alertState.state === "on" && alertState.total > 0) attention.push("dependabot alerts"); + if (!isError(ciState) && ciState.state === "failure") attention.push("failing ci"); + if (!isError(releaseState) && releaseState.tag !== null && releaseState.releasable > 0) { + attention.push("unreleased changes"); + } + if (!isError(graphState) && graphState.state === "stale") attention.push("stale dependency graph"); + return { + repo: repo.name, + defaultBranch: branch, + pulls: pullList, + alerts: alertState, + ci: ciState, + release: releaseState, + graph: graphState, + attention, + }; +} + +/** Runs `work` on every item, at most `limit` at a time, keeping the order. */ +export async function mapLimit(items: T[], limit: number, work: (item: T) => Promise): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await work(items[index] as T); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} diff --git a/test/github-status.test.ts b/test/github-status.test.ts new file mode 100644 index 0000000..c3b012e --- /dev/null +++ b/test/github-status.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { run } from "../src/cli.js"; +import type { ApiResponse, GitHubApi } from "../src/github/api.js"; +import { readLockfile, sbomPackages, stalePackages, type VersionIndex } from "../src/github/lockfiles.js"; +import { isReleasable } from "../src/github/status.js"; +import { capture } from "./helpers.js"; + +type Routes = Record ApiResponse)>; + +const ok = (data: unknown): ApiResponse => ({ status: 200, data }); +const blob = (text: string) => ok({ content: Buffer.from(text).toString("base64"), encoding: "base64" }); +const purl = (locator: string) => ({ externalRefs: [{ referenceType: "purl", referenceLocator: locator }] }); +const sbom = (...locators: string[]) => ok({ sbom: { packages: locators.map(purl) } }); + +const PACKAGE_LOCK = JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "app" }, + "node_modules/vite": { version: "7.3.9" }, + "node_modules/@scope/lib": { version: "1.0.0" }, + "node_modules/other/node_modules/vite": { version: "6.0.0" }, + }, +}); + +/** A clean repository `a`: no pull requests, alerts, failures or lockfiles; released at v1.0.0. */ +function cleanRepo(name: string): Routes { + const base = `/repos/me/${name}`; + return { + [`${base}/pulls`]: ok([]), + [`${base}/dependabot/alerts`]: ok([]), + [`${base}/actions/workflows`]: ok({ workflows: [{ path: ".github/workflows/ci.yml", state: "active" }] }), + [`${base}/actions/runs`]: ok({ + workflow_runs: [{ name: "CI", path: ".github/workflows/ci.yml", event: "push", conclusion: "success" }], + }), + [`${base}/releases/latest`]: ok({ tag_name: "v1.0.0" }), + [`${base}/compare/v1.0.0...main`]: ok({ ahead_by: 0, commits: [] }), + [`${base}/git/trees/main`]: ok({ tree: [{ path: "README.md", type: "blob", sha: "r" }] }), + }; +} + +function fakeApi(routes: Routes, repos: unknown[]): { api: GitHubApi; calls: string[] } { + const calls: string[] = []; + const api: GitHubApi = { + async request(method, path) { + calls.push(`${method} ${path}`); + const [route = "", query = ""] = path.split("?"); + if (route === "/user") return ok({ login: "me" }); + if (route === "/user/repos") return ok(query.includes("page=1") ? repos : []); + const answer = routes[route]; + if (answer === undefined) return { status: 404, data: { message: "Not Found" } }; + return typeof answer === "function" ? answer(query) : answer; + }, + }; + return { api, calls }; +} + +const repo = (name: string, extra: Record = {}) => ({ + name, + default_branch: "main", + owner: { login: "me" }, + ...extra, +}); + +async function status(routes: Routes, repos: unknown[], args: string[] = []) { + const { api, calls } = fakeApi(routes, repos); + const c = capture(); + const code = await run(["github", "status", ...args], { ...c.io, githubApi: api }); + return { code, out: c.out.join("\n"), err: c.err.join("\n"), calls }; +} + +describe("repokeeper github status", () => { + it("prints only a count when every repository is clean, and skips archived repositories and forks", async () => { + const result = await status({ ...cleanRepo("a") }, [ + repo("a"), + repo("old", { archived: true }), + repo("copy", { fork: true }), + ]); + expect(result.code).toBe(0); + expect(result.out).toBe("all 1 repositories clean"); + expect(result.calls.some((call) => call.includes("/repos/me/old"))).toBe(false); + expect(result.calls.some((call) => call.includes("/repos/me/copy"))).toBe(false); + }); + + it("includes forks with --forks and lists every repository with --all", async () => { + const result = await status( + { ...cleanRepo("a"), ...cleanRepo("copy") }, + [repo("a"), repo("copy", { fork: true })], + ["--forks", "--all"], + ); + expect(result.out).toMatch(/^repo\s+prs\s+alerts\s+ci\s+unreleased\s+graph$/m); + expect(result.out).toMatch(/^copy\s+0\s+0\s+ok\s+0 since v1\.0\.0\s+-$/m); + expect(result.out).toContain("all 2 repositories clean"); + }); + + it("reports pull requests with their checks, alerts by severity and failing workflows", async () => { + const base = "/repos/me/a"; + const result = await status( + { + ...cleanRepo("a"), + [`${base}/pulls`]: ok([ + { number: 7, title: "chore(deps): bump vite", user: { login: "dependabot[bot]" }, head: { sha: "s1" } }, + ]), + [`${base}/commits/s1/check-runs`]: ok({ + total_count: 2, + check_runs: [ + { status: "completed", conclusion: "success" }, + { status: "completed", conclusion: "failure" }, + ], + }), + [`${base}/dependabot/alerts`]: ok([ + { security_advisory: { severity: "low" } }, + { security_advisory: { severity: "critical" } }, + { security_advisory: { severity: "low" } }, + ]), + [`${base}/actions/workflows`]: ok({ + workflows: [ + { path: ".github/workflows/ci.yml", state: "active" }, + { path: ".github/workflows/codeql.yml", state: "active" }, + ], + }), + [`${base}/actions/runs`]: ok({ + workflow_runs: [ + { + name: "Dependabot Updates", + path: "dynamic/dependabot/dependabot-updates", + event: "dynamic", + conclusion: "failure", + }, + { name: "CI", path: ".github/workflows/ci.yml", event: "push", conclusion: "cancelled" }, + { + name: "CI", + path: ".github/workflows/ci.yml", + event: "push", + conclusion: "failure", + created_at: "2026-10-01T00:00:00Z", + html_url: "https://x/1", + }, + { name: "codeql", path: ".github/workflows/codeql.yml", event: "schedule", conclusion: "success" }, + { name: "CI", path: ".github/workflows/ci.yml", event: "push", conclusion: "success" }, + ], + }), + }, + [repo("a")], + ["--exit-code"], + ); + expect(result.code).toBe(1); + expect(result.out).toMatch(/^a\s+1\s+1 critical, 2 low\s+failed\s+0 since v1\.0\.0\s+-$/m); + expect(result.out).toContain(" a#7 dependabot[bot] checks failure chore(deps): bump vite"); + expect(result.out).toContain(" a: CI failure on 2026-10-01 https://x/1"); + expect(result.out).toContain("0 of 1 repositories clean, 1 need attention"); + }); + + it("says off when Dependabot alerts are disabled and none when no workflow ran", async () => { + const base = "/repos/me/a"; + const result = await status( + { + ...cleanRepo("a"), + [`${base}/dependabot/alerts`]: { + status: 403, + data: { message: "Dependabot alerts are disabled for this repository." }, + }, + [`${base}/actions/runs`]: ok({ + workflow_runs: [ + { name: "pages", path: "dynamic/pages/pages-build-deployment", event: "dynamic", conclusion: "success" }, + ], + }), + }, + [repo("a")], + ["--all"], + ); + expect(result.out).toMatch(/^a\s+0\s+off\s+none\s+0 since v1\.0\.0\s+-$/m); + expect(result.out).not.toContain("running"); + }); + + it("counts commits since the latest tag and flags only releasable ones; no tags otherwise", async () => { + const commits = (...messages: string[]) => messages.map((message) => ({ commit: { message } })); + const chores = await status( + { + ...cleanRepo("a"), + "/repos/me/a/compare/v1.0.0...main": ok({ ahead_by: 2, commits: commits("chore(deps): bump x", "docs: y") }), + }, + [repo("a")], + ["--all"], + ); + expect(chores.out).toContain("2 since v1.0.0"); + expect(chores.out).toContain("all 1 repositories clean"); + + const routes: Routes = { ...cleanRepo("a") }; + delete routes["/repos/me/a/releases/latest"]; + routes["/repos/me/a/tags"] = ok([{ name: "v1.9.0" }, { name: "v1.10.0" }, { name: "nightly" }]); + routes["/repos/me/a/compare/v1.10.0...main"] = ok({ ahead_by: 1, commits: commits("feat: z") }); + const tagged = await status(routes, [repo("a")]); + expect(tagged.out).toContain("1 since v1.10.0 (1 feat/fix)"); + + routes["/repos/me/a/tags"] = ok([]); + expect((await status(routes, [repo("a")], ["--all"])).out).toContain("no tags"); + }); + + it("finds a dependency graph that still names versions the lockfiles no longer contain", async () => { + const base = "/repos/me/a"; + const result = await status( + { + ...cleanRepo("a"), + [`${base}/git/trees/main`]: ok({ + tree: [ + { path: "package-lock.json", type: "blob", sha: "lock" }, + { path: "web/packages.config", type: "blob", sha: "cfg" }, + { path: "node_modules/x/package-lock.json", type: "blob", sha: "ignored" }, + ], + }), + [`${base}/git/blobs/lock`]: blob(PACKAGE_LOCK), + [`${base}/git/blobs/cfg`]: blob( + '\n \n', + ), + [`${base}/dependency-graph/sbom`]: sbom( + "pkg:npm/vite@7.3.6", + "pkg:npm/vite@6.0.0", + "pkg:npm/zod@%5E3.25.76", + "pkg:npm/%40scope/lib@1.0.0", + "pkg:npm/gone@1.0.0", + "pkg:nuget/Bootstrap@3.0.0", + "pkg:githubactions/actions/checkout@4", + ), + }, + [repo("a")], + ["--exit-code"], + ); + expect(result.code).toBe(1); + expect(result.out).toContain("stale (2)"); + expect(result.out).toContain("a commit touching the manifest makes GitHub re-read it"); + expect(result.out).toContain(" a: npm vite 7.3.6 in the graph, 6.0.0, 7.3.9 in package-lock.json"); + expect(result.out).toContain(" a: nuget Bootstrap 3.0.0 in the graph, 3.4.1 in web/packages.config"); + expect(result.calls).not.toContain("GET /repos/me/a/git/blobs/ignored"); + }); + + it("keeps going when one repository fails, and reports it", async () => { + const result = await status( + { + ...cleanRepo("a"), + ...cleanRepo("b"), + "/repos/me/b/pulls": { status: 500, data: { message: "Server Error" } }, + "/repos/me/b/dependabot/alerts": { status: 403, data: { message: "API rate limit exceeded for user" } }, + }, + [repo("a"), repo("b")], + ); + expect(result.code).toBe(0); + expect(result.out).toContain(" b: pulls: HTTP 500: Server Error"); + expect(result.out).toContain(" b: alerts: rate limited"); + expect(result.out).toContain("rate limit was reached"); + expect(result.out).toContain("1 of 2 repositories clean, 1 need attention"); + }); + + it("prints JSON with every repository and follows the Link header of the alerts", async () => { + const page2 = [{ security_advisory: { severity: "high" } }]; + const routes: Routes = { + ...cleanRepo("a"), + "/repos/me/a/dependabot/alerts": (query) => + query.includes("after=c1") + ? ok(page2) + : { + status: 200, + data: [{ security_advisory: { severity: "high" } }], + link: '; rel="next"', + }, + }; + const result = await status(routes, [repo("a")], ["--json"]); + const report = JSON.parse(result.out); + expect(report.owner).toBe("me"); + expect(report.clean).toBe(0); + expect(report.repositories[0].alerts).toEqual({ state: "on", total: 2, bySeverity: { high: 2 } }); + expect(report.repositories[0].attention).toEqual(["dependabot alerts"]); + }); + + it("reads another owner's public repositories, and refuses its options on other commands", async () => { + const { api, calls } = fakeApi(cleanRepo("a"), []); + const users: GitHubApi = { + async request(method, path) { + if (path.startsWith("/users/other/repos")) return ok([repo("a", { owner: { login: "other" } })]); + return api.request(method, path.replace("/repos/other/", "/repos/me/")); + }, + }; + const c = capture(); + expect(await run(["github", "status", "--owner", "other"], { ...c.io, githubApi: users })).toBe(0); + expect(c.out).toEqual(["all 1 repositories clean"]); + expect(calls).toContain("GET /orgs/other/repos?type=all&per_page=100&page=1"); + expect(await run(["check", "--owner", "x"], capture().io)).toBe(2); + expect(await run(["github", "apply", "--exit-code"], capture().io)).toBe(2); + }); +}); + +describe("lockfiles and the dependency graph", () => { + it("reads every pnpm lockfile format, including pnpm 11's two documents", () => { + const index: VersionIndex = new Map(); + readLockfile( + "pnpm-lock.yaml", + "lockfileVersion: 5.4\npackages:\n /@s/a/1.0.0_react@18.0.0:\n dev: false\n", + index, + ); + readLockfile( + "v6/pnpm-lock.yaml", + "lockfileVersion: '6.0'\npackages:\n /b@2.0.0(react@18.0.0):\n dev: false\n", + index, + ); + readLockfile( + "v9/pnpm-lock.yaml", + "---\nlockfileVersion: '9.0'\nimporters: {}\n---\nlockfileVersion: '9.0'\npackages:\n '@s/c@3.0.0':\n resolution: {}\n", + index, + ); + expect(index).toEqual( + new Map([ + ["npm:@s/a", new Set(["1.0.0"])], + ["npm:b", new Set(["2.0.0"])], + ["npm:@s/c", new Set(["3.0.0"])], + ]), + ); + }); + + it("reads package-lock v1 and treats NuGet versions and ids loosely", () => { + const index: VersionIndex = new Map(); + readLockfile( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 1, + dependencies: { a: { version: "1.0.0", dependencies: { b: { version: "2.0.0" } } } }, + }), + index, + ); + readLockfile("packages.config", '', index); + const graph = sbomPackages({ + sbom: { packages: [purl("pkg:npm/b@2.0.0"), purl("pkg:nuget/pagedlist@1.17.0"), purl("pkg:npm/a@1.x")] }, + }); + expect(graph).toHaveLength(2); + expect(stalePackages(graph, index)).toEqual([]); + }); + + it("tells release-please's releasable commits from the rest", () => { + expect(isReleasable("fix(deps): bump x")).toBe(true); + expect(isReleasable("refactor!: drop y")).toBe(true); + expect(isReleasable("chore: z\n\nBREAKING CHANGE: gone")).toBe(true); + expect(isReleasable("chore(main): release 1.0.0")).toBe(false); + expect(isReleasable("Merge pull request #1 from x/y")).toBe(false); + }); +});