diff --git a/.env.example b/.env.example index 711434b..c4c05bd 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 14f1e8f..627ba1d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -289,6 +324,7 @@ gitlib open code gitlib open remote gitlib tui gitlib notes edit +gitlib dependabot check [--json] gitlib planning backup [path] gitlib backup create [dir] [--keep N] gitlib backup verify @@ -308,6 +344,8 @@ Command summary: - `gitlib open remote ` opens the preferred configured remote in your browser. - `gitlib tui` launches the interactive terminal UI. - `gitlib notes edit ` 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 diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 57604d4..8d06582 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -11,6 +11,7 @@ import { restoreBackup, verifyBackup, type BackupManifest, + type DependabotBackend, type Env, type GitLibApp, type SearchBackend, @@ -18,7 +19,16 @@ import { } 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"; @@ -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 = @@ -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 } @@ -160,6 +173,13 @@ function buildProgram( .argument("") .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") @@ -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; @@ -373,7 +399,7 @@ export async function main(argv: readonly string[], io: CliIo = {}): Promise = { + 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 { + 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, + }; +} diff --git a/apps/cli/test/dependabot.test.ts b/apps/cli/test/dependabot.test.ts new file mode 100644 index 0000000..437c961 --- /dev/null +++ b/apps/cli/test/dependabot.test.ts @@ -0,0 +1,363 @@ +import { + DependabotAuthError, + DependabotUnavailableError, + createApplication, + type DependabotBackend, + type DependabotPr, + type OwnerPulls, +} from "@gitlib/core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Fixture } from "./fixture.js"; + +let fx: Fixture; +beforeEach(() => { + fx = new Fixture(); +}); +afterEach(() => fx.cleanup()); + +/** A clean, green patch update unless an override says otherwise. */ +function pr(overrides: Partial = {}): DependabotPr { + const number = overrides.number ?? 41; + const repo = overrides.repo ?? "meddlin/gitlib"; + return { + repo, + number, + title: "build(deps): bump undici from 6.27.0 to 6.29.1", + url: `https://github.com/${repo}/pull/${number}`, + author: "dependabot", + headRefName: "dependabot/npm_and_yarn/undici-6.29.1", + ecosystem: "npm_and_yarn", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: null, + labels: ["dependencies"], + commitCount: 1, + updateType: "patch", + dependencies: ["undici"], + ci: { state: "success" }, + createdAt: "2026-10-26T06:00:00Z", + updatedAt: "2026-10-30T06:00:00Z", + ...overrides, + }; +} + +class FakeBackend implements DependabotBackend { + readonly label = "fake"; + calls: string[] = []; + constructor(private readonly byOwner: Record | Error>) {} + + async fetchOwner(owner: string): Promise { + this.calls.push(owner); + const answer = this.byOwner[owner]; + if (answer instanceof Error) throw answer; + return { owner, prs: [], truncated: false, unreachable: [], warnings: [], rateLimit: null, ...answer }; + } +} + +/** Real repos with the given `origin` URLs, all starred. */ +function starRepos(remotes: Record, starred = Object.keys(remotes)): void { + fx.writeConfig(); + for (const [relativePath, url] of Object.entries(remotes)) { + const repoPath = fx.initRealGitRepo(relativePath); + if (url) fx.git(repoPath, "remote", "add", "origin", url); + } + const app = createApplication(fx.env()); + app.refresh(); + for (const relativePath of starred) app.saveRepositoryFavorite(relativePath, true); +} + +describe("gitlib dependabot check", () => { + it("groups the report by bucket, with reasons, suggestions and links", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git", web: "git@github.com:meddlin/web.git" }); + const backend = new FakeBackend({ + meddlin: { + prs: [ + pr({ number: 41 }), + pr({ number: 42, repo: "meddlin/web", title: "build(deps): bump left-pad from 1.0.0 to 2.0.0", updateType: "major" }), + pr({ number: 43, mergeStateStatus: "BEHIND" }), + pr({ number: 44, mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }), + ], + rateLimit: { cost: 3, remaining: 4997, resetAt: "2026-11-02T13:00:00Z" }, + }, + }); + + const result = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + + expect(result.code, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(backend.calls).toEqual(["meddlin"]); + expect(result.stdout.trimEnd().split("\n")).toEqual([ + expect.stringMatching(/^Dependabot pull requests on 2 starred repositories \(checked \d{4}-\d\d-\d\dT[\d:.]+Z\)$/), + "1 ready, 1 review first, 1 needs work, 1 waiting", + "", + "READY (1)", + " meddlin/gitlib#41 build(deps): bump undici from 6.27.0 to 6.29.1", + " npm_and_yarn | patch update | CI passing", + " https://github.com/meddlin/gitlib/pull/41", + " - All checks pass.", + " - No conflicts and up to date with the base branch.", + " - Patch update.", + " Next: Merge it from the PR page on GitHub.", + "", + "REVIEW FIRST (1)", + " meddlin/web#42 build(deps): bump left-pad from 1.0.0 to 2.0.0", + " npm_and_yarn | major update | CI passing", + " https://github.com/meddlin/web/pull/42", + " - Major version bump.", + " Next: Read the release notes and changelog linked in the PR before merging.", + " Next: If you do not want this update, comment `@dependabot ignore this major version`.", + "", + "NEEDS WORK (1)", + " meddlin/gitlib#43 build(deps): bump undici from 6.27.0 to 6.29.1", + " npm_and_yarn | patch update | CI passing", + " https://github.com/meddlin/gitlib/pull/43", + " - Behind the base branch.", + " Next: Comment `@dependabot rebase` on the PR to rebase it onto the base branch.", + " Next: If you do not want this update, comment `@dependabot ignore this patch version`.", + "", + "WAITING (1)", + " meddlin/gitlib#44 build(deps): bump undici from 6.27.0 to 6.29.1", + " npm_and_yarn | patch update | CI passing", + " https://github.com/meddlin/gitlib/pull/44", + " - GitHub is still working out whether it merges cleanly (mergeability UNKNOWN is transient).", + " Next: Run the check again in a minute or two.", + "", + "GitHub quota: this check cost 3, 4997 left until 2026-11-02T13:00:00Z.", + ]); + }); + + it("never suggests a retired Dependabot command", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const backend = new FakeBackend({ + meddlin: { + prs: [pr({ number: 1 }), pr({ number: 2, mergeable: "CONFLICTING" }), pr({ number: 3, updateType: "major" }), pr({ number: 4, isDraft: true })], + }, + }); + const { stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + expect(stdout).not.toMatch(/@dependabot\s+(merge|squash|cancel|close|reopen)/i); + expect(stdout).toContain("`@dependabot rebase`"); + expect(stdout).toContain("`@dependabot recreate`"); + }); + + it("says CI unknown, with the reason, and does not call the PR green", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const backend = new FakeBackend({ + meddlin: { + prs: [pr({ ci: { state: "unknown", reason: "GitHub would not return the checks (Resource not accessible by personal access token)." } })], + }, + }); + + const { code, stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + + expect(code).toBe(0); + expect(stdout).toContain("REVIEW FIRST (1)"); + expect(stdout).not.toContain("READY"); + expect(stdout).not.toContain("CI passing"); + expect(stdout).toContain("npm_and_yarn | patch update | CI unknown"); + expect(stdout).toContain("- CI unknown: GitHub would not return the checks (Resource not accessible by personal access token)."); + }); + + it("names failing and pending checks", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const backend = new FakeBackend({ + meddlin: { + prs: [ + pr({ number: 1, ci: { state: "failing", failed: ["lint", "test"] } }), + pr({ number: 2, ci: { state: "pending", pending: ["build"] } }), + ], + }, + }); + const { stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + expect(stdout).toContain("npm_and_yarn | patch update | CI failing (lint, test)"); + expect(stdout).toContain("- Checks failing: lint, test."); + expect(stdout).toContain("npm_and_yarn | patch update | CI pending (build)"); + expect(stdout).toContain("- Checks still running: build."); + }); + + it("says how many starred repositories were skipped, and which", async () => { + starRepos( + { + gitlib: "https://github.com/meddlin/gitlib.git", + gitlab: "https://gitlab.com/meddlin/thing.git", + lonely: null, + unstarred: "https://github.com/meddlin/unstarred.git", + }, + ["gitlib", "gitlab", "lonely"], + ); + const backend = new FakeBackend({ meddlin: { prs: [pr()] } }); + + const { code, stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + + expect(code).toBe(0); + // The unstarred repo is not asked about at all. + expect(backend.calls).toEqual(["meddlin"]); + expect(stdout).toContain("Skipped 2 starred repositories (no GitHub remote to ask about):"); + expect(stdout).toContain(" gitlab: no remote is on github.com (origin: gitlab.com)"); + expect(stdout).toContain(" lonely: it has no git remote"); + expect(stdout).not.toContain("unstarred"); + }); + + it("lists watched repositories that have nothing open", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git", quiet: "https://github.com/meddlin/quiet.git" }); + const backend = new FakeBackend({ meddlin: { prs: [pr()] } }); + const { stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + expect(stdout).toContain("No open Dependabot pull requests: meddlin/quiet."); + }); + + it("says plainly when there are no open PRs at all", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const { code, stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: new FakeBackend({ meddlin: {} }) }); + expect(code).toBe(0); + expect(stdout).toContain("No open Dependabot pull requests."); + }); + + it("says what to do when no starred repository has a GitHub remote", async () => { + starRepos({ gitlab: "https://gitlab.com/meddlin/thing.git" }); + const backend = new FakeBackend({}); + const { code, stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + expect(code).toBe(0); + expect(backend.calls).toEqual([]); + expect(stdout).toContain("No starred repository has a GitHub remote"); + expect(stdout).toContain(" gitlab: no remote is on github.com (origin: gitlab.com)"); + }); + + it("flags what it could not check, and exits 1 only when an owner failed", async () => { + starRepos({ + secret: "https://github.com/meddlin/secret.git", + big: "https://github.com/meddlin/big.git", + down: "https://github.com/downorg/down.git", + }); + const backend = new FakeBackend({ + meddlin: { unreachable: ["meddlin/secret"], truncated: true, warnings: ["labels exploded"] }, + downorg: new DependabotUnavailableError("Could not reach GitHub at api.github.com (ENOTFOUND)."), + }); + + const { code, stdout } = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + + expect(code).toBe(1); + expect(stdout).toContain("NOT CHECKED: GitHub does not show these to your token (private without access, renamed or deleted): meddlin/secret."); + expect(stdout).toContain("NOT CHECKED: downorg: Could not reach GitHub at api.github.com (ENOTFOUND)."); + expect(stdout).toContain("INCOMPLETE: meddlin has more matching pull requests than GitHub search returns, so some are missing."); + expect(stdout).toContain("GitHub warning: labels exploded"); + // An unreachable repository is not listed among the repositories with nothing open. + expect(stdout).not.toContain("No open Dependabot pull requests: meddlin/secret"); + + const withoutFailure = await fx.runCli(["dependabot", "check"], { dependabotBackend: new FakeBackend({ meddlin: { unreachable: ["meddlin/secret"] }, downorg: {} }) }); + expect(withoutFailure.code).toBe(0); + }); + + it("explains how to set a token when there is none, and stops", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const result = await fx.runCli(["dependabot", "check"], { dependabotBackend: null }); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.trim()).toBe("Checking Dependabot pull requests needs a GitHub token: set GITLIB_GITHUB_TOKEN or GITHUB_TOKEN."); + }); + + it("reports a rejected token as an error, not a stack trace", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const backend = new FakeBackend({ meddlin: new DependabotAuthError("GitHub rejected the token (401).") }); + const result = await fx.runCli(["dependabot", "check"], { dependabotBackend: backend }); + expect(result.code).toBe(1); + expect(result.stderr.trim()).toBe("GitHub rejected the token (401)."); + }); + + it("never prints the token", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const token = "test-token-not-a-real-secret"; + const backend = new FakeBackend({ meddlin: { prs: [pr()] } }); + for (const args of [["dependabot", "check"], ["dependabot", "check", "--json"]]) { + const result = await fx.runCli(args, { dependabotBackend: backend, env: { GITLIB_GITHUB_TOKEN: token } }); + expect(result.stdout + result.stderr).not.toContain(token); + } + }); + + it("does not need GitHub for any other command", async () => { + fx.writeConfig(); + fx.makeRepo("plain"); + const throwing: DependabotBackend = { + label: "never", + fetchOwner() { + throw new Error("GitHub must not be contacted"); + }, + }; + for (const args of [["list", "--json"], ["show", "plain"], ["scan"]]) { + const result = await fx.runCli(args, { dependabotBackend: throwing }); + expect(result.code, result.stderr).toBe(0); + } + expect((await fx.runCli(["list"], { env: { GITLIB_GITHUB_TOKEN: "", GITHUB_TOKEN: "" } })).code).toBe(0); + }); + + it("is listed in the help", async () => { + const help = await fx.runCli(["dependabot", "--help"]); + expect(help.stdout).toContain("check"); + expect((await fx.runCli(["--help"])).stdout).toContain("dependabot"); + }); + + describe("--json", () => { + it("emits a stable snake_case shape", async () => { + starRepos({ gitlib: "https://github.com/meddlin/gitlib.git", lonely: null }); + const backend = new FakeBackend({ + meddlin: { + prs: [ + pr({ number: 41 }), + pr({ number: 42, ci: { state: "unknown", reason: "no rollup" }, labels: ["dependencies", "security"] }), + pr({ number: 43, mergeStateStatus: "DIRTY", mergeable: "CONFLICTING" }), + ], + rateLimit: { cost: 2, remaining: 4998, resetAt: "2026-11-02T13:00:00Z" }, + }, + }); + + const result = await fx.runCli(["dependabot", "check", "--json"], { dependabotBackend: backend }); + + expect(result.code, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(Object.keys(report)).toEqual([ + "checked_at", + "summary", + "pull_requests", + "repositories", + "skipped", + "errors", + "truncated_owners", + "warnings", + "rate_limit", + ]); + expect(report.summary).toEqual({ ready: 1, "review-first": 1, "needs-work": 1, waiting: 0 }); + expect(report.pull_requests.map((p: { number: number; bucket: string }) => [p.number, p.bucket])).toEqual([ + [41, "ready"], + [42, "review-first"], + [43, "needs-work"], + ]); + expect(report.pull_requests[1]).toMatchObject({ + repo: "meddlin/gitlib", + url: "https://github.com/meddlin/gitlib/pull/42", + update_type: "patch", + ecosystem: "npm_and_yarn", + branch: "dependabot/npm_and_yarn/undici-6.29.1", + merge_state_status: "CLEAN", + review_decision: null, + is_draft: false, + commit_count: 1, + labels: ["dependencies", "security"], + ci: { state: "unknown", reason: "no rollup" }, + }); + expect(report.pull_requests[1].reasons[0]).toBe("CI unknown: no rollup"); + expect(report.pull_requests[2].actions.join("\n")).toContain("`@dependabot rebase`"); + expect(report.repositories).toEqual([{ repo: "meddlin/gitlib", status: "checked", open_pull_requests: 3, paths: ["gitlib"] }]); + expect(report.skipped).toEqual([{ relative_path: "lonely", reason: "it has no git remote" }]); + expect(report.rate_limit).toEqual({ cost: 2, remaining: 4998, reset_at: "2026-11-02T13:00:00Z" }); + }); + + it("still prints the report, and exits 1, when an owner failed", async () => { + starRepos({ down: "https://github.com/downorg/down.git" }); + const backend = new FakeBackend({ downorg: new DependabotUnavailableError("GitHub responded with 502.") }); + const result = await fx.runCli(["dependabot", "check", "--json"], { dependabotBackend: backend }); + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.errors).toEqual([{ owner: "downorg", message: "GitHub responded with 502." }]); + expect(report.repositories[0].status).toBe("failed"); + }); + }); +}); diff --git a/apps/cli/test/fixture.ts b/apps/cli/test/fixture.ts index 478fc51..cb64528 100644 --- a/apps/cli/test/fixture.ts +++ b/apps/cli/test/fixture.ts @@ -6,6 +6,7 @@ import { DEFAULT_IGNORE_DIRS, runCommand, type CommandResult, + type DependabotBackend, type SearchBackend, type SystemDeps, } from "@gitlib/core"; @@ -88,7 +89,12 @@ export class Fixture { async runCli( args: string[], - options: { env?: Record; system?: SystemDeps; searchBackend?: SearchBackend | null } = {}, + options: { + env?: Record; + system?: SystemDeps; + searchBackend?: SearchBackend | null; + dependabotBackend?: DependabotBackend | null; + } = {}, ): Promise<{ code: number; stdout: string; stderr: string }> { let stdout = ""; let stderr = ""; @@ -98,6 +104,7 @@ export class Fixture { stderr: { write: (chunk) => void (stderr += chunk) }, ...(options.system ? { system: options.system } : {}), ...(options.searchBackend !== undefined ? { searchBackend: options.searchBackend } : {}), + ...(options.dependabotBackend !== undefined ? { dependabotBackend: options.dependabotBackend } : {}), }); return { code, stdout, stderr }; } diff --git a/docs/dependabot-watch.md b/docs/dependabot-watch.md index 84c7369..24e41a6 100644 --- a/docs/dependabot-watch.md +++ b/docs/dependabot-watch.md @@ -4,6 +4,25 @@ Status: **research, with a proposed plan** (2026-11-02). Nothing is decided. The record what the primary sources say; "Fit with gitlib" is read from this repository's code and "Recommendation" is the author's proposal, so neither is sourced the way the rest is. +**Built so far** (2026-11-02): the report-only slice, Recommendation items 1, 2, 4 and 11 (b) and (c) without storage: +`gitlib dependabot check [--json]` (README, "Dependabot watch"), backed by `packages/core/src/dependabot/` (a pure +`classify`, a GraphQL fetch behind a `DependabotBackend` interface with a fake in the tests, and `checkDependabot`). +The verdict never reads the PR body. Choices the plan left open: + +- A PR whose `statusCheckRollup` is missing or refused is **review first** with "CI unknown" and the reason, never + ready (the Checks question in section 6 is still open, and an empty rollup is also what a repository without CI + looks like). Failing or `UNSTABLE` is needs work; pending, a draft and `UNKNOWN` mergeability are waiting; a review + that is required, a `security` label, extra commits, a major or unstated update type, or an unrecognised value from + GitHub are review first. +- Not built: the "Maintainer changes" phrase and Dependabot-alert matching (both need the body or the alerts API; + the `security` label stands in), `isRequired` per check, the retry of `UNKNOWN` mergeability, storage (item 7), + the web page and badge (11 d), the lease and schedule (items 5 and 6), and the LLM prose layer (item 8). +- Added to the plan: a lookup of which watched repositories the token can see, so a private repository the token + cannot reach is reported as `NOT CHECKED` instead of looking like "no open PRs". +- 11 (a) has **not** been run: the tests use fixtures shaped like the GraphQL response in "Verify with your own + token" command 5, written from the docs rather than captured from a real response. The first real run may show + differences (for example how a bot's `author.login` is spelled, which the code does not depend on). + ## Context The goal is to make it easier to stay current with Dependabot pull requests across many repositories, using a diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index f2de12c..3f28cb4 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -2,6 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import { buildChatSystemPrompt, OllamaBackend, type ChatBackend } from "./ai/index.js"; import { loadConfig, type AppConfig, type Env } from "./config.js"; +import { + checkDependabot, + DependabotUnavailableError, + GitHubDependabotBackend, + resolveGitHubToken, + type DependabotBackend, + type DependabotReport, +} from "./dependabot/index.js"; import { discoverRepos } from "./discovery.js"; import { GitLibError } from "./errors.js"; import type { DiscoveredRepo, RemoteTarget, RepoDetails, RepoRecord } from "./models.js"; @@ -19,7 +27,7 @@ import { type LocalIssueQuery, type LocalIssueState, } from "./planning/index.js"; -import { parseGitRemotes, selectPreferredRemote } from "./remotes.js"; +import { loadGitRemotes, selectPreferredRemote } from "./remotes.js"; import { scanRepo } from "./scanner.js"; import { RepoSearch, SearchUnavailableError, type SearchBackend, type SearchHit, type SearchResult } from "./search/index.js"; import { Store } from "./store.js"; @@ -51,26 +59,6 @@ export function buildNestedRepoMap(repositories: readonly DiscoveredRepo[]): Map const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); -export function loadGitRemotes( - repoPath: string, - system: SystemDeps = defaultSystemDeps, -): { remotes: RemoteTarget[]; error: string | null } { - let result; - try { - result = system.runCommand(["git", "-C", repoPath, "remote", "-v"]); - } catch (error) { - return { remotes: [], error: errorMessage(error) }; - } - - if (result.status !== 0) { - const message = - result.stderr.trim() || result.stdout.trim() || `git exited with status ${result.status}`; - return { remotes: [], error: message }; - } - - return { remotes: parseGitRemotes(result.stdout), error: null }; -} - export function loadRepoReadme(repoPath: string): { markdown: string | null; error: string | null } { const readmePath = path.join(repoPath, "README.md"); if (!fs.existsSync(readmePath)) return { markdown: null, error: null }; @@ -101,6 +89,8 @@ export class GitLibApp { readonly search: RepoSearch; /** The language-model server behind the chat panel; null when `ollama.enabled = false`. */ readonly chat: ChatBackend | null; + /** Where Dependabot pull requests come from; null when no GitHub token is set. */ + readonly dependabot: DependabotBackend | null; constructor( readonly config: AppConfig, @@ -109,9 +99,11 @@ export class GitLibApp { searchBackend?: SearchBackend | null, readonly planning: PlanningStore = new PlanningStore(config.planningDbPath), chatBackend?: ChatBackend | null, + dependabotBackend: DependabotBackend | null = null, ) { this.search = new RepoSearch(config.search, searchBackend); this.chat = chatBackend !== undefined ? chatBackend : config.ollama.enabled ? new OllamaBackend(config.ollama) : null; + this.dependabot = dependabotBackend; } refresh(): RepoRecord[] { @@ -216,6 +208,19 @@ export class GitLibApp { return buildChatSystemPrompt({ repositories: this.loadRepositories(), repo }); } + /** + * Asks GitHub for the open Dependabot pull requests of every favorite repository and sorts them into buckets. + * Report only: nothing is written to GitHub or to gitlib's data. Throws `DependabotUnavailableError` without a token. + */ + async checkDependabot(options: { signal?: AbortSignal; now?: Date } = {}): Promise { + if (!this.dependabot) { + throw new DependabotUnavailableError( + "Checking Dependabot pull requests needs a GitHub token: set GITLIB_GITHUB_TOKEN or GITHUB_TOKEN.", + ); + } + return checkDependabot({ repositories: this.loadRepositories(), backend: this.dependabot, system: this.system, ...options }); + } + listRepositories(options: FilterOptions & { refresh?: boolean } = {}): RepoRecord[] { const { refresh = true, ...filters } = options; const repositories = refresh ? this.refresh() : this.loadRepositories(); @@ -398,8 +403,11 @@ export function createApplication( system: SystemDeps = defaultSystemDeps, searchBackend?: SearchBackend | null, chatBackend?: ChatBackend | null, + dependabotBackend?: DependabotBackend | null, ): GitLibApp { const config = loadConfig(env); const store = new Store(config.dbPath); - return new GitLibApp(config, store, system, searchBackend, undefined, chatBackend); + const token = resolveGitHubToken(env); + const dependabot = dependabotBackend !== undefined ? dependabotBackend : token ? new GitHubDependabotBackend(token) : null; + return new GitLibApp(config, store, system, searchBackend, undefined, chatBackend, dependabot); } diff --git a/packages/core/src/dependabot/check.ts b/packages/core/src/dependabot/check.ts new file mode 100644 index 0000000..c9017ac --- /dev/null +++ b/packages/core/src/dependabot/check.ts @@ -0,0 +1,190 @@ +import type { RepoRecord } from "../models.js"; +import { loadGitRemotes, selectGitHubRepo, type GitHubRepoRef } from "../remotes.js"; +import { defaultSystemDeps, type SystemDeps } from "../system.js"; +import { classify } from "./classify.js"; +import { isValidOwner, isValidRepoName } from "./github-backend.js"; +import { + BUCKETS, + DependabotAuthError, + DependabotUnavailableError, + type ClassifiedPr, + type DependabotBackend, + type DependabotPr, + type RateLimitInfo, +} from "./types.js"; + +/** One GitHub repository and every local checkout (clone or linked worktree) that points at it. */ +export interface GitHubRepoGroup { + ref: GitHubRepoRef; + /** `owner/repo` as the first checkout spelled it. */ + slug: string; + /** `relativePath` of each checkout. */ + paths: string[]; +} + +/** A repository that could not be mapped to a GitHub slug, and why, in words fit for the report. */ +export interface UnresolvedRepo { + relativePath: string; + reason: string; +} + +/** + * The GitHub repositories behind `repos`, once each: linked worktrees share a remote with their main repo, so they + * collapse into one group, and the slug is compared without regard to case. Repos with no usable GitHub remote are + * returned separately with the reason, never dropped silently. + */ +export function groupReposByGitHubSlug( + repos: readonly RepoRecord[], + system: SystemDeps = defaultSystemDeps, +): { groups: GitHubRepoGroup[]; unresolved: UnresolvedRepo[] } { + const bySlug = new Map(); + const unresolved: UnresolvedRepo[] = []; + + for (const repo of repos) { + const skip = (reason: string) => void unresolved.push({ relativePath: repo.relativePath, reason }); + if (repo.isMissing) { + skip("its folder is missing"); + continue; + } + const { remotes, error } = loadGitRemotes(repo.absolutePath, system); + if (error) { + skip(`git could not list its remotes (${error.split("\n")[0]})`); + continue; + } + if (remotes.length === 0) { + skip("it has no git remote"); + continue; + } + const ref = selectGitHubRepo(remotes); + if (!ref) { + skip(`no remote is on github.com (${remotes.map((remote) => `${remote.name}: ${remote.host || "unknown host"}`).join(", ")})`); + continue; + } + if (!isValidOwner(ref.owner) || !isValidRepoName(ref.repo)) { + skip(`"${ref.owner}/${ref.repo}" is not a valid GitHub name`); + continue; + } + const key = `${ref.owner}/${ref.repo}`.toLowerCase(); + const group = bySlug.get(key) ?? { ref, slug: `${ref.owner}/${ref.repo}`, paths: [] }; + group.paths.push(repo.relativePath); + bySlug.set(key, group); + } + + return { groups: [...bySlug.values()].sort((a, b) => a.slug.localeCompare(b.slug)), unresolved }; +} + +export interface WatchedRepo extends GitHubRepoGroup { + /** + * "checked": GitHub was asked and could see it. "unreachable": the token cannot see it, so an empty list would be + * meaningless. "failed": the request for its owner failed (see the report's `errors`). + */ + status: "checked" | "unreachable" | "failed"; + openPrs: number; +} + +export interface DependabotReport { + /** ISO-8601 UTC time the check started. */ + checkedAt: string; + /** Every favorite with a GitHub remote, once per GitHub repository. */ + watched: WatchedRepo[]; + /** Favorites that were left out, with why. */ + skipped: UnresolvedRepo[]; + /** Open Dependabot PRs of the watched repositories, by bucket, then repo, then number. */ + items: ClassifiedPr[]; + /** Owners whose request failed; nothing about their repositories was checked. */ + errors: { owner: string; message: string }[]; + /** Owners with more matching PRs than GitHub search will return, so some are missing. */ + truncatedOwners: string[]; + /** Problems GitHub reported that did not stop the check. */ + warnings: string[]; + /** Points this check spent and what is left; null when GitHub did not say. */ + rateLimit: RateLimitInfo | null; +} + +export interface DependabotCheckOptions { + /** Every repository gitlib knows; only the favorites are watched. */ + repositories: readonly RepoRecord[]; + backend: DependabotBackend; + system?: SystemDeps; + now?: Date; + signal?: AbortSignal; +} + +/** + * Asks GitHub about the open Dependabot PRs of every favorite repository (one request set per owner, not per + * repository) and sorts them into buckets. Reports only; nothing is written to GitHub or to disk. + * + * A failed owner does not stop the others. A rejected token does, because every other owner would fail the same way. + */ +export async function checkDependabot(options: DependabotCheckOptions): Promise { + const { backend, signal } = options; + const { groups, unresolved } = groupReposByGitHubSlug( + options.repositories.filter((repo) => repo.isFavorite), + options.system, + ); + + const byOwner = new Map(); + for (const group of groups) { + const owner = group.ref.owner.toLowerCase(); + byOwner.set(owner, [...(byOwner.get(owner) ?? []), group]); + } + + const watched: WatchedRepo[] = []; + const prs: DependabotPr[] = []; + const errors: DependabotReport["errors"] = []; + const truncatedOwners: string[] = []; + const warnings: string[] = []; + const quota: { cost: number; last: RateLimitInfo | null } = { cost: 0, last: null }; + + for (const [, ownerGroups] of byOwner) { + const owner = ownerGroups[0]!.ref.owner; + let result; + try { + result = await backend.fetchOwner( + owner, + ownerGroups.map((group) => group.ref.repo), + signal, + ); + } catch (error) { + if (error instanceof DependabotAuthError || !(error instanceof DependabotUnavailableError)) throw error; + errors.push({ owner, message: error.message }); + watched.push(...ownerGroups.map((group): WatchedRepo => ({ ...group, status: "failed", openPrs: 0 }))); + continue; + } + + const unreachable = new Set(result.unreachable.map((slug) => slug.toLowerCase())); + const open = new Map(); + for (const pr of result.prs) open.set(pr.repo.toLowerCase(), [...(open.get(pr.repo.toLowerCase()) ?? []), pr]); + + for (const group of ownerGroups) { + const key = group.slug.toLowerCase(); + const found = open.get(key) ?? []; + prs.push(...found); + watched.push({ ...group, status: unreachable.has(key) ? "unreachable" : "checked", openPrs: found.length }); + } + if (result.truncated) truncatedOwners.push(owner); + warnings.push(...result.warnings); + if (result.rateLimit) { + quota.cost += result.rateLimit.cost; + quota.last = result.rateLimit; + } + } + + const items = prs + .map((pr): ClassifiedPr => ({ pr, ...classify(pr) })) + .sort( + (a, b) => + BUCKETS.indexOf(a.bucket) - BUCKETS.indexOf(b.bucket) || a.pr.repo.localeCompare(b.pr.repo) || a.pr.number - b.pr.number, + ); + + return { + checkedAt: (options.now ?? new Date()).toISOString(), + watched: watched.sort((a, b) => a.slug.localeCompare(b.slug)), + skipped: unresolved, + items, + errors, + truncatedOwners, + warnings: [...new Set(warnings)], + rateLimit: quota.last ? { ...quota.last, cost: quota.cost } : null, + }; +} diff --git a/packages/core/src/dependabot/classify.ts b/packages/core/src/dependabot/classify.ts new file mode 100644 index 0000000..ca76e18 --- /dev/null +++ b/packages/core/src/dependabot/classify.ts @@ -0,0 +1,156 @@ +import type { Classification, DependabotPr } from "./types.js"; + +/** + * The merge-or-not verdict. It reads only fields GitHub returns, never the PR body: that text quotes release notes + * written by whoever publishes the package, so it cannot be allowed to move a PR between buckets. + * + * Precedence is needs-work, then waiting, then review-first, then ready: a conflict has to be fixed whatever the + * checks are doing, a PR whose checks or mergeability are still settling should be looked at again rather than judged, + * and only a PR with nothing to say against it is ready. When in doubt it is not ready. + * + * Suggested actions are text for a person. `@dependabot merge` and its siblings stopped working on 2026-01-27, so + * none is ever suggested; the PR's link is what takes you to the merge button. + */ +export function classify(pr: DependabotPr): Classification { + const needsWork = new Findings(); + const waiting = new Findings(); + const review = new Findings(); + + const extraCommits = pr.commitCount > 1; + // Dependabot stops rebasing a branch someone else pushed to, so only `recreate` (which drops their commits) is left. + const fixBranch = extraCommits + ? "Dependabot no longer rebases a branch with extra commits: comment `@dependabot recreate` to rebuild it (this discards those commits), or rebase it yourself." + : null; + const rebase = fixBranch ?? "Comment `@dependabot rebase` on the PR to rebase it onto the base branch."; + + // --- Broken: someone has to do something before it can merge. + if (pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") { + needsWork.add( + "Merge conflicts with the base branch.", + fixBranch ?? "Comment `@dependabot rebase` on the PR; if the conflict is still there afterwards, comment `@dependabot recreate`.", + ); + } + if (pr.mergeStateStatus === "BEHIND") needsWork.add("Behind the base branch.", rebase); + if (pr.reviewDecision === "CHANGES_REQUESTED") { + needsWork.add("A reviewer requested changes.", "Open the PR and respond to the requested changes."); + } + if (pr.ci.state === "failing") { + needsWork.add( + pr.ci.failed.length > 0 ? `Checks failing: ${pr.ci.failed.join(", ")}.` : "Checks failing.", + "Open the failing check on the PR and read its log.", + ); + } else if (pr.mergeStateStatus === "UNSTABLE") { + needsWork.add( + "GitHub's merge state is UNSTABLE: a commit status is not passing.", + "Open the PR and look at its checks.", + ); + } + + // --- Not settled: look again later. + if (pr.isDraft || pr.mergeStateStatus === "DRAFT") { + waiting.add("Still a draft.", "Mark it ready for review on GitHub when you want it merged."); + } + if (pr.mergeable === "UNKNOWN" || pr.mergeStateStatus === "UNKNOWN") { + waiting.add( + "GitHub is still working out whether it merges cleanly (mergeability UNKNOWN is transient).", + "Run the check again in a minute or two.", + ); + } + if (pr.ci.state === "pending") { + waiting.add( + pr.ci.pending.length > 0 ? `Checks still running: ${pr.ci.pending.join(", ")}.` : "Checks still running.", + "Run the check again once they finish.", + ); + } + + // --- Clean as far as GitHub says, but a person should look first. + if (pr.ci.state === "unknown") { + review.add( + `CI unknown: ${pr.ci.reason}`, + "Open the PR and look at its checks yourself; gitlib cannot say whether they pass.", + ); + } + if (pr.reviewDecision === "REVIEW_REQUIRED") { + review.add("Branch protection asks for a review and none has approved it.", "Review and approve it on GitHub."); + } + const blockedIsExplained = + pr.reviewDecision === "REVIEW_REQUIRED" || pr.reviewDecision === "CHANGES_REQUESTED" || pr.ci.state === "pending" || pr.ci.state === "failing"; + if (pr.mergeStateStatus === "BLOCKED" && !blockedIsExplained) { + review.add( + "GitHub reports the merge as blocked (a required review or check, a ruleset, or a merge queue).", + "Open the PR: the merge box says what is missing.", + ); + } + if (pr.updateType === "major") { + review.add("Major version bump.", "Read the release notes and changelog linked in the PR before merging."); + } else if (pr.updateType === "unknown") { + review.add( + "The commit message does not say how big the bump is.", + "Read the title and the release notes linked in the PR.", + ); + } + if (pr.labels.some((label) => label.toLowerCase() === "security")) { + review.add( + "Labelled `security`: it addresses a vulnerability.", + "Look at the advisory linked in the PR, and do not leave it open longer than it needs to be.", + ); + } + if (extraCommits) { + review.add( + `Has ${pr.commitCount} commits; Dependabot opens a PR with one, so someone pushed to the branch.`, + "Check what was added before merging.", + ); + } + // A value this code has never seen must not slip through as ready. + const unrecognised: [field: string, value: string | null, known: readonly string[]][] = [ + ["mergeable", pr.mergeable, ["MERGEABLE", "CONFLICTING", "UNKNOWN"]], + ["mergeStateStatus", pr.mergeStateStatus, ["CLEAN", "UNSTABLE", "HAS_HOOKS", "BEHIND", "BLOCKED", "DIRTY", "DRAFT", "UNKNOWN"]], + ["reviewDecision", pr.reviewDecision, ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]], + ]; + for (const [field, value, known] of unrecognised) { + if (value !== null && !known.includes(value)) { + review.add(`GitHub reported a ${field} this version of gitlib does not recognise: ${value}.`, "Open the PR and look at its merge box."); + } + } + + const byBucket = { "needs-work": needsWork, waiting, "review-first": review } as const; + const bucket = (Object.keys(byBucket) as (keyof typeof byBucket)[]).find((name) => byBucket[name].reasons.length > 0); + if (!bucket) return { bucket: "ready", reasons: readyReasons(pr), actions: ["Merge it from the PR page on GitHub."] }; + + // The decisive bucket's findings come first, then whatever else was found, so nothing is hidden. + const findings = [byBucket[bucket], needsWork, waiting, review]; + const actions = unique(findings.flatMap((finding) => finding.actions)); + if ((bucket === "needs-work" || bucket === "review-first") && pr.dependencies.length <= 1) { + actions.push(`If you do not want this update, ${ignoreCommand(pr)}.`); + } + return { bucket, reasons: unique(findings.flatMap((finding) => finding.reasons)), actions }; +} + +function readyReasons(pr: DependabotPr): string[] { + const reasons = [ + "All checks pass.", + "No conflicts and up to date with the base branch.", + `${pr.updateType === "minor" ? "Minor" : "Patch"} update.`, + ]; + if (pr.reviewDecision === "APPROVED") reasons.push("Approved."); + if (pr.mergeStateStatus === "HAS_HOOKS") reasons.push("Pre-receive hooks will run when it merges."); + return reasons; +} + +function ignoreCommand(pr: DependabotPr): string { + const byType = { major: "this major version", minor: "this minor version", patch: "this patch version", unknown: "this dependency" } as const; + return `comment \`@dependabot ignore ${byType[pr.updateType]}\``; +} + +/** Reasons and the actions that go with them, kept in the order they were found. */ +class Findings { + readonly reasons: string[] = []; + readonly actions: string[] = []; + + add(reason: string, ...actions: string[]): void { + this.reasons.push(reason); + this.actions.push(...actions); + } +} + +const unique = (values: readonly T[]): T[] => [...new Set(values)]; diff --git a/packages/core/src/dependabot/github-backend.ts b/packages/core/src/dependabot/github-backend.ts new file mode 100644 index 0000000..f9fdc25 --- /dev/null +++ b/packages/core/src/dependabot/github-backend.ts @@ -0,0 +1,388 @@ +import type { Env } from "../config.js"; +import { parseBranchEcosystem, parseCommitMetadata } from "./metadata.js"; +import { + DependabotAuthError, + DependabotUnavailableError, + type CiState, + type DependabotBackend, + type DependabotPr, + type OwnerPulls, + type RateLimitInfo, +} from "./types.js"; + +export const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; +/** GitHub's guidance is to keep a request well under the 10 s it enforces, so pages stay small. */ +const PAGE_SIZE = 50; +/** GitHub search never returns more than 1,000 results, which is 20 pages of 50. */ +const MAX_PAGES = 20; +const PROBE_BATCH = 50; +const REQUEST_TIMEOUT_MS = 30_000; + +/** Fields documented in docs/dependabot-watch.md sections 2 and 6; the commit message carries the `update-type` block. */ +const SEARCH_QUERY = ` +query($q: String!, $first: Int!, $after: String) { + rateLimit { cost remaining resetAt } + search(query: $q, type: ISSUE, first: $first, after: $after) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + number + title + url + createdAt + updatedAt + headRefName + isDraft + mergeable + mergeStateStatus + reviewDecision + author { __typename login } + repository { nameWithOwner } + labels(first: 20) { nodes { name } } + commits(last: 1) { + totalCount + nodes { + commit { + message + statusCheckRollup { + state + contexts(first: 50) { + nodes { + __typename + ... on CheckRun { name status conclusion } + ... on StatusContext { context state } + } + } + } + } + } + } + } + } + } +}`; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const asString = (value: unknown): string | null => (typeof value === "string" ? value : null); +const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + +interface GraphQlError { + message: string; + type: string | null; + path: unknown[]; +} + +function readErrors(body: Record): GraphQlError[] { + return asArray(body["errors"]).flatMap((error) => + isRecord(error) + ? [{ message: asString(error["message"]) ?? "unspecified error", type: asString(error["type"]), path: asArray(error["path"]) }] + : [], + ); +} + +function readRateLimit(data: Record): RateLimitInfo | null { + const limit = data["rateLimit"]; + if (!isRecord(limit)) return null; + const { cost, remaining, resetAt } = limit; + return typeof cost === "number" && typeof remaining === "number" && typeof resetAt === "string" ? { cost, remaining, resetAt } : null; +} + +/** The `data` object of a GraphQL response, or a readable error when GitHub sent none. */ +function requireData(body: unknown): { data: Record; errors: GraphQlError[] } { + if (!isRecord(body)) throw new DependabotUnavailableError("GitHub sent a response that is not a GraphQL result."); + const errors = readErrors(body); + const data = body["data"]; + if (!isRecord(data)) { + const why = errors[0]?.message ?? asString(body["message"]) ?? "no data"; + throw new DependabotUnavailableError(`GitHub's GraphQL API returned no data (${why}).`); + } + return { data, errors }; +} + +const CHECK_FAILED = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE", "ACTION_REQUIRED"]); +const STATUS_FAILED = new Set(["FAILURE", "ERROR"]); +const STATUS_PENDING = new Set(["PENDING", "EXPECTED"]); + +const NO_ROLLUP = + "GitHub returned no check results for the head commit. A repository without CI looks like this, and so may a token " + + "that cannot read checks (see docs/dependabot-watch.md, \"Verify with your own token\")."; + +/** Names of the checks and statuses on a rollup that satisfy `pick`, in order and without repeats. */ +function contextNames(rollup: Record, pick: (kind: "check" | "status", context: Record) => boolean): string[] { + const contexts = rollup["contexts"]; + const nodes = isRecord(contexts) ? asArray(contexts["nodes"]) : []; + const names = nodes.flatMap((node) => { + if (!isRecord(node)) return []; + const kind = node["__typename"] === "StatusContext" ? "status" : node["__typename"] === "CheckRun" ? "check" : null; + const name = asString(kind === "status" ? node["context"] : node["name"]); + return kind && name && pick(kind, node) ? [name] : []; + }); + return [...new Set(names)]; +} + +/** `problem` is GitHub's own message when it refused to resolve the rollup field (GraphQL reports that as an error). */ +function parseCi(commits: unknown, problem: string | null): CiState { + const commit = isRecord(commits) ? asArray(commits["nodes"]).map((node) => (isRecord(node) ? node["commit"] : null)).find(isRecord) : undefined; + if (!commit) return { state: "unknown", reason: "GitHub returned no head commit for the pull request." }; + + const rollup = commit["statusCheckRollup"]; + if (!isRecord(rollup)) { + return { state: "unknown", reason: problem ? `GitHub would not return the checks (${problem}).` : NO_ROLLUP }; + } + switch (rollup["state"]) { + case "SUCCESS": + return { state: "success" }; + case "FAILURE": + case "ERROR": + return { + state: "failing", + failed: contextNames(rollup, (kind, context) => (kind === "check" ? CHECK_FAILED.has(String(context["conclusion"])) : STATUS_FAILED.has(String(context["state"])))), + }; + case "PENDING": + case "EXPECTED": + return { + state: "pending", + pending: contextNames(rollup, (kind, context) => (kind === "check" ? context["status"] !== "COMPLETED" : STATUS_PENDING.has(String(context["state"])))), + }; + default: + return { state: "unknown", reason: `GitHub reported a check state this version of gitlib does not recognise: ${String(rollup["state"])}.` }; + } +} + +/** One element of `search.nodes`; null when it is not a pull request (an inline fragment that did not match gives `{}`). */ +export function parsePullRequest(node: unknown, ciProblem: string | null = null): DependabotPr | null { + if (!isRecord(node)) return null; + const number = node["number"]; + const repository = isRecord(node["repository"]) ? asString(node["repository"]["nameWithOwner"]) : null; + if (typeof number !== "number" || !repository) return null; + + const headRefName = asString(node["headRefName"]) ?? ""; + const commits = isRecord(node["commits"]) ? node["commits"] : {}; + const lastCommit = asArray(commits["nodes"]).map((entry) => (isRecord(entry) ? entry["commit"] : null)).find(isRecord); + const metadata = parseCommitMetadata(asString(lastCommit?.["message"]) ?? ""); + const labels = (isRecord(node["labels"]) ? asArray(node["labels"]["nodes"]) : []).flatMap((label) => { + const name = isRecord(label) ? asString(label["name"]) : null; + return name ? [name] : []; + }); + + return { + repo: repository, + number, + title: asString(node["title"]) ?? "", + url: asString(node["url"]) ?? `https://github.com/${repository}/pull/${number}`, + author: isRecord(node["author"]) ? asString(node["author"]["login"]) : null, + headRefName, + ecosystem: parseBranchEcosystem(headRefName), + isDraft: node["isDraft"] === true, + mergeable: asString(node["mergeable"]) ?? "UNKNOWN", + mergeStateStatus: asString(node["mergeStateStatus"]) ?? "UNKNOWN", + reviewDecision: asString(node["reviewDecision"]), + labels, + commitCount: typeof commits["totalCount"] === "number" ? commits["totalCount"] : 1, + updateType: metadata.updateType, + dependencies: metadata.dependencies, + ci: parseCi(commits, ciProblem), + createdAt: asString(node["createdAt"]), + updatedAt: asString(node["updatedAt"]), + }; +} + +export interface SearchPage { + prs: DependabotPr[]; + /** How many results GitHub says match in total, across all pages. */ + issueCount: number; + /** How many nodes this page held. */ + nodeCount: number; + hasNextPage: boolean; + endCursor: string | null; + rateLimit: RateLimitInfo | null; + warnings: string[]; +} + +/** One page of the search query's response. Problems with a single PR's checks become that PR's "CI unknown". */ +export function parseSearchPage(body: unknown): SearchPage { + const { data, errors } = requireData(body); + const search = data["search"]; + if (!isRecord(search)) { + throw new DependabotUnavailableError(`GitHub's GraphQL API returned no search results (${errors[0]?.message ?? "no error given"}).`); + } + + // A field GitHub refuses to resolve comes back as null plus an error whose path names it. + const rollupProblems = new Map(); + const warnings: string[] = []; + for (const error of errors) { + const [root, list, index] = error.path; + if (root === "search" && list === "nodes" && typeof index === "number" && error.path.includes("statusCheckRollup")) { + rollupProblems.set(index, error.message); + } else { + warnings.push(error.message); + } + } + + const nodes = asArray(search["nodes"]); + const pageInfo = isRecord(search["pageInfo"]) ? search["pageInfo"] : {}; + return { + prs: nodes.flatMap((node, index) => parsePullRequest(node, rollupProblems.get(index) ?? null) ?? []), + issueCount: typeof search["issueCount"] === "number" ? search["issueCount"] : nodes.length, + nodeCount: nodes.length, + hasNextPage: pageInfo["hasNextPage"] === true, + endCursor: asString(pageInfo["endCursor"]), + rateLimit: readRateLimit(data), + warnings, + }; +} + +/** `owner/repo` of every repository the aliased `repository(...)` lookups could see. */ +export function parseVisibleRepositories(body: unknown): { visible: Set; rateLimit: RateLimitInfo | null } { + const { data } = requireData(body); + const visible = new Set(); + for (const [alias, value] of Object.entries(data)) { + if (/^r\d+$/.test(alias) && isRecord(value)) { + const name = asString(value["nameWithOwner"]); + if (name) visible.add(name.toLowerCase()); + } + } + return { visible, rateLimit: readRateLimit(data) }; +} + +/** The token the web app uses too: `GITLIB_GITHUB_TOKEN`, else `GITHUB_TOKEN`; null when neither is set. */ +export function resolveGitHubToken(env: Env): string | null { + return env["GITLIB_GITHUB_TOKEN"]?.trim() || env["GITHUB_TOKEN"]?.trim() || null; +} + +/** GitHub user and organisation logins; checked before one goes into a search query. */ +export const isValidOwner = (owner: string): boolean => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner); +/** GitHub repository names. `.` and `..` are not allowed. */ +export const isValidRepoName = (name: string): boolean => /^[A-Za-z0-9._-]{1,100}$/.test(name) && name !== "." && name !== ".."; + +/** Why a `fetch` failed, in words: undici hides the useful part (ECONNREFUSED, ENOTFOUND, ...) in `cause`. */ +function describeFetchError(error: unknown): string { + const cause = error instanceof Error ? (error.cause as { code?: unknown; message?: unknown } | undefined) : undefined; + if (typeof cause?.code === "string") return cause.code; + if (typeof cause?.message === "string") return cause.message; + return error instanceof Error ? error.message : String(error); +} + +export interface GitHubDependabotBackendOptions { + fetch?: typeof fetch; + endpoint?: string; + timeoutMs?: number; +} + +/** + * Reads Dependabot PRs from GitHub's GraphQL API: one paginated search per owner, plus one small request that + * asks which of the owner's watched repositories the token can see at all. + */ +export class GitHubDependabotBackend implements DependabotBackend { + readonly label: string; + private readonly fetchImpl: typeof fetch; + private readonly endpoint: string; + private readonly timeoutMs: number; + + /** `token` is sent as a bearer token and appears in no message or error this class produces. */ + constructor(private readonly token: string, options: GitHubDependabotBackendOptions = {}) { + this.fetchImpl = options.fetch ?? fetch; + this.endpoint = options.endpoint ?? GITHUB_GRAPHQL_URL; + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + this.label = new URL(this.endpoint).host; + } + + async fetchOwner(owner: string, repoNames: readonly string[], signal?: AbortSignal): Promise { + if (!isValidOwner(owner)) throw new DependabotUnavailableError(`"${owner}" is not a valid GitHub owner name.`); + + // Each response reports its own cost; the owner's total is their sum, and what is left is the latest figure. + const spent: { cost: number; latest: RateLimitInfo | null } = { cost: 0, latest: null }; + const track = (rateLimit: RateLimitInfo | null) => { + if (!rateLimit) return; + spent.cost += rateLimit.cost; + spent.latest = rateLimit; + }; + + const unreachable: string[] = []; + const names = repoNames.filter(isValidRepoName); + for (let start = 0; start < names.length; start += PROBE_BATCH) { + const batch = names.slice(start, start + PROBE_BATCH); + const lookups = batch.map((name, index) => `r${index}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { nameWithOwner }`); + const { visible, rateLimit } = parseVisibleRepositories( + await this.post(`query { rateLimit { cost remaining resetAt } ${lookups.join(" ")} }`, {}, signal), + ); + track(rateLimit); + for (const name of batch) if (!visible.has(`${owner}/${name}`.toLowerCase())) unreachable.push(`${owner}/${name}`); + } + + const query = `is:pr is:open author:app/dependabot user:${owner} archived:false`; + const prs: DependabotPr[] = []; + const warnings: string[] = []; + let fetched = 0; + let total = 0; + let more = false; + let after: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const result = parseSearchPage(await this.post(SEARCH_QUERY, { q: query, first: PAGE_SIZE, after }, signal)); + prs.push(...result.prs); + warnings.push(...result.warnings); + track(result.rateLimit); + fetched += result.nodeCount; + total = result.issueCount; + more = result.hasNextPage && result.endCursor !== null; + if (!more) break; + after = result.endCursor; + } + + return { + owner, + prs, + truncated: more || total > fetched, + unreachable, + warnings: [...new Set(warnings)], + rateLimit: spent.latest ? { ...spent.latest, cost: spent.cost } : null, + }; + } + + private async post(query: string, variables: Record, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(this.timeoutMs); + let response: Response; + try { + response = await this.fetchImpl(this.endpoint, { + method: "POST", + headers: { + authorization: `Bearer ${this.token}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "gitlib", + }, + body: JSON.stringify({ query, variables }), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + } catch (error) { + if (signal?.aborted) throw error; + const why = timeout.aborted ? `no answer within ${this.timeoutMs / 1000} seconds` : describeFetchError(error); + throw new DependabotUnavailableError(`Could not reach GitHub at ${this.label} (${why}).`, { cause: error }); + } + + if (response.status === 401) { + throw new DependabotAuthError("GitHub rejected the token (401). Check GITLIB_GITHUB_TOKEN or GITHUB_TOKEN: it may be mistyped, expired or revoked."); + } + if (response.status === 403 || response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + const reset = Number(response.headers.get("x-ratelimit-reset")); + const exhausted = response.headers.get("x-ratelimit-remaining") === "0" && Number.isFinite(reset); + throw new DependabotUnavailableError( + retryAfter + ? `GitHub asked for a pause (rate limit); try again in ${retryAfter} seconds.` + : exhausted + ? `GitHub rate limit reached; it resets at ${new Date(reset * 1000).toISOString()}.` + : `GitHub refused the request (${response.status}). The token may lack access, or a rate limit applies.`, + ); + } + if (!response.ok) throw new DependabotUnavailableError(`GitHub responded with ${response.status}.`); + + try { + return await response.json(); + } catch (error) { + throw new DependabotUnavailableError("GitHub sent a response that is not JSON.", { cause: error }); + } + } +} diff --git a/packages/core/src/dependabot/index.ts b/packages/core/src/dependabot/index.ts new file mode 100644 index 0000000..37c70a8 --- /dev/null +++ b/packages/core/src/dependabot/index.ts @@ -0,0 +1,5 @@ +export * from "./check.js"; +export * from "./classify.js"; +export * from "./github-backend.js"; +export * from "./metadata.js"; +export * from "./types.js"; diff --git a/packages/core/src/dependabot/metadata.ts b/packages/core/src/dependabot/metadata.ts new file mode 100644 index 0000000..3a66713 --- /dev/null +++ b/packages/core/src/dependabot/metadata.ts @@ -0,0 +1,56 @@ +import type { UpdateType } from "./types.js"; + +/** + * Dependabot ends its commit message with a YAML block between `---` and `...`, one list item per updated dependency. + * No docs page describes it as a supported format, so everything here reads it defensively and answers "unknown" + * rather than guessing; see docs/dependabot-watch.md section 3. + */ +const BLOCK = /^---[ \t]*\r?\n([\s\S]*?)\r?\n\.\.\.[ \t]*$/m; + +const SEMVER_LEVELS: Record> = { + "version-update:semver-major": "major", + "version-update:semver-minor": "minor", + "version-update:semver-patch": "patch", +}; + +const unquote = (value: string): string => value.trim().replace(/^(["'])(.*)\1$/, "$2"); + +export interface CommitMetadata { + updateType: UpdateType; + /** `dependency-name` of every list item, in order. */ + dependencies: string[]; +} + +/** Reads the `update-type` of every dependency in a Dependabot commit message and keeps the biggest bump. */ +export function parseCommitMetadata(message: string): CommitMetadata { + const block = BLOCK.exec(message)?.[1]; + if (block === undefined) return { updateType: "unknown", dependencies: [] }; + + // `- dependency-name: x` opens an item; the indented lines after it belong to it. + const items: { name: string | null; updateType: string | null }[] = []; + for (const line of block.split(/\r?\n/)) { + const entry = /^\s*(?-\s+)?(?[\w-]+):\s*(?.*)$/.exec(line); + if (!entry?.groups) continue; + const { opens, key, value } = entry.groups as { opens?: string; key: string; value: string }; + if (opens) items.push({ name: null, updateType: null }); + const item = items[items.length - 1]; + if (!item) continue; + if (key === "dependency-name") item.name = unquote(value); + else if (key === "update-type") item.updateType = unquote(value); + } + + const dependencies = items.flatMap((item) => (item.name ? [item.name] : [])); + if (items.length === 0) return { updateType: "unknown", dependencies }; + + const levels = items.map((item) => (item.updateType ? (SEMVER_LEVELS[item.updateType] ?? null) : null)); + if (levels.includes("major")) return { updateType: "major", dependencies }; + // One dependency without a usable level could be anything, so the group cannot be called minor or patch. + if (levels.includes(null)) return { updateType: "unknown", dependencies }; + return { updateType: levels.includes("minor") ? "minor" : "patch", dependencies }; +} + +/** `npm_and_yarn` from `dependabot/npm_and_yarn/undici-6.29.1`; null for a custom prefix or separator. */ +export function parseBranchEcosystem(headRefName: string): string | null { + const segments = headRefName.split("/"); + return segments.length >= 3 && segments[0] === "dependabot" && segments[1] ? segments[1] : null; +} diff --git a/packages/core/src/dependabot/types.ts b/packages/core/src/dependabot/types.ts new file mode 100644 index 0000000..f8c9bc3 --- /dev/null +++ b/packages/core/src/dependabot/types.ts @@ -0,0 +1,102 @@ +import { GitLibError } from "../errors.js"; + +/** + * How big a bump is, from the `update-type` lines in Dependabot's commit message. "unknown" covers a missing block, + * a missing line (observed on indirect updates), a value outside semver-major/minor/patch, and a group in which any + * dependency lacks one without another being major. + */ +export type UpdateType = "major" | "minor" | "patch" | "unknown"; + +/** What CI said about the PR's head commit, from GraphQL `statusCheckRollup`. "unknown" is never read as green. */ +export type CiState = + | { state: "success" } + | { state: "pending"; pending: string[] } + | { state: "failing"; failed: string[] } + /** The rollup was missing, refused or unrecognised; `reason` says which, in words fit for the report. */ + | { state: "unknown"; reason: string }; + +/** + * One open Dependabot pull request, reduced to the fields the verdict reads. The merge fields are GitHub's own + * strings (`mergeable`: MERGEABLE, CONFLICTING, UNKNOWN; `mergeStateStatus`: CLEAN, UNSTABLE, HAS_HOOKS, BEHIND, + * BLOCKED, DIRTY, DRAFT, UNKNOWN) and anything else GitHub adds later is kept as it came and treated as unrecognised. + */ +export interface DependabotPr { + /** `owner/repo` as GitHub spells it. */ + repo: string; + number: number; + title: string; + url: string; + author: string | null; + headRefName: string; + /** The second segment of a default-layout `dependabot/ECOSYSTEM/...` branch; null for a custom prefix or separator. */ + ecosystem: string | null; + isDraft: boolean; + mergeable: string; + mergeStateStatus: string; + /** APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or null (no review is required, or none has happened). */ + reviewDecision: string | null; + labels: string[]; + /** Dependabot opens a PR with one commit; more means someone pushed to the branch. */ + commitCount: number; + updateType: UpdateType; + /** `dependency-name` of each dependency the commit message lists; more than one means a grouped or multi-dependency PR. */ + dependencies: string[]; + ci: CiState; + createdAt: string | null; + updatedAt: string | null; +} + +export type Bucket = "ready" | "review-first" | "needs-work" | "waiting"; + +/** Display order: what can be acted on soonest first. */ +export const BUCKETS: readonly Bucket[] = ["ready", "review-first", "needs-work", "waiting"]; + +export interface Classification { + bucket: Bucket; + /** Why, most decisive first. For "ready" these are the facts that were checked. */ + reasons: string[]; + /** Suggestions as text for a person to act on; nothing here is ever run. */ + actions: string[]; +} + +export interface ClassifiedPr extends Classification { + pr: DependabotPr; +} + +/** GitHub's GraphQL rate-limit block; `cost` is what the query just spent. */ +export interface RateLimitInfo { + cost: number; + remaining: number; + resetAt: string; +} + +/** Everything one GraphQL search per owner found. */ +export interface OwnerPulls { + owner: string; + /** Every open Dependabot PR in the owner's repositories, not only the watched ones. */ + prs: DependabotPr[]; + /** More PRs matched than were fetched (GitHub search stops at 1,000 results), so some are missing. */ + truncated: boolean; + /** Requested repositories the token cannot see, as `owner/repo`; their PRs could not be checked. */ + unreachable: string[]; + /** Problems that did not stop the fetch, e.g. GraphQL errors for individual fields. */ + warnings: string[]; + rateLimit: RateLimitInfo | null; +} + +/** The only thing the rest of gitlib knows about where Dependabot PRs come from. */ +export interface DependabotBackend { + /** Human-readable location, used in error messages. */ + readonly label: string; + /** + * Open Dependabot PRs across `owner`'s repositories, and which of `repoNames` (without the owner) the token cannot + * see at all, so that an invisible private repository is not reported as having no PRs. + */ + fetchOwner(owner: string, repoNames: readonly string[], signal?: AbortSignal): Promise; +} + +/** The check cannot run at all: no token, a refused token, GitHub unreachable. The message is safe to show. */ +export class DependabotUnavailableError extends GitLibError {} + +/** The token was rejected, so every other owner would fail the same way and the run stops. */ +export class DependabotAuthError extends DependabotUnavailableError {} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4e18ca..94d7195 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./ai/index.js"; export * from "./app.js"; export * from "./backup.js"; export * from "./config.js"; +export * from "./dependabot/index.js"; export * from "./discovery.js"; export * from "./errors.js"; export * from "./models.js"; diff --git a/packages/core/src/remotes.ts b/packages/core/src/remotes.ts index 8987c3b..44fca94 100644 --- a/packages/core/src/remotes.ts +++ b/packages/core/src/remotes.ts @@ -1,4 +1,5 @@ import type { RemoteTarget } from "./models.js"; +import { defaultSystemDeps, type SystemDeps } from "./system.js"; const REMOTE_LINE = /^(?\S+)\s+(?\S+)\s+\((?fetch|push)\)$/; const SCP_STYLE = /^[^@]+@(?[^:]+):/; @@ -108,6 +109,26 @@ export function parseGitRemotes(output: string): RemoteTarget[] { return remotes; } +export function loadGitRemotes( + repoPath: string, + system: SystemDeps = defaultSystemDeps, +): { remotes: RemoteTarget[]; error: string | null } { + let result; + try { + result = system.runCommand(["git", "-C", repoPath, "remote", "-v"]); + } catch (error) { + return { remotes: [], error: error instanceof Error ? error.message : String(error) }; + } + + if (result.status !== 0) { + const message = + result.stderr.trim() || result.stdout.trim() || `git exited with status ${result.status}`; + return { remotes: [], error: message }; + } + + return { remotes: parseGitRemotes(result.stdout), error: null }; +} + export function selectPreferredRemote(remotes: readonly RemoteTarget[]): RemoteTarget | null { return ( remotes.find((remote) => remote.name === "origin" && remote.landingPageUrl) ?? diff --git a/packages/core/test/dependabot.test.ts b/packages/core/test/dependabot.test.ts new file mode 100644 index 0000000..073f566 --- /dev/null +++ b/packages/core/test/dependabot.test.ts @@ -0,0 +1,966 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + BUCKETS, + DependabotAuthError, + DependabotUnavailableError, + GitHubDependabotBackend, + checkDependabot, + classify, + createApplication, + groupReposByGitHubSlug, + isGitLibError, + parseBranchEcosystem, + parseCommitMetadata, + parsePullRequest, + parseSearchPage, + resolveGitHubToken, + type Bucket, + type CiState, + type DependabotBackend, + type DependabotPr, + type OwnerPulls, + type UpdateType, +} from "../src/index.js"; +import { Fixture } from "./fixture.js"; + +let fx: Fixture; +beforeEach(() => { + fx = new Fixture(); +}); +afterEach(() => fx.cleanup()); + +// --- Fixtures shaped like the GraphQL response of the search query in src/dependabot/github-backend.ts --------------- + +const checkRun = (name: string, status = "COMPLETED", conclusion: string | null = "SUCCESS") => ({ + __typename: "CheckRun", + name, + status, + conclusion, +}); +const statusContext = (context: string, state: string) => ({ __typename: "StatusContext", context, state }); +const rollup = (state: string, ...contexts: object[]) => ({ state, contexts: { nodes: contexts } }); +const green = () => rollup("SUCCESS", checkRun("build"), checkRun("test"), statusContext("codecov/patch", "SUCCESS")); + +/** Dependabot's commit message, ending in the YAML block between `---` and `...`. */ +function commitMessage(...updates: { name: string; type?: string | null }[]): string { + const items = updates.flatMap(({ name, type }) => [ + `- dependency-name: ${name}`, + " dependency-version: 6.29.1", + " dependency-type: direct:production", + ...(type ? [` update-type: version-update:semver-${type}`] : []), + ]); + return [ + "build(deps): bump undici from 6.27.0 to 6.29.1", + "", + "Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.29.1.", + "", + "---", + "updated-dependencies:", + ...items, + "...", + "", + "Signed-off-by: dependabot[bot] ", + ].join("\n"); +} + +interface NodeOptions { + number?: number; + repo?: string; + title?: string; + headRefName?: string; + isDraft?: boolean; + mergeable?: string; + mergeStateStatus?: string; + reviewDecision?: string | null; + labels?: string[]; + commitCount?: number; + message?: string; + rollup?: object | null; +} + +/** One element of `data.search.nodes`, clean and green unless an override says otherwise. */ +function node(options: NodeOptions = {}): Record { + const o = { + number: 41, + repo: "meddlin/gitlib", + title: "build(deps): bump undici from 6.27.0 to 6.29.1", + headRefName: "dependabot/npm_and_yarn/undici-6.29.1", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: null, + labels: ["dependencies", "javascript"], + commitCount: 1, + message: commitMessage({ name: "undici", type: "patch" }), + rollup: green() as object | null, + ...options, + }; + return { + number: o.number, + title: o.title, + url: `https://github.com/${o.repo}/pull/${o.number}`, + createdAt: "2026-10-26T06:00:00Z", + updatedAt: "2026-10-30T06:00:00Z", + headRefName: o.headRefName, + isDraft: o.isDraft, + mergeable: o.mergeable, + mergeStateStatus: o.mergeStateStatus, + reviewDecision: o.reviewDecision, + author: { __typename: "Bot", login: "dependabot" }, + repository: { nameWithOwner: o.repo }, + labels: { nodes: o.labels.map((name) => ({ name })) }, + commits: { totalCount: o.commitCount, nodes: [{ commit: { message: o.message, statusCheckRollup: o.rollup } }] }, + }; +} + +interface PageOptions { + nodes?: unknown[]; + issueCount?: number; + hasNextPage?: boolean; + endCursor?: string | null; + cost?: number; + errors?: object[]; +} + +/** A whole GraphQL response body for one page of the search. */ +function searchResponse(options: PageOptions = {}): Record { + const nodes = options.nodes ?? [node()]; + return { + data: { + rateLimit: { cost: options.cost ?? 1, remaining: 4990, resetAt: "2026-11-02T13:00:00Z" }, + search: { + issueCount: options.issueCount ?? nodes.length, + pageInfo: { hasNextPage: options.hasNextPage ?? false, endCursor: options.endCursor ?? null }, + nodes, + }, + }, + ...(options.errors ? { errors: options.errors } : {}), + }; +} + +/** A PR as the classifier sees it, parsed from a fixture node. */ +const pr = (options: NodeOptions = {}): DependabotPr => { + const parsed = parsePullRequest(node(options)); + if (!parsed) throw new Error("fixture did not parse"); + return parsed; +}; + +// --- Commit message and branch --------------------------------------------------------------------------------- + +describe("parseCommitMetadata", () => { + it("reads the update type and dependency of a single bump", () => { + expect(parseCommitMetadata(commitMessage({ name: "undici", type: "patch" }))).toEqual({ + updateType: "patch", + dependencies: ["undici"], + }); + }); + + it("keeps the biggest bump in a group", () => { + const message = commitMessage({ name: "a", type: "patch" }, { name: "b", type: "major" }, { name: "c", type: "minor" }); + expect(parseCommitMetadata(message)).toEqual({ updateType: "major", dependencies: ["a", "b", "c"] }); + expect(parseCommitMetadata(commitMessage({ name: "a", type: "patch" }, { name: "c", type: "minor" })).updateType).toBe("minor"); + }); + + it("cannot call a group minor or patch when one dependency states no type (indirect updates)", () => { + expect(parseCommitMetadata(commitMessage({ name: "a", type: "patch" }, { name: "indirect", type: null })).updateType).toBe("unknown"); + // ...but a major elsewhere in the group still decides it. + expect(parseCommitMetadata(commitMessage({ name: "a", type: "major" }, { name: "indirect", type: null })).updateType).toBe("major"); + }); + + it("answers unknown for a missing block, an unrecognised value, or an unterminated block", () => { + expect(parseCommitMetadata("build(deps): bump x from 1 to 2\n\nNo block here.").updateType).toBe("unknown"); + expect(parseCommitMetadata("---\nupdated-dependencies:\n- dependency-name: x\n update-type: version-update:semver-prerelease\n...").updateType).toBe("unknown"); + expect(parseCommitMetadata("---\nupdated-dependencies:\n- dependency-name: x\n update-type: version-update:semver-patch\n").updateType).toBe("unknown"); + expect(parseCommitMetadata("").updateType).toBe("unknown"); + }); + + it("copes with CRLF line endings and quoted values", () => { + const message = "title\r\n\r\n---\r\nupdated-dependencies:\r\n- dependency-name: \"@types/node\"\r\n update-type: 'version-update:semver-minor'\r\n...\r\n"; + expect(parseCommitMetadata(message)).toEqual({ updateType: "minor", dependencies: ["@types/node"] }); + }); +}); + +describe("parseBranchEcosystem", () => { + it("reads the ecosystem from the default layout and nothing from a custom one", () => { + expect(parseBranchEcosystem("dependabot/npm_and_yarn/undici-6.29.1")).toBe("npm_and_yarn"); + expect(parseBranchEcosystem("dependabot/github_actions/actions/checkout-6")).toBe("github_actions"); + expect(parseBranchEcosystem("deps-npm_and_yarn-undici")).toBeNull(); + expect(parseBranchEcosystem("dependabot-npm_and_yarn-undici")).toBeNull(); + expect(parseBranchEcosystem("dependabot/")).toBeNull(); + }); +}); + +// --- Parsing GitHub's response --------------------------------------------------------------------------------- + +describe("parseSearchPage", () => { + it("turns a search response into PRs plus paging and quota information", () => { + const page = parseSearchPage( + searchResponse({ + nodes: [node({ number: 7, labels: ["dependencies", "security"], commitCount: 3 })], + issueCount: 120, + hasNextPage: true, + endCursor: "Y3Vyc29y", + cost: 2, + }), + ); + expect(page).toMatchObject({ + issueCount: 120, + nodeCount: 1, + hasNextPage: true, + endCursor: "Y3Vyc29y", + rateLimit: { cost: 2, remaining: 4990, resetAt: "2026-11-02T13:00:00Z" }, + warnings: [], + }); + expect(page.prs).toHaveLength(1); + expect(page.prs[0]).toMatchObject({ + repo: "meddlin/gitlib", + number: 7, + url: "https://github.com/meddlin/gitlib/pull/7", + author: "dependabot", + headRefName: "dependabot/npm_and_yarn/undici-6.29.1", + ecosystem: "npm_and_yarn", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: null, + labels: ["dependencies", "security"], + commitCount: 3, + updateType: "patch", + dependencies: ["undici"], + ci: { state: "success" }, + }); + }); + + it("skips nodes that are not pull requests", () => { + // An inline fragment that does not match comes back as an empty object; a hidden result can be null. + const page = parseSearchPage(searchResponse({ nodes: [{}, null, node({ number: 2 })] })); + expect(page.prs.map((p) => p.number)).toEqual([2]); + expect(page.nodeCount).toBe(3); + }); + + describe("CI", () => { + const ciOf = (statusCheckRollup: object | null): CiState => parseSearchPage(searchResponse({ nodes: [node({ rollup: statusCheckRollup })] })).prs[0]!.ci; + + it("is success only when the rollup says SUCCESS", () => { + expect(ciOf(green())).toEqual({ state: "success" }); + }); + + it("names the checks and statuses that failed", () => { + const failing = rollup( + "FAILURE", + checkRun("build"), + checkRun("lint", "COMPLETED", "FAILURE"), + checkRun("e2e", "COMPLETED", "TIMED_OUT"), + checkRun("lint", "COMPLETED", "FAILURE"), + statusContext("ci/legacy", "ERROR"), + statusContext("codecov", "SUCCESS"), + ); + expect(ciOf(failing)).toEqual({ state: "failing", failed: ["lint", "e2e", "ci/legacy"] }); + expect(ciOf(rollup("ERROR"))).toEqual({ state: "failing", failed: [] }); + }); + + it("names the checks still running", () => { + expect(ciOf(rollup("PENDING", checkRun("build", "IN_PROGRESS", null), checkRun("test", "QUEUED", null), checkRun("lint"), statusContext("ci/legacy", "PENDING")))).toEqual({ + state: "pending", + pending: ["build", "test", "ci/legacy"], + }); + expect(ciOf(rollup("EXPECTED"))).toEqual({ state: "pending", pending: [] }); + }); + + it("is unknown, never green, when GitHub returns no rollup at all", () => { + const ci = ciOf(null); + expect(ci.state).toBe("unknown"); + expect(ci).toMatchObject({ reason: expect.stringContaining("no check results") }); + // The reason must point at the open question rather than claim there is no CI. + expect(ci).toMatchObject({ reason: expect.stringContaining("token that cannot read checks") }); + }); + + it("is unknown, with GitHub's own words, when it refused to resolve the rollup field", () => { + const response = searchResponse({ + nodes: [node({ number: 1, rollup: null }), node({ number: 2 })], + errors: [ + { + type: "FORBIDDEN", + path: ["search", "nodes", 0, "commits", "nodes", 0, "commit", "statusCheckRollup"], + message: "Resource not accessible by personal access token", + }, + ], + }); + const page = parseSearchPage(response); + expect(page.prs[0]!.ci).toEqual({ + state: "unknown", + reason: "GitHub would not return the checks (Resource not accessible by personal access token).", + }); + // The error belongs to the first PR only. + expect(page.prs[1]!.ci).toEqual({ state: "success" }); + expect(page.warnings).toEqual([]); + }); + + it("is unknown for a state it has never seen", () => { + expect(ciOf(rollup("SOMETHING_NEW"))).toMatchObject({ state: "unknown", reason: expect.stringContaining("SOMETHING_NEW") }); + }); + + it("is unknown when the head commit is missing", () => { + const broken = node(); + broken["commits"] = { totalCount: 0, nodes: [] }; + expect(parseSearchPage(searchResponse({ nodes: [broken] })).prs[0]!.ci.state).toBe("unknown"); + }); + }); + + it("keeps errors that do not concern a check rollup as warnings", () => { + const page = parseSearchPage(searchResponse({ errors: [{ type: "SOMETHING", path: ["search", "nodes", 0, "labels"], message: "labels exploded" }] })); + expect(page.warnings).toEqual(["labels exploded"]); + expect(page.prs).toHaveLength(1); + }); + + it("throws a GitLibError with GitHub's message when there is no search result", () => { + expect(() => parseSearchPage({ errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] })).toThrow(/API rate limit exceeded/); + expect(() => parseSearchPage({ data: { search: null }, errors: [{ message: "nope" }] })).toThrow(DependabotUnavailableError); + expect(() => parseSearchPage("")).toThrow(DependabotUnavailableError); + try { + parseSearchPage({}); + } catch (error) { + expect(isGitLibError(error)).toBe(true); + } + }); + + it("falls back to UNKNOWN for merge fields GitHub leaves out", () => { + const bare = node(); + delete bare["mergeable"]; + delete bare["mergeStateStatus"]; + expect(parseSearchPage(searchResponse({ nodes: [bare] })).prs[0]).toMatchObject({ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }); + }); +}); + +// --- The verdict ----------------------------------------------------------------------------------------------- + +describe("classify", () => { + const verdict = (options: NodeOptions = {}) => classify(pr(options)); + const text = (strings: string[]) => strings.join("\n"); + + describe("ready", () => { + it("is a clean, green patch update", () => { + const result = verdict(); + expect(result.bucket).toBe("ready"); + expect(result.reasons).toEqual(["All checks pass.", "No conflicts and up to date with the base branch.", "Patch update."]); + expect(text(result.actions)).toContain("Merge it from the PR page"); + }); + + it("accepts a minor update, an approval, and a merge state with hooks", () => { + const result = verdict({ + message: commitMessage({ name: "undici", type: "minor" }), + reviewDecision: "APPROVED", + mergeStateStatus: "HAS_HOOKS", + }); + expect(result.bucket).toBe("ready"); + expect(result.reasons).toEqual([ + "All checks pass.", + "No conflicts and up to date with the base branch.", + "Minor update.", + "Approved.", + "Pre-receive hooks will run when it merges.", + ]); + }); + }); + + describe("needs work", () => { + it("flags conflicts and suggests rebase, then recreate", () => { + for (const options of [{ mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }, { mergeStateStatus: "DIRTY" }, { mergeable: "CONFLICTING" }]) { + const result = verdict(options); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons[0]).toBe("Merge conflicts with the base branch."); + expect(text(result.actions)).toContain("`@dependabot rebase`"); + expect(text(result.actions)).toContain("`@dependabot recreate`"); + } + }); + + it("flags a branch that is behind and suggests rebase", () => { + const result = verdict({ mergeStateStatus: "BEHIND" }); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons).toEqual(["Behind the base branch."]); + expect(result.actions[0]).toContain("`@dependabot rebase`"); + }); + + it("suggests recreate instead of rebase when someone pushed to the branch", () => { + const result = verdict({ mergeStateStatus: "BEHIND", commitCount: 2 }); + expect(result.bucket).toBe("needs-work"); + expect(text(result.actions)).toContain("`@dependabot recreate`"); + expect(text(result.actions)).not.toContain("`@dependabot rebase`"); + // And the extra commits are a reason to look, whichever bucket it lands in. + expect(text(result.reasons)).toContain("Has 2 commits"); + }); + + it("flags requested changes", () => { + const result = verdict({ reviewDecision: "CHANGES_REQUESTED" }); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons).toEqual(["A reviewer requested changes."]); + }); + + it("flags failing checks by name", () => { + const result = verdict({ mergeStateStatus: "BLOCKED", rollup: rollup("FAILURE", checkRun("build"), checkRun("test", "COMPLETED", "FAILURE")) }); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons).toEqual(["Checks failing: test."]); + // A blocked merge state is explained by the failing check and not repeated. + expect(text(result.reasons)).not.toContain("blocked"); + }); + + it("flags an UNSTABLE merge state even when the rollup claims success", () => { + const result = verdict({ mergeStateStatus: "UNSTABLE" }); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons[0]).toContain("UNSTABLE"); + }); + + it("lists the other findings after the decisive one", () => { + const result = verdict({ mergeable: "CONFLICTING", message: commitMessage({ name: "undici", type: "major" }) }); + expect(result.bucket).toBe("needs-work"); + expect(result.reasons).toEqual(["Merge conflicts with the base branch.", "Major version bump."]); + }); + }); + + describe("waiting", () => { + it("treats UNKNOWN mergeability as transient: not ready, not broken, look again", () => { + // GitHub answers UNKNOWN for both fields right after a push, while it computes the test merge. + const result = verdict({ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }); + expect(result.bucket).toBe("waiting"); + expect(result.reasons[0]).toContain("UNKNOWN"); + expect(result.reasons[0]).toContain("transient"); + expect(text(result.actions)).toContain("Run the check again"); + // One field alone is enough. + expect(verdict({ mergeable: "UNKNOWN" }).bucket).toBe("waiting"); + expect(verdict({ mergeStateStatus: "UNKNOWN" }).bucket).toBe("waiting"); + }); + + it("does not let UNKNOWN hide a failure that is already certain", () => { + expect(verdict({ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN", reviewDecision: "CHANGES_REQUESTED" }).bucket).toBe("needs-work"); + expect(verdict({ mergeable: "UNKNOWN", rollup: rollup("FAILURE", checkRun("test", "COMPLETED", "FAILURE")) }).bucket).toBe("needs-work"); + }); + + it("waits for checks that are still running", () => { + const result = verdict({ mergeStateStatus: "BLOCKED", rollup: rollup("PENDING", checkRun("build", "IN_PROGRESS", null)) }); + expect(result.bucket).toBe("waiting"); + expect(result.reasons).toEqual(["Checks still running: build."]); + }); + + it("waits on a draft", () => { + expect(verdict({ isDraft: true }).bucket).toBe("waiting"); + expect(verdict({ mergeStateStatus: "DRAFT" }).bucket).toBe("waiting"); + }); + + it("still reports a major bump it is waiting on", () => { + const result = verdict({ mergeable: "UNKNOWN", message: commitMessage({ name: "undici", type: "major" }) }); + expect(result.bucket).toBe("waiting"); + expect(result.reasons).toContain("Major version bump."); + }); + }); + + describe("review first", () => { + it("holds back a major bump even when everything is green", () => { + const result = verdict({ message: commitMessage({ name: "undici", type: "major" }) }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons).toEqual(["Major version bump."]); + expect(text(result.actions)).toContain("release notes"); + expect(text(result.actions)).toContain("`@dependabot ignore this major version`"); + }); + + it("holds back an update whose type the commit message does not state", () => { + const result = verdict({ message: "build(deps): bump x from 1 to 2" }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons).toEqual(["The commit message does not say how big the bump is."]); + expect(text(result.actions)).toContain("`@dependabot ignore this dependency`"); + }); + + it("holds back a security update", () => { + const result = verdict({ labels: ["dependencies", "Security"] }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons[0]).toContain("security"); + }); + + it("holds back a PR nobody has reviewed when branch protection asks for one", () => { + const result = verdict({ reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED" }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons).toEqual(["Branch protection asks for a review and none has approved it."]); + }); + + it("holds back a blocked merge that nothing else explains", () => { + const result = verdict({ mergeStateStatus: "BLOCKED" }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons[0]).toContain("blocked"); + }); + + it("holds back a PR someone pushed extra commits to", () => { + const result = verdict({ commitCount: 2 }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons[0]).toContain("Has 2 commits"); + }); + + it("holds back a value it has never seen", () => { + expect(verdict({ mergeStateStatus: "SHINY_NEW" }).bucket).toBe("review-first"); + expect(verdict({ mergeable: "SHINY_NEW" }).bucket).toBe("review-first"); + expect(verdict({ reviewDecision: "SHINY_NEW" }).bucket).toBe("review-first"); + }); + + it("says CI unknown, with the reason, instead of calling a PR with no readable checks green", () => { + const result = verdict({ rollup: null }); + expect(result.bucket).toBe("review-first"); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toMatch(/^CI unknown: GitHub returned no check results/); + expect(text(result.actions)).toContain("gitlib cannot say whether they pass"); + }); + + it("quotes GitHub's refusal when checks could not be read", () => { + const refused = parseSearchPage( + searchResponse({ + nodes: [node({ rollup: null })], + errors: [{ type: "FORBIDDEN", path: ["search", "nodes", 0, "commits", "nodes", 0, "commit", "statusCheckRollup"], message: "Resource not accessible by personal access token" }], + }), + ).prs[0]!; + expect(classify(refused).reasons[0]).toBe("CI unknown: GitHub would not return the checks (Resource not accessible by personal access token)."); + }); + }); + + it("does not offer to ignore a grouped update (the commands differ for groups)", () => { + const group = verdict({ message: commitMessage({ name: "a", type: "major" }, { name: "b", type: "patch" }) }); + expect(group.bucket).toBe("review-first"); + expect(text(group.actions)).not.toContain("ignore"); + }); + + it("matches the ignore command to the size of the bump", () => { + expect(text(verdict({ mergeable: "CONFLICTING", message: commitMessage({ name: "a", type: "minor" }) }).actions)).toContain("`@dependabot ignore this minor version`"); + expect(text(verdict({ mergeable: "CONFLICTING", message: commitMessage({ name: "a", type: "patch" }) }).actions)).toContain("`@dependabot ignore this patch version`"); + }); + + // Every combination of the fields the verdict reads. Two properties must hold for all of them, whatever GitHub sends. + describe("for every combination of inputs", () => { + const ci: CiState[] = [ + { state: "success" }, + { state: "pending", pending: ["build"] }, + { state: "failing", failed: ["test"] }, + { state: "unknown", reason: "no rollup" }, + ]; + const base = pr(); + const combinations: DependabotPr[] = []; + for (const mergeable of ["MERGEABLE", "CONFLICTING", "UNKNOWN", "OTHER"]) { + for (const mergeStateStatus of ["CLEAN", "UNSTABLE", "HAS_HOOKS", "BEHIND", "BLOCKED", "DIRTY", "DRAFT", "UNKNOWN", "OTHER"]) { + for (const reviewDecision of [null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", "OTHER"]) { + for (const isDraft of [false, true]) { + for (const state of ci) { + for (const updateType of ["major", "minor", "patch", "unknown"] as UpdateType[]) { + for (const labels of [[], ["security"]]) { + for (const commitCount of [1, 3]) { + combinations.push({ ...base, mergeable, mergeStateStatus, reviewDecision, isDraft, ci: state, updateType, labels, commitCount }); + } + } + } + } + } + } + } + } + + it("only calls a PR ready when nothing stands against it", () => { + let ready = 0; + for (const candidate of combinations) { + if (classify(candidate).bucket !== "ready") continue; + ready += 1; + expect(candidate).toMatchObject({ mergeable: "MERGEABLE", isDraft: false, commitCount: 1, ci: { state: "success" }, labels: [] }); + expect(["CLEAN", "HAS_HOOKS"]).toContain(candidate.mergeStateStatus); + expect([null, "APPROVED"]).toContain(candidate.reviewDecision); + expect(["minor", "patch"]).toContain(candidate.updateType); + } + // Guard against a vacuous pass. + expect(ready).toBeGreaterThan(0); + expect(ready).toBeLessThan(combinations.length / 10); + }); + + it("always returns a bucket with at least one reason, and never suggests a retired Dependabot command", () => { + const retired = /@dependabot\s+(merge|squash|cancel|close|reopen)/i; + const seen = new Set(); + for (const candidate of combinations) { + const result = classify(candidate); + seen.add(result.bucket); + expect(result.reasons.length).toBeGreaterThan(0); + for (const action of result.actions) expect(action).not.toMatch(retired); + for (const reason of result.reasons) expect(reason).not.toMatch(retired); + } + expect([...seen].sort()).toEqual([...BUCKETS].sort()); + }); + }); +}); + +// --- GitHub transport ------------------------------------------------------------------------------------------ + +interface SentRequest { + url: string; + headers: Record; + body: { query: string; variables: Record }; +} + +/** A `fetch` that answers from a queue of canned responses and records what was sent. */ +function fakeFetch(...responses: (Response | Error)[]): { fetch: typeof fetch; sent: SentRequest[] } { + const sent: SentRequest[] = []; + const queue = [...responses]; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + sent.push({ + url: String(input), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body: JSON.parse(String(init?.body)), + }); + const next = queue.shift(); + if (!next) throw new Error("fakeFetch ran out of responses"); + if (next instanceof Error) throw next; + return next; + }) as typeof fetch; + return { fetch: fetchImpl, sent }; +} + +const json = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, ...init }); + +/** The response to the request that asks which repositories the token can see. */ +const probeResponse = (visible: (string | null)[], cost = 1) => ({ + data: { + rateLimit: { cost, remaining: 4999, resetAt: "2026-11-02T13:00:00Z" }, + ...Object.fromEntries(visible.map((name, index) => [`r${index}`, name ? { nameWithOwner: name } : null])), + }, + ...(visible.includes(null) ? { errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository", path: ["r1"] }] } : {}), +}); + +describe("GitHubDependabotBackend", () => { + const TOKEN = "test-token-not-a-real-secret"; + const backendFor = (...responses: (Response | Error)[]) => { + const faked = fakeFetch(...responses); + return { backend: new GitHubDependabotBackend(TOKEN, { fetch: faked.fetch }), sent: faked.sent }; + }; + + it("asks which repositories are visible, then runs one search for the owner", async () => { + const { backend, sent } = backendFor( + json(probeResponse(["meddlin/gitlib", "meddlin/other"])), + json(searchResponse({ nodes: [node({ number: 1 }), node({ number: 2, repo: "meddlin/other" })], cost: 2 })), + ); + + const result = await backend.fetchOwner("meddlin", ["gitlib", "other"]); + + expect(sent).toHaveLength(2); + expect(sent.every((request) => request.url === "https://api.github.com/graphql")).toBe(true); + expect(sent[0]!.headers["authorization"]).toBe(`Bearer ${TOKEN}`); + expect(sent[0]!.body.query).toContain('r0: repository(owner: "meddlin", name: "gitlib")'); + expect(sent[1]!.body.variables).toEqual({ q: "is:pr is:open author:app/dependabot user:meddlin archived:false", first: 50, after: null }); + expect(sent[1]!.body.query).toMatch(/search\(query: \$q, type: ISSUE/); + // The fields the verdict reads are all asked for. + for (const field of ["isDraft", "mergeable", "mergeStateStatus", "reviewDecision", "headRefName", "labels", "statusCheckRollup", "message"]) { + expect(sent[1]!.body.query).toContain(field); + } + + expect(result.owner).toBe("meddlin"); + expect(result.prs.map((p) => p.number)).toEqual([1, 2]); + expect(result.unreachable).toEqual([]); + expect(result.truncated).toBe(false); + expect(result.rateLimit).toEqual({ cost: 3, remaining: 4990, resetAt: "2026-11-02T13:00:00Z" }); + }); + + it("reports repositories the token cannot see instead of letting them look empty", async () => { + const { backend } = backendFor(json(probeResponse(["meddlin/gitlib", null])), json(searchResponse({ nodes: [] }))); + const result = await backend.fetchOwner("meddlin", ["gitlib", "private-thing"]); + expect(result.unreachable).toEqual(["meddlin/private-thing"]); + }); + + it("follows the cursor to the next page and says when GitHub's 1,000-result cap cut the list short", async () => { + const { backend, sent } = backendFor( + json(searchResponse({ nodes: [node({ number: 1 })], issueCount: 1500, hasNextPage: true, endCursor: "c1" })), + json(searchResponse({ nodes: [node({ number: 2 })], issueCount: 1500, hasNextPage: false })), + ); + const result = await backend.fetchOwner("meddlin", []); + expect(sent.map((request) => request.body.variables["after"])).toEqual([null, "c1"]); + expect(result.prs.map((p) => p.number)).toEqual([1, 2]); + expect(result.truncated).toBe(true); + }); + + it("does not call a complete listing truncated", async () => { + const { backend } = backendFor(json(searchResponse({ nodes: [node()], issueCount: 1 }))); + expect((await backend.fetchOwner("meddlin", [])).truncated).toBe(false); + }); + + it("passes on warnings once", async () => { + const warning = { type: "X", path: ["search", "nodes", 0, "labels"], message: "labels exploded" }; + const { backend } = backendFor(json(searchResponse({ errors: [warning, warning] }))); + expect((await backend.fetchOwner("meddlin", [])).warnings).toEqual(["labels exploded"]); + }); + + it("refuses names that could change the search or the lookup, without calling GitHub", async () => { + const { backend, sent } = backendFor(); + await expect(backend.fetchOwner("meddlin repo:other/thing", [])).rejects.toThrow(DependabotUnavailableError); + expect(sent).toEqual([]); + + // A repository name is only ever interpolated after it matches GitHub's own alphabet. + const withSearch = backendFor(json(searchResponse({ nodes: [] }))); + await withSearch.backend.fetchOwner("meddlin", ['x") { id } evil: repository(owner: "a']); + expect(withSearch.sent).toHaveLength(1); + expect(withSearch.sent[0]!.body.query).not.toContain("evil"); + }); + + it("stops the whole run on a rejected token, without echoing it", async () => { + const { backend } = backendFor(json({ message: "Bad credentials" }, { status: 401 })); + const error = await backend.fetchOwner("meddlin", []).catch((e: unknown) => e); + expect(error).toBeInstanceOf(DependabotAuthError); + expect(isGitLibError(error)).toBe(true); + expect((error as Error).message).not.toContain(TOKEN); + }); + + it("explains an exhausted rate limit and a polite pause", async () => { + const reset = Math.floor(Date.UTC(2026, 10, 2, 13, 0, 0) / 1000); + const exhausted = backendFor(json({}, { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } })); + await expect(exhausted.backend.fetchOwner("meddlin", [])).rejects.toThrow("resets at 2026-11-02T13:00:00.000Z"); + + const paused = backendFor(json({}, { status: 403, headers: { "retry-after": "60" } })); + await expect(paused.backend.fetchOwner("meddlin", [])).rejects.toThrow("try again in 60 seconds"); + + const plain = backendFor(json({}, { status: 403 })); + await expect(plain.backend.fetchOwner("meddlin", [])).rejects.toThrow(/refused the request \(403\)/); + }); + + it("turns network failures, server errors and garbage into readable errors", async () => { + const down = new TypeError("fetch failed", { cause: Object.assign(new Error("connect failed"), { code: "ENOTFOUND" }) }); + await expect(backendFor(down).backend.fetchOwner("meddlin", [])).rejects.toThrow("Could not reach GitHub at api.github.com (ENOTFOUND)"); + await expect(backendFor(new Response("", { status: 502 })).backend.fetchOwner("meddlin", [])).rejects.toThrow("GitHub responded with 502"); + await expect(backendFor(new Response("", { status: 200 })).backend.fetchOwner("meddlin", [])).rejects.toThrow("not JSON"); + }); + + it("lets a caller's abort through unchanged", async () => { + const controller = new AbortController(); + controller.abort(); + const abort = new DOMException("aborted", "AbortError"); + const { backend } = backendFor(abort); + await expect(backend.fetchOwner("meddlin", [], controller.signal)).rejects.toBe(abort); + }); +}); + +describe("resolveGitHubToken", () => { + it("prefers GITLIB_GITHUB_TOKEN, falls back to GITHUB_TOKEN, and ignores blanks", () => { + expect(resolveGitHubToken({ GITLIB_GITHUB_TOKEN: " a ", GITHUB_TOKEN: "b" })).toBe("a"); + expect(resolveGitHubToken({ GITLIB_GITHUB_TOKEN: "", GITHUB_TOKEN: "b" })).toBe("b"); + expect(resolveGitHubToken({ GITLIB_GITHUB_TOKEN: " " })).toBeNull(); + expect(resolveGitHubToken({})).toBeNull(); + }); +}); + +// --- Which repositories are watched ---------------------------------------------------------------------------- + +/** Lets a test script what GitHub says per owner. */ +class FakeBackend implements DependabotBackend { + readonly label = "fake"; + calls: { owner: string; repoNames: string[] }[] = []; + constructor(private readonly byOwner: Record | Error>) {} + + async fetchOwner(owner: string, repoNames: readonly string[]): Promise { + this.calls.push({ owner, repoNames: [...repoNames] }); + const answer = this.byOwner[owner]; + if (answer instanceof Error) throw answer; + return { owner, prs: [], truncated: false, unreachable: [], warnings: [], rateLimit: null, ...answer }; + } +} + +describe("watching favorites", () => { + function setUp(remotes: Record) { + fx.writeConfig(); + for (const [relativePath, url] of Object.entries(remotes)) { + const repoPath = fx.initRealGitRepo(relativePath); + if (url) fx.git(repoPath, "remote", "add", "origin", url); + } + } + const appWith = (backend: DependabotBackend | null) => createApplication(fx.env(), undefined, null, null, backend); + const star = (app: ReturnType, ...paths: string[]) => { + app.refresh(); + for (const relativePath of paths) app.saveRepositoryFavorite(relativePath, true); + }; + + describe("groupReposByGitHubSlug", () => { + it("collapses clones and linked worktrees of one GitHub repository, ignoring case", () => { + setUp({ gitlib: "https://github.com/Meddlin/gitlib.git", "gitlib-copy": "git@github.com:meddlin/GitLib.git", other: "https://github.com/meddlin/other" }); + fx.addWorktree("gitlib", "gitlib/.claude/worktrees/feature"); + const app = appWith(null); + app.refresh(); + + const { groups, unresolved } = groupReposByGitHubSlug(app.loadRepositories(), app.system); + + expect(unresolved).toEqual([]); + expect(groups.map((group) => group.slug)).toEqual(["Meddlin/gitlib", "meddlin/other"]); + expect(groups[0]!.paths.sort()).toEqual(["gitlib", "gitlib-copy", "gitlib/.claude/worktrees/feature"]); + }); + + it("returns repositories it cannot map, with the reason, instead of dropping them", () => { + setUp({ + lonely: null, + gitlab: "https://gitlab.com/meddlin/thing.git", + selfhosted: "git@git.example.com:team/thing.git", + }); + const app = appWith(null); + app.refresh(); + + const { groups, unresolved } = groupReposByGitHubSlug(app.loadRepositories(), app.system); + + expect(groups).toEqual([]); + expect(Object.fromEntries(unresolved.map((repo) => [repo.relativePath, repo.reason]))).toEqual({ + lonely: "it has no git remote", + gitlab: "no remote is on github.com (origin: gitlab.com)", + selfhosted: "no remote is on github.com (origin: git.example.com)", + }); + }); + + it("skips an owner or repository name that GitHub would not allow", () => { + setUp({ odd: "https://github.com/me%20repo:evil/thing" }); + const app = appWith(null); + app.refresh(); + const { groups, unresolved } = groupReposByGitHubSlug(app.loadRepositories(), app.system); + expect(groups).toEqual([]); + expect(unresolved).toHaveLength(1); + }); + + it("reports a repo whose git cannot be run", () => { + setUp({ broken: "https://github.com/meddlin/broken" }); + const app = appWith(null); + app.refresh(); + const system = { ...app.system, runCommand: () => ({ status: 128, stdout: "", stderr: "fatal: not a git repository\nsecond line" }) }; + const { unresolved } = groupReposByGitHubSlug(app.loadRepositories(), system); + expect(unresolved).toEqual([{ relativePath: "broken", reason: "git could not list its remotes (fatal: not a git repository)" }]); + }); + }); + + describe("checkDependabot", () => { + it("asks once per owner about favorites only, and keeps only PRs of watched repositories", async () => { + setUp({ + gitlib: "https://github.com/meddlin/gitlib.git", + unstarred: "https://github.com/meddlin/unstarred.git", + zeta: "https://github.com/zeta-org/zeta.git", + gitlab: "https://gitlab.com/meddlin/thing.git", + lonely: null, + }); + const backend = new FakeBackend({ + meddlin: { + prs: [ + pr({ number: 2, repo: "meddlin/gitlib", mergeable: "CONFLICTING" }), + pr({ number: 1, repo: "meddlin/gitlib" }), + pr({ number: 9, repo: "meddlin/unstarred" }), + ], + rateLimit: { cost: 3, remaining: 4900, resetAt: "2026-11-02T13:00:00Z" }, + }, + "zeta-org": { + prs: [pr({ number: 5, repo: "zeta-org/zeta", message: commitMessage({ name: "x", type: "major" }) })], + rateLimit: { cost: 2, remaining: 4898, resetAt: "2026-11-02T13:00:00Z" }, + }, + }); + const app = appWith(backend); + star(app, "gitlib", "zeta", "gitlab", "lonely"); + + const report = await app.checkDependabot({ now: new Date("2026-11-02T08:00:00Z") }); + + expect(backend.calls).toEqual([ + { owner: "meddlin", repoNames: ["gitlib"] }, + { owner: "zeta-org", repoNames: ["zeta"] }, + ]); + expect(report.checkedAt).toBe("2026-11-02T08:00:00.000Z"); + expect(report.items.map((item) => [item.pr.repo, item.pr.number, item.bucket])).toEqual([ + ["meddlin/gitlib", 1, "ready"], + ["zeta-org/zeta", 5, "review-first"], + ["meddlin/gitlib", 2, "needs-work"], + ]); + expect(report.watched.map((repo) => [repo.slug, repo.status, repo.openPrs])).toEqual([ + ["meddlin/gitlib", "checked", 2], + ["zeta-org/zeta", "checked", 1], + ]); + expect(report.skipped).toEqual([ + { relativePath: "gitlab", reason: "no remote is on github.com (origin: gitlab.com)" }, + { relativePath: "lonely", reason: "it has no git remote" }, + ]); + expect(report.rateLimit).toEqual({ cost: 5, remaining: 4898, resetAt: "2026-11-02T13:00:00Z" }); + expect(report.errors).toEqual([]); + }); + + it("matches the PR's repository to the watched one without regard to case", async () => { + setUp({ gitlib: "https://github.com/Meddlin/GitLib.git" }); + const backend = new FakeBackend({ Meddlin: { prs: [pr({ repo: "meddlin/gitlib" })] } }); + const app = appWith(backend); + star(app, "gitlib"); + expect((await app.checkDependabot()).items).toHaveLength(1); + }); + + it("marks a repository the token cannot see, rather than reporting it empty", async () => { + setUp({ secret: "https://github.com/meddlin/secret.git", open: "https://github.com/meddlin/open.git" }); + const backend = new FakeBackend({ meddlin: { unreachable: ["meddlin/secret"], truncated: true, warnings: ["careful"] } }); + const app = appWith(backend); + star(app, "secret", "open"); + + const report = await app.checkDependabot(); + + expect(report.watched.map((repo) => [repo.slug, repo.status])).toEqual([ + ["meddlin/open", "checked"], + ["meddlin/secret", "unreachable"], + ]); + expect(report.truncatedOwners).toEqual(["meddlin"]); + expect(report.warnings).toEqual(["careful"]); + }); + + it("records a failed owner and carries on with the rest", async () => { + setUp({ a: "https://github.com/aaa/a.git", b: "https://github.com/bbb/b.git" }); + const backend = new FakeBackend({ + aaa: new DependabotUnavailableError("Could not reach GitHub at api.github.com (ENOTFOUND)."), + bbb: { prs: [pr({ repo: "bbb/b" })] }, + }); + const app = appWith(backend); + star(app, "a", "b"); + + const report = await app.checkDependabot(); + + expect(report.errors).toEqual([{ owner: "aaa", message: "Could not reach GitHub at api.github.com (ENOTFOUND)." }]); + expect(report.watched.map((repo) => [repo.slug, repo.status])).toEqual([ + ["aaa/a", "failed"], + ["bbb/b", "checked"], + ]); + expect(report.items).toHaveLength(1); + }); + + it("stops on a rejected token and on a bug, but not on an ordinary failure", async () => { + setUp({ a: "https://github.com/aaa/a.git", b: "https://github.com/bbb/b.git" }); + for (const failure of [new DependabotAuthError("GitHub rejected the token"), new TypeError("a bug")]) { + const backend = new FakeBackend({ aaa: failure, bbb: {} }); + const app = appWith(backend); + star(app, "a", "b"); + await expect(app.checkDependabot()).rejects.toBe(failure); + expect(backend.calls).toHaveLength(1); + } + }); + + it("says there is nothing to check when no favorite has a GitHub remote", async () => { + setUp({ gitlab: "https://gitlab.com/meddlin/thing.git", notstarred: "https://github.com/meddlin/x.git" }); + const backend = new FakeBackend({}); + const app = appWith(backend); + star(app, "gitlab"); + const report = await app.checkDependabot(); + expect(backend.calls).toEqual([]); + expect(report.watched).toEqual([]); + expect(report.skipped).toHaveLength(1); + }); + + it("works on its own, without a GitLibApp", async () => { + setUp({ gitlib: "https://github.com/meddlin/gitlib.git" }); + const app = appWith(null); + star(app, "gitlib"); + const backend = new FakeBackend({ meddlin: { prs: [pr()] } }); + const report = await checkDependabot({ repositories: app.loadRepositories(), backend, system: app.system }); + expect(report.items).toHaveLength(1); + }); + }); + + describe("the token", () => { + it("is read from GITLIB_GITHUB_TOKEN or GITHUB_TOKEN; without either the check explains what to set", async () => { + fx.writeConfig(); + expect(createApplication(fx.env()).dependabot).toBeNull(); + expect(createApplication(fx.env({ GITLIB_GITHUB_TOKEN: "x" })).dependabot?.label).toBe("api.github.com"); + expect(createApplication(fx.env({ GITHUB_TOKEN: "x" })).dependabot?.label).toBe("api.github.com"); + + const error = await createApplication(fx.env()).checkDependabot().catch((e: unknown) => e); + expect(error).toBeInstanceOf(DependabotUnavailableError); + expect(isGitLibError(error)).toBe(true); + expect((error as Error).message).toContain("GITLIB_GITHUB_TOKEN"); + }); + }); +});