From d1511b208ebebdf18127427b8f0405dc84ca3731 Mon Sep 17 00:00:00 2001 From: Darrien Rushing Date: Mon, 2 Nov 2026 15:22:30 -0600 Subject: [PATCH] feat(core): add a report-only Dependabot check for starred repositories `gitlib dependabot check [--json]` lists the open Dependabot PRs of the favorites that have a GitHub remote and sorts them into ready, review first, needs work and waiting. The verdict comes only from fields GitHub returns (mergeable, mergeStateStatus, reviewDecision, the check rollup, labels, and the update-type in Dependabot's commit-message block), never from the PR body. Suggestions are text only: `@dependabot rebase` / `recreate` where the research note says so, and never the merge commands retired on 2026-01-27. - core: pure `classify`, a GraphQL fetch behind a `DependabotBackend` interface (fake in tests, one search per owner plus a visibility lookup so a repository the token cannot see is not reported as empty), and `checkDependabot`, also exposed as `GitLibApp.checkDependabot`. - A missing or refused `statusCheckRollup` shows as "CI unknown" with the reason and is never ready: whether GraphQL rollups work with a fine-grained token is still undocumented. - `loadGitRemotes` moves from app.ts to remotes.ts (same export) so the new module does not import app.ts. Out of scope here: storage, web page, schedule, LLM explanations. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 4 +- README.md | 38 + apps/cli/src/cli.ts | 30 +- apps/cli/src/render.ts | 138 ++- apps/cli/test/dependabot.test.ts | 363 +++++++ apps/cli/test/fixture.ts | 9 +- docs/dependabot-watch.md | 19 + packages/core/src/app.ts | 52 +- packages/core/src/dependabot/check.ts | 190 ++++ packages/core/src/dependabot/classify.ts | 156 +++ .../core/src/dependabot/github-backend.ts | 388 +++++++ packages/core/src/dependabot/index.ts | 5 + packages/core/src/dependabot/metadata.ts | 56 + packages/core/src/dependabot/types.ts | 102 ++ packages/core/src/index.ts | 1 + packages/core/src/remotes.ts | 21 + packages/core/test/dependabot.test.ts | 966 ++++++++++++++++++ 17 files changed, 2511 insertions(+), 27 deletions(-) create mode 100644 apps/cli/test/dependabot.test.ts create mode 100644 packages/core/src/dependabot/check.ts create mode 100644 packages/core/src/dependabot/classify.ts create mode 100644 packages/core/src/dependabot/github-backend.ts create mode 100644 packages/core/src/dependabot/index.ts create mode 100644 packages/core/src/dependabot/metadata.ts create mode 100644 packages/core/src/dependabot/types.ts create mode 100644 packages/core/test/dependabot.test.ts 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"); + }); + }); +});