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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
29 changes: 27 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <version> write a release's version into the stack's version files (the GitLab release job runs it)",
Expand All @@ -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 <folder> write every reusable workflow into another repository's folder instead (eject)",
" --json machine-readable output (check)",
" --json machine-readable output (check, github status)",
" --owner <name> 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");
Expand All @@ -79,6 +86,10 @@ export async function run(argv: string[], io: Io): Promise<number> {
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 },
Expand Down Expand Up @@ -117,6 +128,11 @@ export async function run(argv: string[], io: Io): Promise<number> {
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,
Expand All @@ -131,6 +147,10 @@ export async function run(argv: string[], io: Io): Promise<number> {
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,
};
Expand All @@ -140,7 +160,12 @@ export async function run(argv: string[], io: Io): Promise<number> {
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 <name>]",
);
}
return await githubApplyCommand(io.cwd, options, io);
}
if (command === "gitlab") {
Expand Down
8 changes: 8 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export interface CommandOptions {
to?: string;
/** Folder of each stack given as `--stack id:folder` (init). */
stackDirectories?: Partial<Record<import("../config/types.js").StackId, string>>;
/** 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;
}
Expand Down
139 changes: 139 additions & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<number> {
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 <name>");
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;
}
5 changes: 4 additions & 1 deletion src/github/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 } : {}) };
},
};
}
Loading
Loading