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()