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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion dependabot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Every run prints a table and appends it to the job summary: for the reconciler `
| `exclude` | `[{"repo": "owner/name", "reason": "..."}]` | Repositories neither script touches. A reason is required. |
| `schedule` | weekly, Monday 06:00, America/New_York | The documented convention. |
| `cooldown` | 3 days, 7 for majors | A release is not proposed the day it ships. |
| `open-pull-requests-limit` | `10` | Per ecosystem entry. |
| `open-pull-requests-limit` | `10` | Per ecosystem entry. Dependabot's own cap, not the reconciler's. |
| `max-new-per-run` | `5` | The reconciler's own cap: at most this many **new** pull requests per account per run. A pull request already open is still refreshed past it. `0` removes the limit. |
| `labels` | `["dependencies"]` | Applied to every Dependabot pull request. |
| `commit-message-prefix` | per ecosystem | `chore(actions)`, `deps(composer)`, `deps(npm)`, … |
| `groups` | `{ecosystem}-minor-and-patch`, minor + patch | One grouped pull request per ecosystem; majors stay one each. |
Expand Down Expand Up @@ -85,6 +86,28 @@ Dependency Merge Bot this section used to describe was never created; its job is

Both workflows ship as dry runs.

> **The reconciler is rate limited, and that is what makes going live safe.** Measured
> 2026-11-02, before the limit existed: a first apply run would have opened **118 pull
> requests across 256 repositories** -- 64 in laranail, 38 in simtabi, 15 in ichava, 1 in
> simbi-prjs-clt. Against the 773 pull requests in the week of 2026-09-30 that made
> "fewer, fuller PRs" a rule, that is not a switch to flip blind.
>
> `policy.json`'s `max-new-per-run` (5) caps **new** pull requests per account per run, so a
> scheduled week opens at most 5 x the number of accounts with drift and the estate converges
> over a few weeks instead of in one burst. `--max-new N` overrides it; `--max-new 0` removes
> the limit. A repository whose pull request is **already open** is still refreshed past the
> budget -- leaving a stale one open is worse than opening nothing.
>
> The budget is spent at the moment a **new** pull request is decided, not before the work.
> Spending it earlier is cheaper and misreports: a conforming repository came back `deferred`
> with nobody having looked at it, and laranail's summary read 63 deferred when only 64 of its
> 77 active repositories drift at all.
>
> A repository held back reads `deferred:rate-limit`, never `skipped:`, and the summary says
> how many were deferred. That distinction is load-bearing: `skipped` means a decision was
> taken about that repository, and a budget says nothing about it. Without it a capped run
> looks like a clean estate -- which is exactly how the two defects in #15 and #16 hid.

| Step | How |
|---|---|
| Try one run | Actions → the workflow → Run workflow, with `apply` ticked. |
Expand Down
88 changes: 65 additions & 23 deletions dependabot/policy.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
{
"version": 1,
"accounts": {
"orgs": ["brandgulp", "ichava", "laranail", "mukoracms", "simsange", "simtabi", "tayariai", "tusente", "simbi-prjs-clt", "simbi-prjs-int"],
"users": ["imanimanyara"]
},
"exclude": [
{
"repo": "laranail/wire-elements-pro",
"reason": "A mirror of the commercial Wire Elements Pro package: its dependencies follow upstream releases, not Dependabot."
}
"version": 1,
"accounts": {
"orgs": [
"brandgulp",
"ichava",
"laranail",
"mukoracms",
"simsange",
"simtabi",
"tayariai",
"tusente",
"simbi-prjs-clt",
"simbi-prjs-int"
],
"schedule": {"interval": "weekly", "day": "monday", "time": "06:00", "timezone": "America/New_York"},
"cooldown": {"default-days": 3, "semver-major-days": 7},
"open-pull-requests-limit": 10,
"labels": ["dependencies"],
"commit-message-prefix": {"github-actions": "chore(actions)", "composer": "deps(composer)", "npm": "deps(npm)", "pip": "deps(pip)", "gomod": "deps(go)", "cargo": "deps(cargo)", "docker": "deps(docker)", "bundler": "deps(bundler)"},
"groups": {"name-pattern": "{ecosystem}-minor-and-patch", "update-types": ["minor", "patch"]},
"merge": {
"default": "actions",
"overrides": {},
"hold-label": "automerge:hold",
"majors": "never"
},
"local-block": {"begin": "# >>> local (kept by the generator)", "end": "# <<< local"}
"users": [
"imanimanyara"
]
},
"exclude": [
{
"repo": "laranail/wire-elements-pro",
"reason": "A mirror of the commercial Wire Elements Pro package: its dependencies follow upstream releases, not Dependabot."
}
],
"schedule": {
"interval": "weekly",
"day": "monday",
"time": "06:00",
"timezone": "America/New_York"
},
"cooldown": {
"default-days": 3,
"semver-major-days": 7
},
"open-pull-requests-limit": 10,
"labels": [
"dependencies"
],
"commit-message-prefix": {
"github-actions": "chore(actions)",
"composer": "deps(composer)",
"npm": "deps(npm)",
"pip": "deps(pip)",
"gomod": "deps(go)",
"cargo": "deps(cargo)",
"docker": "deps(docker)",
"bundler": "deps(bundler)"
},
"groups": {
"name-pattern": "{ecosystem}-minor-and-patch",
"update-types": [
"minor",
"patch"
]
},
"merge": {
"default": "actions",
"overrides": {},
"hold-label": "automerge:hold",
"majors": "never"
},
"local-block": {
"begin": "# >>> local (kept by the generator)",
"end": "# <<< local"
},
"max-new-per-run": 5
}
40 changes: 37 additions & 3 deletions dependabot/reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,24 @@ def open_pr(gh, full_name, owner):
return gh.api(f"repos/{full_name}/pulls/{prs[0]['number']}")


def reconcile_repo(repo, owner, gh, shell, opts, policy, workdir):
class Budget:
"""How many NEW pull requests this run may still open. 0 means no limit."""

def __init__(self, limit):
self.limit = limit
self.spent = 0

def take(self):
if self.limit and self.spent >= self.limit:
return False
self.spent += 1
return True


def reconcile_repo(repo, owner, gh, shell, opts, policy, workdir, budget=None):
"""(status, note). Raises on errors; the caller records them."""
name, default = repo["full_name"], repo["default_branch"]
budget = budget if budget is not None else Budget(0)
if not default or default == BRANCH:
return "skipped:default-branch-is-the-reconciler-branch", ""
dest = os.path.join(workdir, name.replace("/", "__"))
Expand Down Expand Up @@ -160,6 +175,12 @@ def reconcile_repo(repo, owner, gh, shell, opts, policy, workdir):
lease = branch_head(gh, name)
if lease:
return "skipped:branch-without-open-pr", f"`{BRANCH}` exists with no open PR; delete it to re-propose"
if not budget.take():
# Only a NEW pull request spends the budget. The branches above return before this,
# so a repository that conforms, is already up to date, or is blocked for another
# reason keeps its own status rather than being reported as deferred.
return "deferred:rate-limit", (f"{', '.join(changed)} -- budget of {budget.limit} new "
"pull requests spent; the next run takes this one")
status = "opened" if opts.apply else "would-open"

if not opts.apply:
Expand Down Expand Up @@ -191,6 +212,10 @@ def main(argv=None, gh=None, shell=None):
ap.add_argument("--policy", default=common.DEFAULT_POLICY)
ap.add_argument("--apply", action="store_true", help="push branches and open/refresh PRs")
ap.add_argument("--repo", action="append", default=[], help="limit to these names (repeatable)")
ap.add_argument("--max-new", type=int, default=None,
help="open at most N NEW pull requests this run (default: policy "
"max-new-per-run). Refreshing one that is already open does not count. "
"0 means no limit.")
ap.add_argument("--git-name", default=os.environ.get("RECONCILE_GIT_NAME", "Imani Manyara"))
ap.add_argument("--git-email", default=os.environ.get("RECONCILE_GIT_EMAIL", "imanimanyara@users.noreply.github.com"))
opts = ap.parse_args(argv)
Expand All @@ -200,6 +225,8 @@ def main(argv=None, gh=None, shell=None):

policy = common.load_policy(opts.policy)
kind = common.account_kind(policy, opts.owner)
if opts.max_new is None:
opts.max_new = int(policy.get("max-new-per-run", 0))
if not os.path.isfile(opts.generator_script):
print(f"reconcile: generator not found at {opts.generator_script}", file=sys.stderr)
return 2
Expand All @@ -213,9 +240,11 @@ def main(argv=None, gh=None, shell=None):
errors = 0
workdir = tempfile.mkdtemp(prefix="dependabot-reconcile-")
try:
budget = Budget(opts.max_new)
for repo in active:
try:
status, note = reconcile_repo(repo, opts.owner, gh, shell, opts, policy, workdir)
status, note = reconcile_repo(repo, opts.owner, gh, shell, opts, policy, workdir,
budget)
except Exception as exc: # one repository's failure must not stop the others
errors += 1
status, note = "error", str(exc)[:300]
Expand All @@ -227,7 +256,12 @@ def main(argv=None, gh=None, shell=None):

rows.sort(key=lambda r: r[0].lower())
mode = "apply" if opts.apply else "dry run"
head = f"## Dependabot reconcile: {opts.owner} ({mode})\n\n{len(repos)} repositories listed, {len(active)} active.\n\n"
deferred = sum(1 for r in rows if r[1] == "deferred:rate-limit")
budget = (f" Budget: {opts.max_new} new pull requests per run"
+ (f", {deferred} deferred to a later run." if deferred else ", none deferred.")
if opts.max_new else "")
head = (f"## Dependabot reconcile: {opts.owner} ({mode})\n\n"
f"{len(repos)} repositories listed, {len(active)} active.{budget}\n\n")
text = head + common.markdown_table(["repo", "status", "note"], rows)
print(text)
common.write_summary(text)
Expand Down
71 changes: 71 additions & 0 deletions dependabot/tests/test_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,77 @@ def test_write_never_carries_strict(self):
self.assertTrue(writes, "drifting repositories are written")
self.assertFalse([c for c in writes if "--strict" in c])

# --- the per-run budget -------------------------------------------------------------
# Going live without one meant 118 pull requests across 256 repositories in a single
# weekly run, measured 2026-11-02. These three cases are what make the switch safe to
# flip: the budget holds, deferring is not skipping, and an open pull request is still
# refreshed once the budget is spent.

def three_drifting(self):
routes = self.base_routes()
routes["orgs/simtabi/repos"] = [repo("d1"), repo("d2"), repo("d3")]
return routes, {"d1": 1, "d2": 1, "d3": 1}

def test_the_budget_caps_new_pull_requests(self):
routes, rc = self.three_drifting()
captured = []
orig = common.write_summary
common.write_summary = captured.append
try:
code, gh, _ = self.run_main(routes, rc, "--apply", "--max-new", "1")
finally:
common.write_summary = orig
self.assertEqual(code, 0)
self.assertEqual(len([r for r in gh.runs if r[1] == "create"]), 1)
self.assertEqual(captured[0].count("deferred:rate-limit"), 2)

def test_a_deferred_repository_is_not_reported_as_skipped(self):
# "skipped" means a decision was taken about that repository. A budget says nothing
# about it, so the status has to read differently or a capped run looks like a clean
# estate.
routes, rc = self.three_drifting()
captured = []
orig = common.write_summary
common.write_summary = captured.append
try:
self.run_main(routes, rc, "--apply", "--max-new", "1")
finally:
common.write_summary = orig
self.assertIn("Budget: 1 new pull requests per run, 2 deferred", captured[0])
self.assertNotIn("skipped:rate", captured[0])

def test_an_open_pull_request_is_still_refreshed_past_the_budget(self):
# pr-stale already has one open; leaving it stale would be worse than opening nothing.
routes = self.base_routes()
routes["orgs/simtabi/repos"] = [repo("d1"), repo("pr-stale")]
code, gh, _ = self.run_main(routes, {"d1": 1, "pr-stale": 1}, "--apply", "--max-new", "1")
self.assertEqual(code, 0)
verbs = sorted(r[1] for r in gh.runs)
self.assertIn("create", verbs)
self.assertIn("edit", verbs)

def test_no_budget_means_no_limit(self):
routes, rc = self.three_drifting()
code, gh, _ = self.run_main(routes, rc, "--apply", "--max-new", "0")
self.assertEqual(code, 0)
self.assertEqual(len([r for r in gh.runs if r[1] == "create"]), 3)

def test_the_budget_does_not_mislabel_a_repository_it_never_reached(self):
# The budget was first spent before the work, so a conforming repository came back
# `deferred` without anyone having looked at it -- laranail read 63 deferred where
# only 64 of 77 drift. Only a NEW pull request may spend it.
routes = self.base_routes()
routes["orgs/simtabi/repos"] = [repo("d1"), repo("d2"), repo("conforming")]
captured = []
orig = common.write_summary
common.write_summary = captured.append
try:
self.run_main(routes, {"d1": 1, "d2": 1, "conforming": 0}, "--apply", "--max-new", "1")
finally:
common.write_summary = orig
self.assertRegex(captured[0], r"\| simtabi/conforming \| conforms \|")
self.assertEqual(captured[0].count("deferred:rate-limit"), 1)

def test_the_rewrite_uses_render_not_check(self):
# `check --write` creates or appends; it does not rewrite a file that is merely
# wrong, which is what drift is. Measured 2026-11-02: every repository sampled came
Expand Down
Loading