From 6eaba44ceb320b67494134dc03287db9a2a518c3 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Thu, 22 Oct 2026 22:59:41 +0700 Subject: [PATCH] feat: add github status, a report across an owner's repositories `repokeeper github status [--owner ]` reads every repository of a user or organization that is not archived (forks with --forks) and lists those that need attention: open pull requests with their checks, open Dependabot alerts by severity ("off" when disabled), the latest finished run of each workflow on the default branch, commits since the latest release tag, and a stale dependency graph. The graph check compares the npm and NuGet versions of GitHub's SBOM with package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml and packages.config on the default branch, and reports a package the graph still names at a version the lockfiles no longer contain; a commit touching the manifest makes GitHub re-read it. --all lists every repository, --json prints the whole result, --exit-code exits 1 when something needs attention. One repository failing or hitting the rate limit is reported and the rest carry on. --- README.md | 44 ++++ src/cli.ts | 29 ++- src/commands/report.ts | 8 + src/commands/status.ts | 139 ++++++++++++ src/github/api.ts | 5 +- src/github/lockfiles.ts | 176 ++++++++++++++ src/github/status.ts | 453 +++++++++++++++++++++++++++++++++++++ test/github-status.test.ts | 343 ++++++++++++++++++++++++++++ 8 files changed, 1194 insertions(+), 3 deletions(-) create mode 100644 src/commands/status.ts create mode 100644 src/github/lockfiles.ts create mode 100644 src/github/status.ts create mode 100644 test/github-status.test.ts 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); + }); +});