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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ GITLIB_OLLAMA_MODEL=
#GITLIB_OLLAMA_NUM_CTX=8192

# --- Integrations ------------------------------------------------------------------------------------
# Fine-grained token with read-only "Issues" access is enough. Not included in backups: keep it elsewhere.
# Fine-grained token with read-only "Issues" access is enough for the web app. `gitlib dependabot check` also reads
# pull requests and their checks (needs "Pull requests" read; whether a fine-grained token can read check results is
# unsettled, see README "Dependabot watch"). Not included in backups: keep it elsewhere.
GITLIB_GITHUB_TOKEN=
# How often (in hours) GitHub community scores are fetched again. They are stored in the database, so this is
# the only time they cost GitHub requests besides the Refresh buttons. 0 turns automatic refreshing off.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,41 @@ Ollama with `OLLAMA_HOST=0.0.0.0:11434` so it listens beyond loopback. Ollama ha
has none either, so keep both off the internet. On a 12 GB graphics card an 8B-class model with `num_ctx = 8192` is a
reasonable start; check with `ollama ps` on that machine that the model is fully on the GPU.

## Dependabot watch

`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).

```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 --json # the same, machine-readable
```

| Bucket | Meaning |
| --- | --- |
| **Ready** | Not a draft, mergeable, up to date, checks passing, no review blocking it, a patch or minor update. Merge it from its page. |
| **Review first** | Nothing is broken, but look first: a major bump, an update type the commit message does not state, a `security` label, a review that is required, extra commits on the branch, or **CI unknown** (below). |
| **Needs work** | Merge conflicts, behind the base branch, changes requested, or failing checks. Suggests `@dependabot rebase` or `@dependabot recreate` where they apply. |
| **Waiting** | Checks still running, a draft, or GitHub still calculating mergeability (`UNKNOWN`, which is transient; run the check again). |

The report only ever *suggests* comments for you to post; `@dependabot merge` and its siblings stopped working on
2026-01-27 and are never suggested. It asks GitHub once per owner (one GraphQL search for the owner's open Dependabot
PRs, plus one small lookup that tells which watched repositories your token cannot see at all), so a check costs a
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.

**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.

## Planning

The web app's **Planning** page (`/planning`) is a place to file and track work against any
Expand Down Expand Up @@ -289,6 +324,7 @@ gitlib open code <repo>
gitlib open remote <repo>
gitlib tui
gitlib notes edit <repo>
gitlib dependabot check [--json]
gitlib planning backup [path]
gitlib backup create [dir] [--keep N]
gitlib backup verify <archive>
Expand All @@ -308,6 +344,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 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
30 changes: 28 additions & 2 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@ import {
restoreBackup,
verifyBackup,
type BackupManifest,
type DependabotBackend,
type Env,
type GitLibApp,
type SearchBackend,
type SystemDeps,
} from "@gitlib/core";
import { Command, CommanderError, InvalidArgumentError } from "commander";
import { editNotes } from "./notes.js";
import { renderDetail, renderJson, renderSearchResults, renderTable, repoToDict, searchResultToDict } from "./render.js";
import {
dependabotReportToDict,
renderDependabotReport,
renderDetail,
renderJson,
renderSearchResults,
renderTable,
repoToDict,
searchResultToDict,
} from "./render.js";

export const VERSION = "0.1.0";

Expand All @@ -30,6 +40,8 @@ export interface CliIo {
system?: SystemDeps;
/** Replaces the Meilisearch backend; tests use this to avoid a real server. */
searchBackend?: SearchBackend | null;
/** Replaces the GitHub backend of `dependabot check`; tests use this to avoid the network. */
dependabotBackend?: DependabotBackend | null;
}

type Invocation =
Expand All @@ -50,6 +62,7 @@ type Invocation =
| { name: "open-code"; repo: string }
| { name: "open-remote"; repo: string }
| { name: "notes-edit"; repo: string }
| { name: "dependabot-check"; json?: boolean }
| { name: "planning-backup"; destination?: string }
| { name: "backup-create"; directory?: string; keep?: number }
| { name: "backup-verify"; archive: string }
Expand Down Expand Up @@ -160,6 +173,13 @@ function buildProgram(
.argument("<repo>")
.action((repo: string) => onInvoke({ name: "notes-edit", repo }));

const dependabot = program.command("dependabot").description("Watch Dependabot pull requests on your starred repositories");
dependabot
.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 }));

const planning = program.command("planning").description("Manage gitlib's own planning data (issues)");
planning
.command("backup")
Expand Down Expand Up @@ -335,6 +355,12 @@ async function execute(
}
return 0;
}
case "dependabot-check": {
const report = await app.checkDependabot();
print(invocation.json ? renderJson(dependabotReportToDict(report)) : renderDependabotReport(report, color));
// 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 "planning-backup": {
print(`Backed up the planning database to ${app.backupPlanning(invocation.destination)}.`);
return 0;
Expand Down Expand Up @@ -373,7 +399,7 @@ export async function main(argv: readonly string[], io: CliIo = {}): Promise<num

try {
if (isBackupInvocation(invocation)) return runBackupCommand(invocation, env, print);
const app = createApplication(env, io.system, io.searchBackend);
const app = createApplication(env, io.system, io.searchBackend, undefined, io.dependabotBackend);
const color = Boolean((stdout as { isTTY?: boolean }).isTTY) && !env["NO_COLOR"];
return await execute(invocation, app, env, print, printError, color);
} catch (error) {
Expand Down
138 changes: 137 additions & 1 deletion apps/cli/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { renderSegments, type RepoRecord, type SearchField, type SearchHit, type SearchResult } from "@gitlib/core";
import {
BUCKETS,
renderSegments,
type Bucket,
type CiState,
type ClassifiedPr,
type DependabotReport,
type RepoRecord,
type SearchField,
type SearchHit,
type SearchResult,
} from "@gitlib/core";

function notesPreview(notes: string, width = 50): string {
const collapsed = notes.split(/\s+/).filter(Boolean).join(" ");
Expand Down Expand Up @@ -102,3 +113,128 @@ export function searchResultToDict(query: string, result: SearchResult): Record<
})),
};
}

const BUCKET_TITLES: Record<Bucket, string> = {
ready: "Ready",
"review-first": "Review first",
"needs-work": "Needs work",
waiting: "Waiting",
};

/** One short phrase; the long reason for "unknown" is in the PR's reasons, not here. */
function ciLabel(ci: CiState): string {
switch (ci.state) {
case "success":
return "CI passing";
case "failing":
return ci.failed.length > 0 ? `CI failing (${ci.failed.join(", ")})` : "CI failing";
case "pending":
return ci.pending.length > 0 ? `CI pending (${ci.pending.join(", ")})` : "CI pending";
case "unknown":
return "CI unknown";
}
}

function renderPullRequest({ pr, reasons, actions }: ClassifiedPr): string[] {
const facts = [pr.ecosystem, pr.updateType === "unknown" ? "update type unknown" : `${pr.updateType} update`, ciLabel(pr.ci)];
return [
` ${pr.repo}#${pr.number} ${pr.title}`,
` ${facts.filter(Boolean).join(" | ")}`,
` ${pr.url}`,
...reasons.map((reason) => ` - ${reason}`),
...actions.map((action) => ` Next: ${action}`),
];
}

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[] = [];
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()}`);
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.");

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 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 (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`.");
}
if (report.skipped.length > 0) {
notes.push(
`Skipped ${plural(report.skipped.length, "starred repository", "starred repositories")} (no GitHub remote to ask about):`,
...report.skipped.map((repo) => ` ${repo.relativePath}: ${repo.reason}`),
);
}
const unreachable = report.watched.filter((repo) => repo.status === "unreachable").map((repo) => repo.slug);
if (unreachable.length > 0) {
notes.push(`NOT CHECKED: GitHub does not show these to your token (private without access, renamed or deleted): ${unreachable.join(", ")}.`);
}
for (const { owner, message } of report.errors) notes.push(`NOT CHECKED: ${owner}: ${message}`);
for (const owner of report.truncatedOwners) {
notes.push(`INCOMPLETE: ${owner} has more matching pull requests than GitHub search returns, so some are missing.`);
}
for (const warning of report.warnings) notes.push(`GitHub warning: ${warning}`);
if (report.rateLimit) {
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);
return lines.join("\n");
}

/** The stable, snake_case JSON shape emitted by `dependabot check --json`. */
export function dependabotReportToDict(report: DependabotReport): Record<string, unknown> {
return {
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,
})),
repositories: report.watched.map((repo) => ({
repo: repo.slug,
status: repo.status,
open_pull_requests: repo.openPrs,
paths: repo.paths,
})),
skipped: report.skipped.map((repo) => ({ relative_path: repo.relativePath, reason: repo.reason })),
errors: report.errors,
truncated_owners: report.truncatedOwners,
warnings: report.warnings,
rate_limit: report.rateLimit
? { cost: report.rateLimit.cost, remaining: report.rateLimit.remaining, reset_at: report.rateLimit.resetAt }
: null,
};
}
Loading
Loading