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
33 changes: 24 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,18 @@ reasonable start; check with `ollama ps` on that machine that the model is fully
`gitlib dependabot check` reports which open [Dependabot](https://docs.github.com/code-security/dependabot) pull
requests on your **starred** repositories can be merged and which need work. "Starred" is the Star toggle in gitlib
(`repo_favorites`), not GitHub stars, and only favorites with a github.com remote can be watched; the report says
how many were skipped and why. It is report-only: nothing is written to GitHub or to gitlib's database, no model is
involved, and the verdict comes only from fields GitHub returns (never from the PR text, which quotes third-party
release notes). The research and the reasoning behind it are in
[`docs/dependabot-watch.md`](docs/dependabot-watch.md).
how many were skipped and why. Nothing is ever written to GitHub, and the verdict comes only from fields GitHub
returns (never from the PR text, which quotes third-party release notes). The research and the reasoning behind it
are in [`docs/dependabot-watch.md`](docs/dependabot-watch.md).

```bash
export GITLIB_GITHUB_TOKEN=... # or GITHUB_TOKEN; GitHub's GraphQL API does not answer without one
gitlib dependabot check # grouped by bucket, with reasons and suggested actions
gitlib dependabot check # asks GitHub, saves the result, prints it grouped by bucket
gitlib dependabot check --json # the same, machine-readable
gitlib dependabot check --no-save # print only; nothing is stored and no lock is taken
gitlib dependabot show [--json] # the newest saved check, without contacting GitHub
gitlib dependabot dismiss meddlin/gitlib#41 [--until 2026-12-01] # put a PR aside
gitlib dependabot restore meddlin/gitlib#41
```

| Bucket | Meaning |
Expand All @@ -179,11 +182,20 @@ PRs, plus one small lookup that tells which watched repositories your token cann
few points of the 5,000 an hour; the report prints the actual cost. Anything it could not check is listed under
`NOT CHECKED` or `INCOMPLETE` rather than left out, and the command exits with status 1 if a whole owner failed.

**Saved runs.** Each check is stored in `gitlib.db` (`dependabot_runs`, `dependabot_findings`; the newest 60 runs
are kept) so the findings can be read offline and compared over time; they are derived from GitHub and can be fetched
again. If one owner cannot be asked (network, rate limit), the last findings for its repositories are kept, marked
`last seen <time>`, and the run is flagged `NOT CHECKED`, so a blip does not empty the list. A lock row in the same
database stops two checks (this command, the web page, a schedule) from running at once; it lapses after 15 minutes
if the holder crashed. **Putting a PR aside** is your decision, not derived data, so it is stored in `planning.db`
(and covered by `gitlib backup`): `dismiss` hides it until it closes or until the date you give, and reports leave it
out while counting it under `Put aside`.

**CI unknown.** A PR whose check results GitHub did not return is shown as `CI unknown` with the reason, filed under
*Review first*, and never as ready. GitHub's token guide lists the Checks API among the things fine-grained tokens
cannot do, and whether GraphQL `statusCheckRollup` works with one is undocumented, so with a fine-grained token you
may see `CI unknown` on every PR. [`docs/dependabot-watch.md`](docs/dependabot-watch.md#verify-with-your-own-token)
has the commands that settle it. Still to come: storing findings, a web page, a schedule and notifications.
has the commands that settle it.

## Planning

Expand Down Expand Up @@ -324,7 +336,10 @@ gitlib open code <repo>
gitlib open remote <repo>
gitlib tui
gitlib notes edit <repo>
gitlib dependabot check [--json]
gitlib dependabot check [--json] [--no-save]
gitlib dependabot show [--json]
gitlib dependabot dismiss <owner/repo#number> [--until DATE]
gitlib dependabot restore <owner/repo#number>
gitlib planning backup [path]
gitlib backup create [dir] [--keep N]
gitlib backup verify <archive>
Expand All @@ -344,8 +359,8 @@ Command summary:
- `gitlib open remote <repo>` opens the preferred configured remote in your browser.
- `gitlib tui` launches the interactive terminal UI.
- `gitlib notes edit <repo>` opens your configured editor to update repo notes.
- `gitlib dependabot check [--json]` reports which Dependabot PRs on starred repositories can be merged (see
[Dependabot watch](#dependabot-watch)).
- `gitlib dependabot check|show|dismiss|restore` reports which Dependabot PRs on starred repositories can be merged,
shows the last saved report, and puts PRs aside (see [Dependabot watch](#dependabot-watch)).
- `gitlib planning backup [path]` snapshots the planning database (see [Planning](#planning)).
- `gitlib backup create [dir]` writes a verified archive of everything gitlib stores that cannot be rebuilt
(config, notes, descriptions, favorites, planning) to `dir` (default `$GITLIB_BACKUP_DIR`, else
Expand Down
93 changes: 89 additions & 4 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {

export const VERSION = "0.1.0";

const TRIGGER_NAMES = { cli: "the CLI", web: "the web page", schedule: "the schedule" } as const;

export interface CliIo {
/** Merged over `process.env`, so callers only need to pass overrides. */
env?: Env;
Expand Down Expand Up @@ -62,7 +64,10 @@ type Invocation =
| { name: "open-code"; repo: string }
| { name: "open-remote"; repo: string }
| { name: "notes-edit"; repo: string }
| { name: "dependabot-check"; json?: boolean }
| { name: "dependabot-check"; json?: boolean; save: boolean }
| { name: "dependabot-show"; json?: boolean }
| { name: "dependabot-dismiss"; pr: DependabotPrRef; until?: string }
| { name: "dependabot-restore"; pr: DependabotPrRef }
| { name: "planning-backup"; destination?: string }
| { name: "backup-create"; directory?: string; keep?: number }
| { name: "backup-verify"; archive: string }
Expand All @@ -73,6 +78,18 @@ type BackupInvocation = Extract<Invocation, { name: `backup-${string}` }>;
const isBackupInvocation = (invocation: Invocation): invocation is BackupInvocation =>
invocation.name.startsWith("backup-");

interface DependabotPrRef {
repo: string;
number: number;
}

/** `owner/repo#123`, the way the report prints a pull request. */
function parsePrRef(value: string): DependabotPrRef {
const match = /^(?<repo>[^#\s]+\/[^#\s]+)#(?<number>\d+)$/.exec(value);
if (!match?.groups) throw new InvalidArgumentError("expected owner/repo#number, e.g. meddlin/gitlib#41");
return { repo: match.groups["repo"]!, number: Number(match.groups["number"]) };
}

function parsePositiveInt(value: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("must be a positive whole number");
Expand Down Expand Up @@ -178,7 +195,25 @@ function buildProgram(
.command("check")
.description("Report which open Dependabot PRs on starred repositories can be merged and which need work")
.option("--json", "machine-readable output")
.action((options: { json?: boolean }) => onInvoke({ name: "dependabot-check", json: options.json }));
.option("--no-save", "do not store the result as the newest run")
.action((options: { json?: boolean; save: boolean }) => onInvoke({ name: "dependabot-check", json: options.json, save: options.save }));
dependabot
.command("show")
.description("Show the newest stored Dependabot check without contacting GitHub")
.option("--json", "machine-readable output")
.action((options: { json?: boolean }) => onInvoke({ name: "dependabot-show", json: options.json }));

dependabot
.command("dismiss")
.description("Put a Dependabot PR aside so reports and the badge leave it out")
.argument("<pr>", "owner/repo#number, as printed in the report", parsePrRef)
.option("--until <date>", "bring it back on this date (e.g. 2026-12-01); default is until the PR closes")
.action((pr: DependabotPrRef, options: { until?: string }) => onInvoke({ name: "dependabot-dismiss", pr, until: options.until }));
dependabot
.command("restore")
.description("Bring a dismissed Dependabot PR back")
.argument("<pr>", "owner/repo#number", parsePrRef)
.action((pr: DependabotPrRef) => onInvoke({ name: "dependabot-restore", pr }));

const planning = program.command("planning").description("Manage gitlib's own planning data (issues)");
planning
Expand Down Expand Up @@ -356,11 +391,61 @@ async function execute(
return 0;
}
case "dependabot-check": {
const report = await app.checkDependabot();
print(invocation.json ? renderJson(dependabotReportToDict(report)) : renderDependabotReport(report, color));
const now = new Date();
const dismissals = app.listDependabotDismissals();
// A stored check also takes the lock that keeps the web button and the schedule from running at the same time.
const stored = invocation.save ? await app.runDependabotCheck({ trigger: "cli", now }) : null;
const report = stored?.report ?? (await app.checkDependabot({ now }));
const run = stored ? { id: stored.id, trigger: stored.trigger } : null;
print(
invocation.json
? renderJson(dependabotReportToDict(report, { run, dismissals, now }))
: renderDependabotReport(report, {
color,
dismissals,
now,
footer: stored ? [`Saved as run ${stored.id}; read it again with \`gitlib dependabot show\`.`] : [],
}),
);
// A report that could not cover every owner is not a clean bill of health, so a scheduler can notice.
return report.errors.length > 0 ? 1 : 0;
}
case "dependabot-show": {
const stored = app.latestDependabotRun();
if (!stored) {
printError("No Dependabot check has been saved yet. Run `gitlib dependabot check` first.");
return 1;
}
const now = new Date();
const dismissals = app.listDependabotDismissals();
const run = { id: stored.id, trigger: stored.trigger };
print(
invocation.json
? renderJson(dependabotReportToDict(stored.report, { run, dismissals, now }))
: renderDependabotReport(stored.report, {
color,
dismissals,
now,
header: [`Stored run ${stored.id}, started by ${TRIGGER_NAMES[stored.trigger]}; GitHub was not contacted.`, ""],
}),
);
return 0;
}
case "dependabot-dismiss": {
const { repo, number } = invocation.pr;
const dismissal = app.dismissDependabotPr(repo, number, invocation.until ?? null);
print(
dismissal.snoozedUntil
? `Put ${repo}#${number} aside until ${dismissal.snoozedUntil}.`
: `Put ${repo}#${number} aside until it closes. Bring it back with \`gitlib dependabot restore ${repo}#${number}\`.`,
);
return 0;
}
case "dependabot-restore": {
app.restoreDependabotPr(invocation.pr.repo, invocation.pr.number);
print(`${invocation.pr.repo}#${invocation.pr.number} is no longer put aside.`);
return 0;
}
case "planning-backup": {
print(`Backed up the planning database to ${app.backupPlanning(invocation.destination)}.`);
return 0;
Expand Down
111 changes: 74 additions & 37 deletions apps/cli/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
BUCKETS,
renderSegments,
splitDismissed,
type Bucket,
type CiState,
type ClassifiedPr,
type DependabotDismissal,
type DependabotReport,
type RepoRecord,
type SearchField,
Expand Down Expand Up @@ -135,8 +137,24 @@ function ciLabel(ci: CiState): string {
}
}

function renderPullRequest({ pr, reasons, actions }: ClassifiedPr): string[] {
/** A report as `check` produces it, or as the store gives it back (then each PR says when GitHub was last asked). */
export type ReportLike = Omit<DependabotReport, "items"> & { items: (ClassifiedPr & { observedAt?: string })[] };

export interface DependabotRenderOptions {
color?: boolean;
/** PRs the user put aside are left out of the buckets and counted instead. */
dismissals?: readonly DependabotDismissal[];
now?: Date;
/** Lines printed before the report, e.g. which stored run this is. */
header?: string[];
/** Lines printed after it. */
footer?: string[];
}

function renderPullRequest({ pr, reasons, actions, observedAt }: ClassifiedPr & { observedAt?: string }, checkedAt: string): string[] {
const facts = [pr.ecosystem, pr.updateType === "unknown" ? "update type unknown" : `${pr.updateType} update`, ciLabel(pr.ci)];
// A finding carried over from an earlier check says so, because GitHub could not be asked this time.
if (observedAt && observedAt !== checkedAt) facts.push(`last seen ${observedAt}`);
return [
` ${pr.repo}#${pr.number} ${pr.title}`,
` ${facts.filter(Boolean).join(" | ")}`,
Expand All @@ -148,28 +166,32 @@ function renderPullRequest({ pr, reasons, actions }: ClassifiedPr): string[] {

const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;

/** The text report of `dependabot check`: PRs grouped by bucket, then everything that was not or could not be checked. */
export function renderDependabotReport(report: DependabotReport, color = false): string {
const bold = (text: string) => (color ? `\u001b[1m${text}\u001b[22m` : text);
const lines: string[] = [];
/** The text report of `dependabot check` and `dependabot show`: PRs grouped by bucket, then everything not checked. */
export function renderDependabotReport(report: ReportLike, options: DependabotRenderOptions = {}): string {
const bold = (text: string) => (options.color ? `\u001b[1m${text}\u001b[22m` : text);
const { active: items, dismissed } = splitDismissed(report.items, options.dismissals ?? [], options.now ?? new Date());
const lines: string[] = [...(options.header ?? [])];
const checked = report.watched.filter((repo) => repo.status === "checked");

const counts = BUCKETS.map((bucket) => `${report.items.filter((item) => item.bucket === bucket).length} ${BUCKET_TITLES[bucket].toLowerCase()}`);
const counts = BUCKETS.map((bucket) => `${items.filter((item) => item.bucket === bucket).length} ${BUCKET_TITLES[bucket].toLowerCase()}`);
lines.push(
`Dependabot pull requests on ${plural(report.watched.length, "starred repository", "starred repositories")} (checked ${report.checkedAt})`,
);
lines.push(report.items.length > 0 ? counts.join(", ") : "No open Dependabot pull requests.");
lines.push(items.length > 0 ? counts.join(", ") : "No open Dependabot pull requests.");

for (const bucket of BUCKETS) {
const items = report.items.filter((item) => item.bucket === bucket);
if (items.length === 0) continue;
lines.push("", bold(`${BUCKET_TITLES[bucket].toUpperCase()} (${items.length})`));
for (const item of items) lines.push(...renderPullRequest(item));
const inBucket = items.filter((item) => item.bucket === bucket);
if (inBucket.length === 0) continue;
lines.push("", bold(`${BUCKET_TITLES[bucket].toUpperCase()} (${inBucket.length})`));
for (const item of inBucket) lines.push(...renderPullRequest(item, report.checkedAt));
}

const quiet = checked.filter((repo) => repo.openPrs === 0).map((repo) => repo.slug);
const notes: string[] = [];
if (quiet.length > 0 && report.items.length > 0) notes.push(`No open Dependabot pull requests: ${quiet.join(", ")}.`);
if (dismissed.length > 0) {
notes.push(`Put aside (${dismissed.length}): ${dismissed.map(({ pr }) => `${pr.repo}#${pr.number}`).join(", ")}.`);
}
if (report.watched.length === 0) {
notes.push("No starred repository has a GitHub remote, so there is nothing to check. Star repositories in the web app, then run `gitlib scan`.");
}
Expand All @@ -192,37 +214,52 @@ export function renderDependabotReport(report: DependabotReport, color = false):
notes.push(`GitHub quota: this check cost ${report.rateLimit.cost}, ${report.rateLimit.remaining} left until ${report.rateLimit.resetAt}.`);
}
if (notes.length > 0) lines.push("", ...notes);
if (options.footer?.length) lines.push("", ...options.footer);
return lines.join("\n");
}

/** The stable, snake_case JSON shape emitted by `dependabot check --json`. */
export function dependabotReportToDict(report: DependabotReport): Record<string, unknown> {
/** The stable, snake_case JSON shape emitted by `dependabot check --json` and `dependabot show --json`. */
export function dependabotReportToDict(
report: ReportLike,
options: { run?: { id: number; trigger: string } | null; dismissals?: readonly DependabotDismissal[]; now?: Date } = {},
): Record<string, unknown> {
const { active, dismissed } = splitDismissed(report.items, options.dismissals ?? [], options.now ?? new Date());
const entries = [
...active.map((item) => ({ item, dismissal: null })),
...dismissed.map(({ dismissal, ...item }) => ({ item, dismissal })),
];
return {
run: options.run ?? null,
checked_at: report.checkedAt,
summary: Object.fromEntries(BUCKETS.map((bucket) => [bucket, report.items.filter((item) => item.bucket === bucket).length])),
pull_requests: report.items.map(({ pr, bucket, reasons, actions }) => ({
repo: pr.repo,
number: pr.number,
title: pr.title,
url: pr.url,
bucket,
reasons,
actions,
update_type: pr.updateType,
ecosystem: pr.ecosystem,
dependencies: pr.dependencies,
branch: pr.headRefName,
author: pr.author,
is_draft: pr.isDraft,
mergeable: pr.mergeable,
merge_state_status: pr.mergeStateStatus,
review_decision: pr.reviewDecision,
labels: pr.labels,
commit_count: pr.commitCount,
ci: pr.ci,
created_at: pr.createdAt,
updated_at: pr.updatedAt,
})),
summary: Object.fromEntries(BUCKETS.map((bucket) => [bucket, active.filter((item) => item.bucket === bucket).length])),
pull_requests: entries.map(({ item, dismissal }) => {
const { pr, bucket, reasons, actions } = item;
return {
repo: pr.repo,
number: pr.number,
title: pr.title,
url: pr.url,
bucket,
reasons,
actions,
update_type: pr.updateType,
ecosystem: pr.ecosystem,
dependencies: pr.dependencies,
branch: pr.headRefName,
author: pr.author,
is_draft: pr.isDraft,
mergeable: pr.mergeable,
merge_state_status: pr.mergeStateStatus,
review_decision: pr.reviewDecision,
labels: pr.labels,
commit_count: pr.commitCount,
ci: pr.ci,
created_at: pr.createdAt,
updated_at: pr.updatedAt,
observed_at: item.observedAt ?? report.checkedAt,
dismissed: dismissal ? { snoozed_until: dismissal.snoozedUntil, created_at: dismissal.createdAt } : null,
};
}),
repositories: report.watched.map((repo) => ({
repo: repo.slug,
status: repo.status,
Expand Down
Loading
Loading