From 0b9c36f44ffa1bc49dc1bd633a4b76815f3afdf5 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 12 Oct 2026 07:58:38 -0400 Subject: [PATCH] Add the org Dependabot control plane One Dependabot standard for every Simtabi-owned org, kept in sync and merged by policy from here: - dependabot/policy.json: the standard (schedule, groups, cooldown, PR limit, labels, prefixes, merge policy) and the owned-org list. - reconcile.py + dependabot-reconcile.yml (weekly, after Dependabot's run): renders each active repo's dependabot.yml with agent-kit's generator and opens one PR where it drifts. Refresh Bot token per org; never pushes to a default branch. New repos are picked up on the next run. - merge.py + dependabot-merge.yml (hourly): merges a Dependabot PR only when policy allows its update type (default: GitHub Actions only; majors never) and every check and status on its exact head SHA is green, at least as many as on the repo's last merged PR. Uses the Dependency Merge Bot, whose key is a secret on this repository only. Squash, pinned to the head SHA. Both are dry runs until DEPENDABOT_RECONCILE_APPLY / DEPENDABOT_MERGE_APPLY are set. ci.yml runs the 13 unit tests (30 decide() cases) and actionlint. --- .github/actionlint.yaml | 9 + .github/workflows/ci.yml | 49 +++++ .github/workflows/dependabot-merge.yml | 89 ++++++++ .github/workflows/dependabot-reconcile.yml | 102 +++++++++ dependabot/README.md | 124 +++++++++++ dependabot/common.py | 122 +++++++++++ dependabot/merge.py | 237 +++++++++++++++++++++ dependabot/policy.json | 21 ++ dependabot/reconcile.py | 226 ++++++++++++++++++++ dependabot/tests/test_merge.py | 168 +++++++++++++++ dependabot/tests/test_reconcile.py | 187 ++++++++++++++++ 11 files changed, 1334 insertions(+) create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/dependabot-merge.yml create mode 100644 .github/workflows/dependabot-reconcile.yml create mode 100644 dependabot/README.md create mode 100644 dependabot/common.py create mode 100644 dependabot/merge.py create mode 100644 dependabot/policy.json create mode 100644 dependabot/reconcile.py create mode 100644 dependabot/tests/test_merge.py create mode 100644 dependabot/tests/test_reconcile.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..3ec3220 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,9 @@ +# actionlint 1.7.12 bundles metadata for actions/create-github-app-token from before v3, where +# `client-id` replaced the now-deprecated `app-id`. The v3 action.yml declares `client-id` and no +# required `app-id` (checked 2026-10-12). Ignore exactly those two stale findings; drop this file +# once an actionlint release knows v3. +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + - 'missing input "app-id" which is required by action "actions/create-github-app-token@v3"' + - 'input "client-id" is not defined in action "actions/create-github-app-token@v3"' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee1c39e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: unit tests + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Dependabot control-plane tests + run: python3 -m unittest discover -s dependabot/tests -v + + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + env: + # rhysd/actionlint v1.7.12 (tag commit 914e7df21a07ef503a81201c76d2b11c789d3fca); the + # tarball is pinned by the checksum its release publishes. + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Install actionlint (checksum-verified) + run: | + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSLo "$RUNNER_TEMP/$tarball" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$tarball" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$tarball" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP" actionlint + - name: Lint workflows + run: '"$RUNNER_TEMP/actionlint" -color' diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 0000000..44fe6d0 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,89 @@ +name: Dependabot merge gate + +# Merges a Dependabot pull request only when policy allows its update type and every check on its +# exact head SHA is green, at least as many as on the repository's last merged PR. Rules: +# dependabot/merge.py decide() and dependabot/README.md. +# +# Dry run unless `apply` is ticked on a manual run, or, for scheduled runs, the repository +# variable DEPENDABOT_MERGE_APPLY is 'true'. Going live is that variable, not a code change. + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + inputs: + apply: + description: Merge eligible pull requests (otherwise a dry run) + type: boolean + default: false + +permissions: {} + +# Never two runs at once: a second run could decide on a PR the first is merging. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + accounts: + name: accounts + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + orgs: ${{ steps.list.outputs.orgs }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Org list from policy.json + id: list + run: | + orgs=$(python3 -c 'import json; print(json.dumps(json.load(open("dependabot/policy.json"))["accounts"]["orgs"]))') + echo "orgs=$orgs" >> "$GITHUB_OUTPUT" + + merge: + name: merge (${{ matrix.org }}) + needs: accounts + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 1 + matrix: + org: ${{ fromJSON(needs.accounts.outputs.orgs) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # Dependency Merge Bot, not Refresh Bot: its key is a secret on this repository only, because it + # carries workflows: write (GitHub refuses an App merge that changes .github/workflows/ without + # it, and github-actions updates are the default policy's auto-merged ecosystem). Refresh Bot's + # key is an org secret every public repo can read, so it must never hold that permission. + # checks/statuses: read the head SHA's results. pull-requests + contents: write merge. + - name: Mint a token for ${{ matrix.org }} + id: token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.MERGE_APP_CLIENT_ID }} + private-key: ${{ secrets.MERGE_APP_PRIVATE_KEY }} + owner: ${{ matrix.org }} + permission-contents: write + permission-pull-requests: write + permission-checks: read + permission-statuses: read + permission-workflows: write + + - name: Merge gate for ${{ matrix.org }} + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + ORG: ${{ matrix.org }} + APPLY: ${{ (github.event_name == 'workflow_dispatch' && inputs.apply) || (github.event_name == 'schedule' && vars.DEPENDABOT_MERGE_APPLY == 'true') }} + run: | + args=(--owner "$ORG") + if [ "$APPLY" = "true" ]; then args+=(--apply); fi + python3 dependabot/merge.py "${args[@]}" diff --git a/.github/workflows/dependabot-reconcile.yml b/.github/workflows/dependabot-reconcile.yml new file mode 100644 index 0000000..fc8c2ce --- /dev/null +++ b/.github/workflows/dependabot-reconcile.yml @@ -0,0 +1,102 @@ +name: Dependabot reconcile + +# Brings every active repository of every owned org to the Dependabot standard, one pull request +# per repository, from the shared generator in simtabi/agent-kit. Logic and safety rules: +# dependabot/reconcile.py and dependabot/README.md. +# +# Runs Monday 14:00 UTC, after Dependabot's own 06:00 America/New_York run. Dry run unless +# `apply` is ticked on a manual run, or the repository variable DEPENDABOT_RECONCILE_APPLY is +# 'true' for scheduled runs. The personal account (imanimanyara) is not here: the GitHub App is +# not installed on it, so it is reconciled locally with the personal gh login. + +on: + schedule: + - cron: "0 14 * * 1" + workflow_dispatch: + inputs: + apply: + description: Push branches and open or refresh pull requests (otherwise a dry run) + type: boolean + default: false + +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + accounts: + name: accounts + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + orgs: ${{ steps.list.outputs.orgs }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Org list from policy.json + id: list + run: | + orgs=$(python3 -c 'import json; print(json.dumps(json.load(open("dependabot/policy.json"))["accounts"]["orgs"]))') + echo "orgs=$orgs" >> "$GITHUB_OUTPUT" + + reconcile: + name: reconcile (${{ matrix.org }}) + needs: accounts + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 4 + matrix: + org: ${{ fromJSON(needs.accounts.outputs.orgs) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # agent-kit is private and holds the generator. Read-only, that one repository only. + - name: Mint a read-only token for simtabi/agent-kit + id: kit-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} + owner: simtabi + repositories: agent-kit + permission-contents: read + - uses: actions/checkout@v7 + with: + repository: simtabi/agent-kit + path: .agent-kit + token: ${{ steps.kit-token.outputs.token }} + persist-credentials: false + + # contents: write pushes chore/dependabot-standard; pull-requests: write opens or edits the + # PR. No workflows permission: the reconciler writes .github/dependabot.yml, which is not + # under .github/workflows/, so GitHub does not require it. + - name: Mint a token for ${{ matrix.org }} + id: token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.REFRESH_APP_CLIENT_ID }} + private-key: ${{ secrets.REFRESH_APP_PRIVATE_KEY }} + owner: ${{ matrix.org }} + permission-contents: write + permission-pull-requests: write + + - name: Reconcile ${{ matrix.org }} + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + ORG: ${{ matrix.org }} + APPLY: ${{ (github.event_name == 'workflow_dispatch' && inputs.apply) || (github.event_name == 'schedule' && vars.DEPENDABOT_RECONCILE_APPLY == 'true') }} + run: | + args=(--owner "$ORG" --generator .agent-kit) + if [ "$APPLY" = "true" ]; then args+=(--apply); fi + python3 dependabot/reconcile.py "${args[@]}" diff --git a/dependabot/README.md b/dependabot/README.md new file mode 100644 index 0000000..8c36ef5 --- /dev/null +++ b/dependabot/README.md @@ -0,0 +1,124 @@ +# Dependabot control plane + +> One Dependabot behaviour for every Simtabi-owned account: a reconciler that keeps each repository's `.github/dependabot.yml` on the standard, and a merge gate that lands only the Dependabot pull requests the policy allows and CI has proven. + +Both tools are dry runs unless told otherwise, and neither ever pushes to a default branch. + +## What each piece does + +| Piece | Job | +|---|---| +| `policy.json` | The single source of every value below. Read by both scripts and by the generator. | +| `reconcile.py` | For one account, lists active repositories, shallow-clones each default branch, runs the shared generator (`simtabi/agent-kit` `skills/repo-conform/scripts/dependabot.py check --strict --policy`), and where it reports drift opens or refreshes one pull request, "Adopt the Dependabot standard", from `chore/dependabot-standard`. | +| `merge.py` | For one account, finds open pull requests by `dependabot[bot]`, decides each with the pure function `decide()`, and with `--apply` merges eligible ones with `gh pr merge --squash --match-head-commit `. | +| `common.py` | Policy loading and validation, the `gh` wrapper, repository listing and the active filter, summary output. | +| `.github/workflows/dependabot-reconcile.yml` | Monday 14:00 UTC (after Dependabot's own 06:00 America/New_York run) and on demand, one job per org. | +| `.github/workflows/dependabot-merge.yml` | Hourly at :17 and on demand, one org at a time, never two runs at once. | +| `.github/workflows/ci.yml` | Unit tests and actionlint on every pull request to this repository. | + +Every run prints a table and appends it to the job summary: for the reconciler `repo · status · note`, for the merge gate `repo · PR · action · reason`. + +## Policy values + +| Key | Value | Meaning | +|---|---|---| +| `version` | `1` | Named in the header of every generated file. Bump it when the rendered shape changes. | +| `accounts.orgs` | 12 owned orgs | Reconciled and merge-gated by the workflows, with a Refresh Bot token per org. | +| `accounts.users` | `imanimanyara` | Not covered by the GitHub App; run locally (below). | +| `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. | +| `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. | +| `merge.default` | `actions` | `off`, `actions`, `patch` or `minor`; see below. | +| `merge.overrides` | `{}` | `"owner/repo"` or `"owner/*"` → a merge level. | +| `merge.hold-label` | `automerge:hold` | A pull request carrying it is never merged by the gate. | +| `merge.majors` | `never` | Fixed. Majors are never merged automatically, whatever the level. | +| `local-block` | `# >>> local (kept by the generator)` … `# <<< local` | Repository-specific entries the generator keeps verbatim at the end of `updates:`. | + +Client accounts (`adelsaiq`, `dannielagumo`, `joemanch`) are refused by both scripts even if one is added to `accounts` by mistake: `load_policy()` raises. + +## Merge rules + +`decide(pr, checks, statuses, floor, policy)` returns `(merge, reason)`, first failing rule wins: + +1. Not a draft, and no `automerge:hold` label. +2. The update type is known and allowed. The ecosystem comes from the branch (`dependabot/github_actions/…` is `github-actions`). A group pull request counts as minor only when its group is the policy's `-minor-and-patch`; any other group name is not trusted. A single update is classified from "from X to Y" in the title by semver; under 1.0 a minor bump counts as major. Pre-releases, SHAs and anything else unparseable are not allowed. +3. Levels: `off` allows nothing; `actions` allows non-major GitHub Actions updates; `patch` adds other ecosystems' patches; `minor` adds their minors. Majors never. Override precedence is `owner/repo`, then `owner/*`, then `default`. +4. Every check run on the head SHA has completed with `success`, `neutral` or `skipped`, and every commit status is `success`. Anything pending holds. +5. At least as many check runs as the floor: the count on the head of the repository's most recently merged pull request not authored by Dependabot. With no such pull request the floor is 1 and one check name must look like a test (`test`, `pest`, `phpunit`, `pytest`, `jest`, `vitest`, `spec`). +6. `mergeable_state` is `clean`. GitHub computes it lazily, so a first read of `unknown` holds until the next hourly run. + +A workflow that is path-filtered off a Dependabot pull request lowers its check count below the floor, and the pull request is held for a person. That is the intended failure direction. + +## Overriding per repository + +Add an entry to `merge.overrides` in a pull request to this repository: + +```json +"overrides": { + "laranail/emojis": "patch", + "ichava/*": "minor", + "laranail/package-tools": "off" +} +``` + +To keep a repository-specific Dependabot entry (an extra directory, an `ignore`, a registry), put it inside the local block of that repository's `dependabot.yml`. To stop one pull request from merging, add the `automerge:hold` label. To take a repository out entirely, add it to `exclude` with a reason. + +## Credentials + +| Workflow | App | Where its key lives | Permissions | +|---|---|---|---| +| reconcile | Refresh Bot | org secret `REFRESH_APP_PRIVATE_KEY`, variable `REFRESH_APP_CLIENT_ID` (every owned org) | contents, pull requests (write) | +| merge | Dependency Merge Bot | repository secret `MERGE_APP_PRIVATE_KEY`, variable `MERGE_APP_CLIENT_ID` on `simtabi/.github` only | contents, pull requests, workflows (write); checks, statuses (read) | + +The merge gate gets its own app because it needs `workflows: write`, and Refresh Bot's key is readable by +every public repository's workflows. Create it with `imanimanyara/github-apps` +(`docs/merge-bot.md`): `CLIENT_ID_VAR=MERGE_APP_CLIENT_ID PRIVATE_KEY_SECRET=MERGE_APP_PRIVATE_KEY bin/create-app manifests/merge-bot.json simtabi/.github`, +then install it on every org in `accounts.orgs`. + +## Going live + +Both workflows ship as dry runs. + +| Step | How | +|---|---| +| Try one run | Actions → the workflow → Run workflow, with `apply` ticked. | +| Reconciler on schedule | Set the repository variable `DEPENDABOT_RECONCILE_APPLY` to `true`. | +| Merge gate on schedule | Set the repository variable `DEPENDABOT_MERGE_APPLY` to `true`. | +| Back to dry run | Delete the variable or set it to anything else. | + +**Prerequisite for the merge gate:** Refresh Bot's manifest grants `contents` and `pull_requests` only. The gate also needs **checks: read**, **statuses: read** and **workflows: write** (merging an Actions update changes `.github/workflows/`, which GitHub refuses an App without it). Until the App is granted those and each org approves the new permissions, the token step fails rather than the gate running half-blind. The reconciler needs nothing beyond the current two. + +## The personal account + +The GitHub App is not installed on `imanimanyara`, so run both locally with the personal login, from this repository's root: + +```bash +GH_TOKEN=$(gh auth token) python3 dependabot/reconcile.py --owner imanimanyara \ + --generator ~/Artisan/projects/opensource/simtabi/packages/agent-kit +GH_TOKEN=$(gh auth token) python3 dependabot/merge.py --owner imanimanyara +``` + +Add `--apply` only after reading the dry-run table. Assert the login first: `[ "$(gh api user --jq .login)" = imanimanyara ]`. The `--repo ` flag limits the reconciler to named repositories. + +## Safety notes + +- **No default-branch push.** The reconciler's refspec is `HEAD:refs/heads/chore/dependabot-standard`, with `--force-with-lease` against the branch head it read, so a commit someone else added is never overwritten. A pull request with more than one commit is skipped. +- **A closed pull request is a decision.** If the branch exists with no open pull request, the repository is reported as `skipped:branch-without-open-pr` and nothing is re-proposed until the branch is deleted. +- **Identical content is not re-pushed.** An open pull request whose files already match is reported as `skipped:pr-up-to-date`. +- **Generator exit 2 is a broken input, not a clean repository**, and is reported as skipped. +- **Merges are pinned** with `--match-head-commit`, so a push after the decision fails the merge. +- **The token never lands on disk.** git reads `GH_TOKEN` through a credential helper at run time. +- **Errors are per repository or per pull request.** One failure is reported and the run continues; the exit status is non-zero only when something errored. +- **No `pull_request_target`.** Every workflow starts from `permissions: {}` and grants per job; the App tokens carry only the permissions named in each step. + +## Tests + +```bash +python3 -m unittest discover -s dependabot/tests -v +``` + +No network: `gh`, `git` and the generator are replaced with fakes. diff --git a/dependabot/common.py b/dependabot/common.py new file mode 100644 index 0000000..8108582 --- /dev/null +++ b/dependabot/common.py @@ -0,0 +1,122 @@ +"""Shared plumbing for the Dependabot control plane: policy, the `gh` runner, repo listing, output. + +Standard library only. Every GitHub call goes through a `Gh` instance, so the tests replace it +with a fake and never touch the network. +""" +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_POLICY = os.path.join(HERE, "policy.json") + +# Client-owned accounts. Simtabi builds them and owns none of them, so nothing here may act on +# them even if a policy edit lists one by mistake. +CLIENT_ACCOUNTS = frozenset({"adelsaiq", "dannielagumo", "joemanch"}) + +MERGE_LEVELS = ("off", "actions", "patch", "minor") + + +class GhError(RuntimeError): + pass + + +def load_policy(path=DEFAULT_POLICY): + with open(path, encoding="utf-8") as fh: + policy = json.load(fh) + if policy.get("version") != 1: + raise ValueError(f"{path}: unsupported policy version {policy.get('version')!r}") + bad = CLIENT_ACCOUNTS & set(policy["accounts"]["orgs"] + policy["accounts"]["users"]) + if bad: + raise ValueError(f"{path}: client accounts must never be managed: {sorted(bad)}") + for value in [policy["merge"]["default"], *policy["merge"]["overrides"].values()]: + if value not in MERGE_LEVELS: + raise ValueError(f"{path}: merge level {value!r} is not one of {MERGE_LEVELS}") + return policy + + +def account_kind(policy, owner): + """'org' or 'user' for an account the policy manages; ValueError otherwise.""" + if owner in CLIENT_ACCOUNTS: + raise ValueError(f"{owner} is a client account and is never managed") + if owner in policy["accounts"]["orgs"]: + return "org" + if owner in policy["accounts"]["users"]: + return "user" + raise ValueError(f"{owner} is not listed in the policy's accounts") + + +class Gh: + """Thin wrapper over the `gh` CLI. GH_TOKEN comes from the environment.""" + + def run(self, args, input_text=None, check=True): + r = subprocess.run(["gh", *args], capture_output=True, text=True, input=input_text) + if check and r.returncode != 0: + raise GhError(f"gh {' '.join(args[:3])}: {r.stderr.strip() or r.stdout.strip()}") + return r + + def api(self, path, paginate=False): + """Parsed JSON. With paginate, list pages are flattened into one list; object pages + (check-runs, search) are returned as a list of pages for the caller to flatten.""" + args = ["api", "-H", "Accept: application/vnd.github+json", path] + if paginate: + args[1:1] = ["--paginate", "--slurp"] + out = self.run(args).stdout + data = json.loads(out or "null") + if paginate and isinstance(data, list) and all(isinstance(p, list) for p in data): + return [item for page in data for item in page] + return data + + +def list_repos(gh, owner, kind): + """Every repository of the account, as the API returns them.""" + if kind == "org": + return gh.api(f"orgs/{owner}/repos?type=all&per_page=100", paginate=True) + # A user's private repos are only listed for that user's own token. + me = gh.api("user") or {} + if me.get("login") == owner: + return gh.api("user/repos?affiliation=owner&per_page=100", paginate=True) + return gh.api(f"users/{owner}/repos?type=owner&per_page=100", paginate=True) + + +def is_empty(gh, repo): + """No commits. `size` is 0 for an empty repo but also for some tiny ones, so confirm.""" + if repo.get("size", 1) != 0: + return False + branches = gh.api(f"repos/{repo['full_name']}/branches?per_page=1") + return not branches + + +def active_repos(gh, policy, repos): + """(active, skipped) where skipped is [(full_name, reason)], per the contract: + not archived, not empty, not a fork, not in exclude.""" + excluded = {e["repo"].lower(): e.get("reason", "") for e in policy.get("exclude", [])} + active, skipped = [], [] + for repo in sorted(repos, key=lambda r: r["full_name"].lower()): + name = repo["full_name"] + if name.lower() in excluded: + skipped.append((name, f"excluded: {excluded[name.lower()] or 'policy'}")) + elif repo.get("archived"): + skipped.append((name, "archived")) + elif repo.get("fork"): + skipped.append((name, "fork")) + elif is_empty(gh, repo): + skipped.append((name, "empty")) + else: + active.append(repo) + return active, skipped + + +def markdown_table(headers, rows): + def cell(v): + return str(v).replace("|", "\\|").replace("\n", " ") + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + lines += ["| " + " | ".join(cell(v) for v in row) + " |" for row in rows] + return "\n".join(lines) + "\n" + + +def write_summary(text): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a", encoding="utf-8") as fh: + fh.write(text + "\n") diff --git a/dependabot/merge.py b/dependabot/merge.py new file mode 100644 index 0000000..5aba4ec --- /dev/null +++ b/dependabot/merge.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Merge the Dependabot pull requests of one account that the policy allows and CI has proven. + +Usage: + merge.py --owner [--policy PATH] [--apply] + +Every open pull request authored by dependabot[bot] gets one decision from `decide()`, a pure +function, and the reason is printed whether or not it merges. Dry run is the default. With +`--apply`, an eligible pull request is merged with + + gh pr merge --squash --match-head-commit + +so a commit pushed after the decision was made makes the merge fail instead of landing unchecked. +Anything not eligible stays open for a person; manual merging is unchanged. +""" +import argparse +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import common # noqa: E402 + +DEPENDABOT = "dependabot[bot]" +OK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"}) +TEST_LIKE = re.compile(r"test|pest|phpunit|pytest|jest|vitest|spec", re.I) + +# Dependabot's branch directory per ecosystem -> the policy's ecosystem name. +BRANCH_ECOSYSTEMS = { + "github_actions": "github-actions", + "composer": "composer", + "npm_and_yarn": "npm", + "pip": "pip", + "go_modules": "gomod", + "cargo": "cargo", + "docker": "docker", + "bundler": "bundler", +} +VERSION = re.compile(r"^v?(\d+(?:\.\d+){0,3})$") +FROM_TO = re.compile(r"\bfrom (\S+) to (\S+?)(?:\s|$)") +GROUP = re.compile(r"\bthe (\S+) group\b") + + +def ecosystem_of(head_ref): + parts = head_ref.split("/") + if len(parts) < 3 or parts[0] != "dependabot": + return None + return BRANCH_ECOSYSTEMS.get(parts[1]) + + +def semver_change(old, new): + """'major' | 'minor' | 'patch' | None (unparseable, equal, or a downgrade). + + Pre-releases and anything that is not 1-4 numeric components are unparseable. Under 1.0 a + minor bump is breaking (semver item 4), so 0.x -> 0.y counts as major. + """ + a, b = VERSION.match(old), VERSION.match(new) + if not a or not b: + return None + x = [int(p) for p in a.group(1).split(".")] + [0] * 4 + y = [int(p) for p in b.group(1).split(".")] + [0] * 4 + x, y = x[:4], y[:4] + if y <= x: + return None + if y[0] != x[0]: + return "major" + if y[1] != x[1]: + return "major" if x[0] == 0 else "minor" + return "patch" + + +def update_type(pr, policy): + """(ecosystem, 'major'|'minor'|'patch'|None, how).""" + eco = ecosystem_of(pr["head_ref"]) + title = pr["title"] + g = GROUP.search(title) + if g: + expected = policy["groups"]["name-pattern"].replace("{ecosystem}", eco or "?") + if eco and g.group(1) == expected: + return eco, "minor", f"group {expected}" + return eco, None, f"group {g.group(1)!r} is not the policy group" + m = FROM_TO.search(title) + if not m: + return eco, None, "no 'from X to Y' in the title" + return eco, semver_change(m.group(1), m.group(2)), f"{m.group(1)} -> {m.group(2)}" + + +def merge_level(policy, repo): + overrides = policy["merge"]["overrides"] + owner = repo.split("/")[0] + level = overrides.get(repo, overrides.get(f"{owner}/*", policy["merge"]["default"])) + return level if level in common.MERGE_LEVELS else "off" + + +def allowed(eco, kind, level): + if kind is None or kind == "major" or level == "off": + return False # majors never, whatever the policy says + if eco == "github-actions": + return True # every level from "actions" up allows non-major Actions updates + if level == "patch": + return kind == "patch" + if level == "minor": + return kind in ("patch", "minor") + return False + + +def decide(pr, checks, statuses, floor, policy): + """(merge, reason). Pure: no I/O. + + pr: {repo, number, title, head_ref, head_sha, draft, mergeable_state, labels: [str]} + checks: check runs on the head SHA ({name, status, conclusion}). + statuses: latest commit status per context on the head SHA ({context, state}). + floor: check runs on the head of the repo's last merged non-Dependabot PR, or None when + there is none (then at least one check is required, and one must look like a test). + """ + if pr.get("draft"): + return False, "draft" + hold = policy["merge"]["hold-label"] + if hold in pr.get("labels", []): + return False, f"held by label {hold}" + + eco, kind, how = update_type(pr, policy) + if eco is None: + return False, f"unknown ecosystem for branch {pr['head_ref']}" + if kind is None: + return False, f"update type not recognised ({how})" + if kind == "major": + return False, f"major update ({how}); majors are never merged automatically" + level = merge_level(policy, pr["repo"]) + if not allowed(eco, kind, level): + return False, f"{eco} {kind} not allowed by merge policy {level!r}" + + pending = [c["name"] for c in checks if c.get("status") != "completed"] + pending += [s["context"] for s in statuses if s.get("state") == "pending"] + if pending: + return False, f"pending: {', '.join(sorted(pending))}" + failing = [f"{c['name']}={c.get('conclusion')}" for c in checks if c.get("conclusion") not in OK_CONCLUSIONS] + failing += [f"{s['context']}={s.get('state')}" for s in statuses if s.get("state") != "success"] + if failing: + return False, f"failing: {', '.join(sorted(failing))}" + + need = 1 if floor is None else max(floor, 1) + if len(checks) < need: + return False, f"{len(checks)} check runs reported, floor is {need}" + if floor is None and not any(TEST_LIKE.search(c["name"]) for c in checks): + return False, "no merged non-Dependabot PR to set a floor, and no test-like check" + + if pr.get("mergeable_state") != "clean": + return False, f"mergeable_state is {pr.get('mergeable_state')!r}" + return True, f"{eco} {kind} ({how}); {len(checks)} checks green, floor {need}" + + +# ---- I/O ---------------------------------------------------------------------------------------- + +def flatten(pages, key): + if isinstance(pages, dict): + pages = [pages] + return [item for page in pages or [] for item in page.get(key, [])] + + +def check_runs(gh, repo, sha): + return flatten(gh.api(f"repos/{repo}/commits/{sha}/check-runs?per_page=100", paginate=True), "check_runs") + + +def commit_statuses(gh, repo, sha): + return (gh.api(f"repos/{repo}/commits/{sha}/status") or {}).get("statuses", []) + + +def floor_for(gh, repo): + """Check runs on the head of the most recently merged PR not authored by Dependabot.""" + prs = gh.api(f"repos/{repo}/pulls?state=closed&sort=updated&direction=desc&per_page=50") or [] + merged = [p for p in prs if p.get("merged_at") and p["user"]["login"] != DEPENDABOT] + if not merged: + return None + last = max(merged, key=lambda p: p["merged_at"]) + return len(check_runs(gh, repo, last["head"]["sha"])) + + +def dependabot_prs(gh, owner, kind): + qualifier = "org" if kind == "org" else "user" + q = f"{qualifier}:{owner}+is:pr+is:open+author:app/dependabot" + items = flatten(gh.api(f"search/issues?q={q}&per_page=100", paginate=True), "items") + for item in items: + repo = item["repository_url"].split("/repos/", 1)[1] + yield repo, item["number"] + + +def main(argv=None, gh=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--owner", required=True) + ap.add_argument("--policy", default=common.DEFAULT_POLICY) + ap.add_argument("--apply", action="store_true", help="merge the eligible pull requests") + opts = ap.parse_args(argv) + gh = gh or common.Gh() + policy = common.load_policy(opts.policy) + kind = common.account_kind(policy, opts.owner) + excluded = {e["repo"].lower() for e in policy.get("exclude", [])} + + rows, errors, floors = [], 0, {} + for repo, number in sorted(dependabot_prs(gh, opts.owner, kind)): + try: + if repo.lower() in excluded: + rows.append((repo, number, "skip", "repository excluded by policy")) + continue + raw = gh.api(f"repos/{repo}/pulls/{number}") + pr = { + "repo": repo, "number": number, "title": raw["title"], "head_ref": raw["head"]["ref"], + "head_sha": raw["head"]["sha"], "draft": raw.get("draft", False), + "mergeable_state": raw.get("mergeable_state"), + "labels": [lb["name"] for lb in raw.get("labels", [])], + } + if repo not in floors: + floors[repo] = floor_for(gh, repo) + ok, reason = decide(pr, check_runs(gh, repo, pr["head_sha"]), + commit_statuses(gh, repo, pr["head_sha"]), floors[repo], policy) + action = "hold" + if ok: + action = "would merge" + if opts.apply: + gh.run(["pr", "merge", str(number), "--repo", repo, "--squash", + "--match-head-commit", pr["head_sha"]]) + action = "merged" + rows.append((repo, number, action, reason)) + except Exception as exc: # one PR's failure must not stop the others + errors += 1 + rows.append((repo, number, "error", str(exc)[:300])) + + mode = "apply" if opts.apply else "dry run" + text = (f"## Dependabot merge gate: {opts.owner} ({mode})\n\n{len(rows)} open Dependabot pull requests.\n\n" + + common.markdown_table(["repo", "PR", "action", "reason"], rows)) + print(text) + common.write_summary(text) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dependabot/policy.json b/dependabot/policy.json new file mode 100644 index 0000000..5db9506 --- /dev/null +++ b/dependabot/policy.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "accounts": { + "orgs": ["brandgulp", "ichava", "laranail", "mukoracms", "simsange", "simtabi", "tayariai", "tayaricms", "the-omaris", "tusente", "simbi-prjs-clt", "simbi-prjs-int"], + "users": ["imanimanyara"] + }, + "exclude": [], + "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"} +} diff --git a/dependabot/reconcile.py b/dependabot/reconcile.py new file mode 100644 index 0000000..07bf16b --- /dev/null +++ b/dependabot/reconcile.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Bring every active repository of one account to the Dependabot standard, by pull request. + +Usage: + reconcile.py --owner --generator [--policy PATH] [--apply] + +For each active repository (not archived, not empty, not a fork, not excluded by the policy) it +shallow-clones the default branch and runs the shared generator: + + python3 /skills/repo-conform/scripts/dependabot.py check --strict --policy + +Exit 0 conforms. Exit 1 is drift: the generator renders the file with `--write`, and with +`--apply` the result is pushed to `chore/dependabot-standard` and one pull request titled +"Adopt the Dependabot standard" is opened, or refreshed when it is already open. Exit 2 (nothing +inspected) and any other code skip the repository and are reported. + +Safety: the default branch is never pushed to; the push names `refs/heads/chore/dependabot-standard` +explicitly and uses a lease, so a commit someone else added to that branch is never overwritten. +An open PR whose content already matches is left alone. A branch that exists with no open PR (a +person closed it) is reported, not re-proposed. + +Dry run is the default and makes no GitHub writes. Exit status is non-zero only on errors. +GH_TOKEN comes from the environment; it is handed to git through a credential helper that reads +the variable at run time, so it is never written into a clone's config. +""" +import argparse +import base64 +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import common # noqa: E402 + +BRANCH = "chore/dependabot-standard" +TITLE = "Adopt the Dependabot standard" +GENERATOR = os.path.join("skills", "repo-conform", "scripts", "dependabot.py") +GIT_AUTH = [ + "-c", "credential.helper=", + "-c", 'credential.helper=!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f', +] + + +class Shell: + def run(self, cmd, check=True, input_text=None): + r = subprocess.run(cmd, capture_output=True, text=True, input=input_text) + if check and r.returncode != 0: + raise RuntimeError(f"{' '.join(cmd[:4])}: {r.stderr.strip() or r.stdout.strip()}") + return r + + +def pr_body(policy, default_branch): + begin, end = policy["local-block"]["begin"], policy["local-block"]["end"] + return f"""This brings `.github/dependabot.yml` to the Simtabi Dependabot standard (policy version {policy['version']}). + +**What the standard is.** One entry per ecosystem this repository actually has, each with: + +- a weekly schedule: {policy['schedule']['day'].title()} {policy['schedule']['time']}, {policy['schedule']['timezone']}; +- a `{policy['groups']['name-pattern']}` group, so minor and patch updates arrive as one pull request per ecosystem and majors stay one pull request each; +- a cooldown of {policy['cooldown']['default-days']} days ({policy['cooldown']['semver-major-days']} for majors), so a release is not proposed the day it ships; +- `open-pull-requests-limit: {policy['open-pull-requests-limit']}`, the `dependencies` label and a commit-message prefix per ecosystem. + +**The local block.** Anything this repository needs beyond the standard goes between these two lines, at the end of `updates:`: + +```yaml +{begin} +{end} +``` + +The generator keeps that block verbatim on every run. Everything outside it is rendered, so a hand edit there is replaced by the next reconcile. + +**How this pull request is maintained.** The reconciler in `simtabi/.github` (`dependabot/reconcile.py`) re-renders the file weekly. While this pull request is open it refreshes this branch only; it never pushes to `{default_branch}`. Close it to decline the change; the branch is then left alone and the repository is reported as skipped until the branch is deleted. + +Auto-merge of Dependabot's own pull requests is decided separately by `dependabot/merge.py` in the same repository; see its README for the rules.""" + + +def changed_paths(shell, dest): + out = shell.run(["git", "-C", dest, "status", "--porcelain", "-z", "--untracked-files=all"]).stdout + return sorted(entry[3:] for entry in out.split("\0") if len(entry) > 3) + + +def read_local(dest, path): + try: + with open(os.path.join(dest, path), encoding="utf-8") as fh: + return fh.read() + except OSError: + return None + + +def read_remote(gh, full_name, path, ref): + try: + data = gh.api(f"repos/{full_name}/contents/{path}?ref={ref}") + except common.GhError: + return None + if not isinstance(data, dict) or "content" not in data: + return None + return base64.b64decode(data["content"]).decode("utf-8") + + +def branch_head(gh, full_name): + try: + ref = gh.api(f"repos/{full_name}/git/ref/heads/{BRANCH}") + except common.GhError: + return None + return (ref or {}).get("object", {}).get("sha") + + +def open_pr(gh, full_name, owner): + prs = gh.api(f"repos/{full_name}/pulls?state=open&head={owner}:{BRANCH}&per_page=10") or [] + if not prs: + return None + return gh.api(f"repos/{full_name}/pulls/{prs[0]['number']}") + + +def reconcile_repo(repo, owner, gh, shell, opts, policy, workdir): + """(status, note). Raises on errors; the caller records them.""" + name, default = repo["full_name"], repo["default_branch"] + if not default or default == BRANCH: + return "skipped:default-branch-is-the-reconciler-branch", "" + dest = os.path.join(workdir, name.replace("/", "__")) + url = f"https://github.com/{name}.git" + shell.run(["git", *GIT_AUTH, "clone", "--quiet", "--depth", "1", "--branch", default, url, dest]) + + gen = [sys.executable, opts.generator_script, "check", dest, "--strict", "--policy", opts.policy] + rc = shell.run(gen, check=False).returncode + pr = open_pr(gh, name, owner) + if rc == 0: + return "conforms", (f"open PR #{pr['number']} is no longer needed" if pr else "") + if rc == 2: + return "skipped:generator-exit-2", "nothing inspected" + if rc != 1: + return f"skipped:generator-exit-{rc}", "the generator could not render this file safely" + + rc = shell.run(gen + ["--write"], check=False).returncode + if rc not in (0, 1): + return f"skipped:write-exit-{rc}", "" + changed = changed_paths(shell, dest) + if not changed: + return "skipped:write-made-no-change", "drift the generator does not fix" + + if pr: + if all(read_local(dest, p) == read_remote(gh, name, p, BRANCH) for p in changed): + return "skipped:pr-up-to-date", f"PR #{pr['number']}" + if pr.get("commits", 1) != 1: + return "skipped:pr-has-extra-commits", f"PR #{pr['number']} carries commits the reconciler did not make" + status, lease = ("updated" if opts.apply else "would-update"), pr["head"]["sha"] + else: + 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" + status = "opened" if opts.apply else "would-open" + + if not opts.apply: + return status, ", ".join(changed) + + # The default branch is never a push target: the refspec names the reconciler branch only. + assert BRANCH != default + shell.run(["git", "-C", dest, "checkout", "--quiet", "-B", BRANCH]) + shell.run(["git", "-C", dest, "add", "--", *changed]) + shell.run(["git", "-C", dest, "-c", f"user.name={opts.git_name}", "-c", f"user.email={opts.git_email}", + "commit", "--quiet", "-m", TITLE, + "-m", f"Rendered by dependabot/reconcile.py from policy version {policy['version']}."]) + shell.run(["git", "-C", dest, *GIT_AUTH, "push", "--quiet", + f"--force-with-lease=refs/heads/{BRANCH}:{lease or ''}", + "origin", f"HEAD:refs/heads/{BRANCH}"]) + body = pr_body(policy, default) + if pr: + gh.run(["pr", "edit", str(pr["number"]), "--repo", name, "--body-file", "-"], input_text=body) + return status, f"PR #{pr['number']}" + out = gh.run(["pr", "create", "--repo", name, "--base", default, "--head", BRANCH, + "--title", TITLE, "--body-file", "-"], input_text=body).stdout.strip() + return status, out + + +def main(argv=None, gh=None, shell=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--owner", required=True) + ap.add_argument("--generator", required=True, help="path to a simtabi/agent-kit checkout") + 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("--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) + opts.policy = os.path.abspath(opts.policy) + opts.generator_script = os.path.join(os.path.abspath(opts.generator), GENERATOR) + gh, shell = gh or common.Gh(), shell or Shell() + + policy = common.load_policy(opts.policy) + kind = common.account_kind(policy, opts.owner) + if not os.path.isfile(opts.generator_script): + print(f"reconcile: generator not found at {opts.generator_script}", file=sys.stderr) + return 2 + + repos = common.list_repos(gh, opts.owner, kind) + if opts.repo: + wanted = {r.split("/")[-1].lower() for r in opts.repo} + repos = [r for r in repos if r["name"].lower() in wanted] + active, skipped = common.active_repos(gh, policy, repos) + rows = [(name, f"skipped:{reason.split(':')[0]}", reason) for name, reason in skipped] + errors = 0 + workdir = tempfile.mkdtemp(prefix="dependabot-reconcile-") + try: + for repo in active: + try: + status, note = reconcile_repo(repo, opts.owner, gh, shell, opts, policy, workdir) + except Exception as exc: # one repository's failure must not stop the others + errors += 1 + status, note = "error", str(exc)[:300] + rows.append((repo["full_name"], status, note)) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + 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" + text = head + common.markdown_table(["repo", "status", "note"], rows) + print(text) + common.write_summary(text) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dependabot/tests/test_merge.py b/dependabot/tests/test_merge.py new file mode 100644 index 0000000..c68d191 --- /dev/null +++ b/dependabot/tests/test_merge.py @@ -0,0 +1,168 @@ +import copy +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import common # noqa: E402 +import merge # noqa: E402 + +POLICY = common.load_policy() + + +def policy(**merge_overrides): + p = copy.deepcopy(POLICY) + p["merge"].update(merge_overrides) + return p + + +def pr(**kw): + base = { + "repo": "laranail/emojis", "number": 7, "draft": False, "mergeable_state": "clean", "labels": [], + "head_sha": "abc", "head_ref": "dependabot/github_actions/shivammathur/setup-php-2.37.2", + "title": "chore(actions): bump shivammathur/setup-php from 2.37.1 to 2.37.2", + } + base.update(kw) + return base + + +def green(*names): + return [{"name": n, "status": "completed", "conclusion": "success"} for n in names] + + +GREEN3 = green("tests (8.4)", "tests (8.5)", "static analysis") +COMPOSER_PATCH = dict(head_ref="dependabot/composer/laravel/framework-13.1.2", + title="deps(composer): bump laravel/framework from 13.1.1 to 13.1.2") +COMPOSER_MINOR = dict(head_ref="dependabot/composer/laravel/framework-13.2.0", + title="deps(composer): bump laravel/framework from 13.1.1 to 13.2.0") + + +class DecideTest(unittest.TestCase): + # (label, pr kwargs, checks, statuses, floor, policy, expected merge, reason fragment) + CASES = [ + ("actions patch, all green", {}, GREEN3, [], 3, POLICY, True, "github-actions patch"), + ("failing check", {}, GREEN3[:2] + [{"name": "static analysis", "status": "completed", "conclusion": "failure"}], + [], 3, POLICY, False, "failing: static analysis=failure"), + ("pending check", {}, GREEN3[:2] + [{"name": "static analysis", "status": "in_progress", "conclusion": None}], + [], 3, POLICY, False, "pending: static analysis"), + ("pending commit status", {}, GREEN3, [{"context": "GitGuardian", "state": "pending"}], 3, POLICY, + False, "pending: GitGuardian"), + ("failed commit status", {}, GREEN3, [{"context": "GitGuardian", "state": "failure"}], 3, POLICY, + False, "failing: GitGuardian=failure"), + ("neutral and skipped are fine", {}, green("tests", "lint") + [ + {"name": "docs", "status": "completed", "conclusion": "skipped"}, + {"name": "scorecard", "status": "completed", "conclusion": "neutral"}], [], 4, POLICY, True, "4 checks"), + ("below floor", {}, GREEN3, [], 5, POLICY, False, "3 check runs reported, floor is 5"), + ("no floor, test-like check present", {}, green("tests (8.4)"), [], None, POLICY, True, "floor 1"), + ("no floor, no test-like check", {}, green("lint"), [], None, POLICY, False, "no test-like check"), + ("no floor, no checks at all", {}, [], [], None, POLICY, False, "0 check runs reported, floor is 1"), + ("hold label", {"labels": ["dependencies", "automerge:hold"]}, GREEN3, [], 3, POLICY, False, "held by label"), + ("draft", {"draft": True}, GREEN3, [], 3, POLICY, False, "draft"), + ("not clean", {"mergeable_state": "blocked"}, GREEN3, [], 3, POLICY, False, "'blocked'"), + ("unknown mergeable state", {"mergeable_state": "unknown"}, GREEN3, [], 3, POLICY, False, "'unknown'"), + ("actions major", {"title": "chore(actions): bump actions/checkout from 6 to 7", + "head_ref": "dependabot/github_actions/actions/checkout-7"}, + GREEN3, [], 3, policy(default="minor"), False, "major update"), + ("composer 0.x minor is major", {"head_ref": "dependabot/composer/laranail/console-0.2.0", + "title": "deps(composer): bump laranail/console from 0.1.4 to 0.2.0"}, + GREEN3, [], 3, policy(default="minor"), False, "major update"), + ("actions group counts as minor", {"head_ref": "dependabot/github_actions/github-actions-minor-and-patch-1a2b", + "title": "chore(actions): bump the github-actions-minor-and-patch group with 3 updates"}, + GREEN3, [], 3, POLICY, True, "group github-actions-minor-and-patch"), + ("composer group under actions-only", {"head_ref": "dependabot/composer/composer-minor-and-patch-1a2b", + "title": "deps(composer): bump the composer-minor-and-patch group with 2 updates"}, + GREEN3, [], 3, POLICY, False, "composer minor not allowed by merge policy 'actions'"), + ("composer group under minor", {"head_ref": "dependabot/composer/composer-minor-and-patch-1a2b", + "title": "deps(composer): bump the composer-minor-and-patch group across 1 directory with 2 updates"}, + GREEN3, [], 3, policy(default="minor"), True, "composer minor"), + ("foreign group name is not trusted", {"head_ref": "dependabot/composer/minor-and-patch-1a2b", + "title": "Bump the minor-and-patch group with 2 updates"}, + GREEN3, [], 3, policy(default="minor"), False, "not the policy group"), + ("actions-only rejects composer patch", COMPOSER_PATCH, GREEN3, [], 3, POLICY, False, + "composer patch not allowed by merge policy 'actions'"), + ("repo override to patch accepts composer patch", COMPOSER_PATCH, GREEN3, [], 3, + policy(overrides={"laranail/emojis": "patch"}), True, "composer patch"), + ("patch override still rejects composer minor", COMPOSER_MINOR, GREEN3, [], 3, + policy(overrides={"laranail/emojis": "patch"}), False, "composer minor not allowed"), + ("org wildcard override", COMPOSER_MINOR, GREEN3, [], 3, policy(overrides={"laranail/*": "minor"}), True, + "composer minor"), + ("repo override beats org wildcard", COMPOSER_PATCH, GREEN3, [], 3, + policy(overrides={"laranail/*": "minor", "laranail/emojis": "off"}), False, "policy 'off'"), + ("off rejects actions", {}, GREEN3, [], 3, policy(default="off"), False, "policy 'off'"), + ("unparseable title", {"title": "Bump something"}, GREEN3, [], 3, POLICY, False, "not recognised"), + ("sha pins are unparseable", {"title": "bump foo/bar from 1a2b3c4 to 5d6e7f8"}, GREEN3, [], 3, POLICY, + False, "not recognised"), + ("pre-release is unparseable", {"title": "bump foo/bar from 2.0.0 to 2.0.1-rc1"}, GREEN3, [], 3, POLICY, + False, "not recognised"), + ("non-dependabot branch", {"head_ref": "feature/x"}, GREEN3, [], 3, POLICY, False, "unknown ecosystem"), + ] + + def test_cases(self): + for label, kw, checks, statuses, floor, pol, want, fragment in self.CASES: + with self.subTest(label): + ok, reason = merge.decide(pr(**kw), checks, statuses, floor, pol) + self.assertEqual(ok, want, reason) + self.assertIn(fragment, reason) + + def test_case_table_is_not_vacuous(self): + self.assertGreaterEqual(len(self.CASES), 25) + self.assertTrue(any(c[6] for c in self.CASES) and not all(c[6] for c in self.CASES)) + + +class SemverTest(unittest.TestCase): + def test_classification(self): + table = [("1.2.3", "1.2.4", "patch"), ("1.2.3", "1.3.0", "minor"), ("1.2.3", "2.0.0", "major"), + ("v4", "v5", "major"), ("6", "6.1", "minor"), ("0.1.0", "0.1.1", "patch"), + ("0.1.0", "0.2.0", "major"), ("1.2.3", "1.2.3", None), ("1.3.0", "1.2.9", None), + ("1.2.3", "1.2.4-beta", None), ("abc", "def", None), ("1.2.3.4", "1.2.3.5", "patch")] + for old, new, want in table: + with self.subTest(f"{old}->{new}"): + self.assertEqual(merge.semver_change(old, new), want) + + +class FakeGh: + def __init__(self, routes): + self.routes, self.calls, self.runs = routes, [], [] + + def api(self, path, paginate=False): + self.calls.append(path) + for prefix, value in self.routes.items(): + if path.startswith(prefix): + return value + raise common.GhError(f"no route for {path}") + + def run(self, args, input_text=None, check=True): + self.runs.append(args) + + +class MainTest(unittest.TestCase): + def routes(self): + return { + "search/issues": [{"items": [{"repository_url": "https://api.github.com/repos/laranail/emojis", "number": 7}]}], + "repos/laranail/emojis/pulls/7": {"title": pr()["title"], "head": {"ref": pr()["head_ref"], "sha": "abc"}, + "draft": False, "mergeable_state": "clean", "labels": []}, + "repos/laranail/emojis/pulls?state=closed": [ + {"merged_at": "2026-10-01T00:00:00Z", "user": {"login": "imanimanyara"}, "head": {"sha": "old"}}], + "repos/laranail/emojis/commits/old/check-runs": [{"check_runs": GREEN3}], + "repos/laranail/emojis/commits/abc/check-runs": [{"check_runs": GREEN3}], + "repos/laranail/emojis/commits/abc/status": {"statuses": [{"context": "GitGuardian", "state": "success"}]}, + } + + def test_dry_run_never_merges(self): + gh = FakeGh(self.routes()) + self.assertEqual(merge.main(["--owner", "laranail"], gh=gh), 0) + self.assertEqual(gh.runs, []) + + def test_apply_merges_pinned_to_head(self): + gh = FakeGh(self.routes()) + self.assertEqual(merge.main(["--owner", "laranail", "--apply"], gh=gh), 0) + self.assertEqual(gh.runs, [["pr", "merge", "7", "--repo", "laranail/emojis", "--squash", + "--match-head-commit", "abc"]]) + + def test_client_account_refused(self): + with self.assertRaises(ValueError): + merge.main(["--owner", "adelsaiq"], gh=FakeGh({})) + + +if __name__ == "__main__": + unittest.main() diff --git a/dependabot/tests/test_reconcile.py b/dependabot/tests/test_reconcile.py new file mode 100644 index 0000000..2acf934 --- /dev/null +++ b/dependabot/tests/test_reconcile.py @@ -0,0 +1,187 @@ +import base64 +import copy +import os +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import common # noqa: E402 +import reconcile # noqa: E402 + +POLICY = common.load_policy() +RENDERED = "# managed\nversion: 2\nupdates: []\n" + + +def repo(name, **kw): + r = {"full_name": f"simtabi/{name}", "name": name, "default_branch": "main", "archived": False, + "fork": False, "size": 10} + r.update(kw) + return r + + +class FakeGh: + def __init__(self, routes): + self.routes, self.runs = routes, [] + + def api(self, path, paginate=False): + for prefix, value in self.routes.items(): + if path.startswith(prefix): + if isinstance(value, Exception): + raise value + return value + raise common.GhError(f"404 {path}") + + def run(self, args, input_text=None, check=True): + self.runs.append(args) + return subprocess.CompletedProcess(args, 0, "https://github.com/simtabi/x/pull/1\n", "") + + +class FakeShell: + """git clone makes a directory; the generator answers per repo; --write writes the file.""" + + def __init__(self, generator_rc): + self.generator_rc, self.cmds = generator_rc, [] + + def run(self, cmd, check=True, input_text=None): + self.cmds.append(cmd) + if "clone" in cmd: + os.makedirs(cmd[-1]) + return subprocess.CompletedProcess(cmd, 0, "", "") + if "check" in cmd and "--strict" in cmd: + dest = cmd[cmd.index("check") + 1] + name = os.path.basename(dest).split("__", 1)[1] + if "--write" in cmd: + os.makedirs(os.path.join(dest, ".github"), exist_ok=True) + with open(os.path.join(dest, ".github", "dependabot.yml"), "w") as fh: + fh.write(RENDERED) + return subprocess.CompletedProcess(cmd, self.generator_rc[name], "", "") + if "status" in cmd: + dest = cmd[2] + dirty = os.path.exists(os.path.join(dest, ".github", "dependabot.yml")) + return subprocess.CompletedProcess(cmd, 0, "?? .github/dependabot.yml\0" if dirty else "", "") + return subprocess.CompletedProcess(cmd, 0, "", "") + + +class FilterTest(unittest.TestCase): + def test_active_filter(self): + pol = copy.deepcopy(POLICY) + pol["exclude"] = [{"repo": "simtabi/legacy", "reason": "frozen"}] + repos = [repo("ok"), repo("old", archived=True), repo("fork", fork=True), repo("legacy"), + repo("empty", size=0), repo("tiny", size=0)] + gh = FakeGh({"repos/simtabi/empty/branches": [], "repos/simtabi/tiny/branches": [{"name": "main"}]}) + active, skipped = common.active_repos(gh, pol, repos) + self.assertEqual([r["name"] for r in active], ["ok", "tiny"]) + self.assertEqual(dict(skipped), {"simtabi/old": "archived", "simtabi/fork": "fork", + "simtabi/legacy": "excluded: frozen", "simtabi/empty": "empty"}) + + def test_client_accounts_never_managed(self): + for owner in ("adelsaiq", "dannielagumo", "joemanch"): + with self.subTest(owner), self.assertRaises(ValueError): + common.account_kind(POLICY, owner) + self.assertEqual(common.account_kind(POLICY, "laranail"), "org") + self.assertEqual(common.account_kind(POLICY, "imanimanyara"), "user") + + def test_policy_rejects_client_account(self): + pol = copy.deepcopy(POLICY) + pol["accounts"]["orgs"].append("adelsaiq") + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh: + import json + json.dump(pol, fh) + try: + with self.assertRaises(ValueError): + common.load_policy(fh.name) + finally: + os.unlink(fh.name) + + +class PlanTest(unittest.TestCase): + def setUp(self): + self.gen = tempfile.mkdtemp() + script = os.path.join(self.gen, reconcile.GENERATOR) + os.makedirs(os.path.dirname(script)) + open(script, "w").close() + + def run_main(self, routes, rc, *extra): + gh, shell = FakeGh(routes), FakeShell(rc) + out = reconcile.main(["--owner", "simtabi", "--generator", self.gen, *extra], gh=gh, shell=shell) + return out, gh, shell + + def base_routes(self): + return { + "orgs/simtabi/repos": [repo("conforming"), repo("drifting"), repo("broken"), repo("pr-current"), + repo("pr-stale"), repo("closed-pr"), repo("archived", archived=True)], + "repos/simtabi/pr-current/pulls?": [{"number": 4}], + "repos/simtabi/pr-current/pulls/4": {"number": 4, "commits": 1, "head": {"sha": "p4"}}, + "repos/simtabi/pr-current/contents/.github/dependabot.yml": + {"content": base64.b64encode(RENDERED.encode()).decode()}, + "repos/simtabi/pr-stale/pulls?": [{"number": 5}], + "repos/simtabi/pr-stale/pulls/5": {"number": 5, "commits": 1, "head": {"sha": "p5"}}, + "repos/simtabi/pr-stale/contents/.github/dependabot.yml": + {"content": base64.b64encode(b"old\n").decode()}, + "repos/simtabi/closed-pr/git/ref/heads/": {"object": {"sha": "c1"}}, + "repos/": [], # any other pulls listing: no open PR + } + + RC = {"conforming": 0, "drifting": 1, "broken": 2, "pr-current": 1, "pr-stale": 1, "closed-pr": 1} + + def test_dry_run_plan(self): + captured = [] + orig = common.write_summary + common.write_summary = captured.append + try: + code, gh, shell = self.run_main(self.base_routes(), self.RC) + finally: + common.write_summary = orig + self.assertEqual(code, 0) + table = captured[0] + for name, status in [("conforming", "conforms"), ("drifting", "would-open"), + ("broken", "skipped:generator-exit-2"), ("pr-current", "skipped:pr-up-to-date"), + ("pr-stale", "would-update"), ("closed-pr", "skipped:branch-without-open-pr"), + ("archived", "skipped:archived")]: + with self.subTest(name): + self.assertRegex(table, rf"\| simtabi/{name} \| {status} \|") + self.assertEqual(gh.runs, [], "a dry run makes no gh writes") + self.assertFalse([c for c in shell.cmds if "push" in c], "a dry run never pushes") + + def test_apply_pushes_only_the_reconciler_branch(self): + code, gh, shell = self.run_main(self.base_routes(), self.RC, "--apply") + self.assertEqual(code, 0) + pushes = [c for c in shell.cmds if "push" in c] + self.assertEqual(len(pushes), 2) # drifting (new) and pr-stale (refresh) + for push in pushes: + self.assertEqual(push[-1], "HEAD:refs/heads/chore/dependabot-standard") + self.assertFalse(any(arg.endswith(":refs/heads/main") or arg == "main" for arg in push)) + leases = sorted(next(a for a in p if a.startswith("--force-with-lease")) for p in pushes) + self.assertEqual(leases, ["--force-with-lease=refs/heads/chore/dependabot-standard:", + "--force-with-lease=refs/heads/chore/dependabot-standard:p5"]) + verbs = sorted(r[1] for r in gh.runs) + self.assertEqual(verbs, ["create", "edit"]) + create = next(r for r in gh.runs if r[1] == "create") + self.assertIn("Adopt the Dependabot standard", create) + + def test_error_in_one_repo_is_reported_and_exits_nonzero(self): + routes = self.base_routes() + routes = {"repos/simtabi/drifting/pulls?": common.GhError("boom"), **routes} + captured = [] + orig = common.write_summary + common.write_summary = captured.append + try: + code, _, _ = self.run_main(routes, self.RC) + finally: + common.write_summary = orig + self.assertEqual(code, 1) + self.assertRegex(captured[0], r"\| simtabi/drifting \| error \| .*boom") + self.assertRegex(captured[0], r"\| simtabi/conforming \| conforms \|") + + def test_pr_with_extra_commits_is_left_alone(self): + routes = self.base_routes() + routes["repos/simtabi/pr-stale/pulls/5"] = {"number": 5, "commits": 2, "head": {"sha": "p5"}} + code, gh, shell = self.run_main(routes, self.RC, "--apply", "--repo", "pr-stale") + self.assertEqual(code, 0) + self.assertFalse([c for c in shell.cmds if "push" in c]) + + +if __name__ == "__main__": + unittest.main()