From 31f715afcac368efef320f3a734e2b0783ac50e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:50:12 +0000 Subject: [PATCH 1/7] Initial plan From 8af3853a2102ff0af34ad351591e3562ef638442 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:54:19 +0000 Subject: [PATCH 2/7] feat: add Dependabot auto-merge pipeline (Tier 1 auto-merge, Tier 2 review, stale handler) --- .github/dependabot-exceptions.yml | 40 +++ .github/scripts/tier_classifier.py | 170 +++++++++ .github/workflows/dependabot-automerge.yml | 396 +++++++++++++++++++++ .github/workflows/stale-pr-handler.yml | 70 ++++ tests/unit/test_tier_classifier.py | 209 +++++++++++ 5 files changed, 885 insertions(+) create mode 100644 .github/dependabot-exceptions.yml create mode 100644 .github/scripts/tier_classifier.py create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/stale-pr-handler.yml create mode 100644 tests/unit/test_tier_classifier.py diff --git a/.github/dependabot-exceptions.yml b/.github/dependabot-exceptions.yml new file mode 100644 index 0000000..cd302c7 --- /dev/null +++ b/.github/dependabot-exceptions.yml @@ -0,0 +1,40 @@ +# Dependabot auto-merge exception list +# +# Packages listed here will NOT be auto-merged even for patch/minor bumps. +# They require human review because minor/patch releases in these packages +# have historically introduced breaking changes or security-sensitive behaviour. +# +# To add a package: append it under the appropriate ecosystem section. +# To remove a package: delete its entry and open a PR for review. + +pip: + # Payment / billing — any change here touches money + - stripe + # Cryptography — security-critical; even patch bumps need review + - cryptography + - pyopenssl + - paramiko + # Database drivers — schema/protocol changes can be silent + - asyncpg + - psycopg2 + - psycopg2-binary + - sqlalchemy + - alembic + # ML/scientific — ABI incompatibilities are common across minor bumps + - scipy + - numpy + - torch + - tensorflow + # Web frameworks — middleware behaviour changes can be subtle + - django + - flask + - fastapi + # AWS SDK — service API surface is enormous + - boto3 + - botocore + +actions: + # Actions that have write access to repo contents + - actions/checkout + - actions/upload-artifact + - actions/download-artifact diff --git a/.github/scripts/tier_classifier.py b/.github/scripts/tier_classifier.py new file mode 100644 index 0000000..b4b1d89 --- /dev/null +++ b/.github/scripts/tier_classifier.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Tier Classifier for Dependabot PRs. + +Reads PR metadata from environment variables (set by the calling workflow or +by dependabot/fetch-metadata) and writes a tier classification + merge +decision to GITHUB_OUTPUT. + +Environment variables consumed +------------------------------- +PR_TITLE : Full PR title (e.g. "chore(deps): bump stripe from 8.1.0 to 9.0.0") +UPDATE_TYPE : semver classification from dependabot/fetch-metadata: + "version-update:semver-patch", "version-update:semver-minor", + "version-update:semver-major", or empty string for non-dep PRs +DEPENDENCY_NAMES : comma-separated list of dependency names (from fetch-metadata) +PACKAGE_ECOSYSTEM : ecosystem string ("pip", "npm", "actions", …) +EXCEPTIONS_FILE : path to YAML blocklist (default: .github/dependabot-exceptions.yml) +DRY_RUN : "true" / "false" (default: "true") + +Outputs written to GITHUB_OUTPUT +--------------------------------- +tier : "1" (safe auto-merge), "2" (needs review) +merge_decision : "auto-merge" or "needs-review" +reason : human-readable explanation +blocklisted_names : comma-separated names that hit the blocklist (may be empty) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import yaml + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _load_exceptions(path: str) -> dict[str, list[str]]: + p = Path(path) + if not p.exists(): + return {} + with open(p, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return {k: [str(v).lower() for v in vs] for k, vs in data.items() if isinstance(vs, list)} + + +def _is_blocklisted( + dep_names: list[str], + ecosystem: str, + exceptions: dict[str, list[str]], +) -> list[str]: + blocked: list[str] = [] + ecosystem_key = ecosystem.lower() if ecosystem else "" + blocklist = exceptions.get(ecosystem_key, []) + for name in dep_names: + if name.lower() in blocklist: + blocked.append(name) + return blocked + + +def _write_outputs(outputs: dict[str, str]) -> None: + out_file = os.environ.get("GITHUB_OUTPUT") + if out_file: + with open(out_file, "a", encoding="utf-8") as f: + for key, val in outputs.items(): + f.write(f"{key}={val}\n") + for key, val in outputs.items(): + print(f" {key}={val}") + + +def _write_summary(lines: list[str]) -> None: + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + content = "\n".join(lines) + print(content) + if summary_file: + with open(summary_file, "a", encoding="utf-8") as f: + f.write(content + "\n") + + +# --------------------------------------------------------------------------- +# Main classification logic +# --------------------------------------------------------------------------- + +def classify() -> None: + pr_title = os.environ.get("PR_TITLE", "") + update_type = os.environ.get("UPDATE_TYPE", "") + dependency_names_raw = os.environ.get("DEPENDENCY_NAMES", "") + ecosystem = os.environ.get("PACKAGE_ECOSYSTEM", "pip") + exceptions_file = os.environ.get( + "EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml" + ) + dry_run = os.environ.get("DRY_RUN", "true").lower() == "true" + + dep_names = [n.strip() for n in dependency_names_raw.split(",") if n.strip()] + + exceptions = _load_exceptions(exceptions_file) + blocklisted = _is_blocklisted(dep_names, ecosystem, exceptions) + + # Determine update class from fetch-metadata output + is_patch = "semver-patch" in update_type + is_minor = "semver-minor" in update_type + is_major = "semver-major" in update_type + + # Classification + if blocklisted: + tier = "2" + merge_decision = "needs-review" + reason = ( + f"Blocklisted package(s) require human review: " + f"{', '.join(blocklisted)}" + ) + elif is_major: + tier = "2" + merge_decision = "needs-review" + reason = "Major version bump detected — potential breaking changes." + elif is_patch or is_minor: + tier = "1" + merge_decision = "auto-merge" + reason = ( + f"{'Patch' if is_patch else 'Minor'} version bump from a " + f"non-blocklisted package — safe to auto-merge." + ) + else: + # Unknown / non-Dependabot PR or no update-type info + tier = "2" + merge_decision = "needs-review" + reason = ( + "Non-Dependabot PR or unrecognised update type " + f"(update_type={update_type!r}) — routing to human review." + ) + + summary_lines = [ + "## 🤖 Tier Classifier Result", + "", + f"**PR title:** {pr_title}", + f"**Dependencies:** {', '.join(dep_names) or '(none detected)'}", + f"**Ecosystem:** {ecosystem}", + f"**Update type:** {update_type or '(unknown)'}", + f"**Blocklisted:** {', '.join(blocklisted) or 'none'}", + "", + f"### Decision: Tier {tier} — `{merge_decision}`", + f"**Reason:** {reason}", + "", + f"**Dry-run mode:** {dry_run}", + ] + if dry_run: + summary_lines.append( + "\n> ⚠️ Dry-run is ON — merge command will be logged but not executed." + ) + + _write_summary(summary_lines) + _write_outputs( + { + "tier": tier, + "merge_decision": merge_decision, + "reason": reason.replace("\n", " "), + "blocklisted_names": ",".join(blocklisted), + } + ) + + +if __name__ == "__main__": + try: + classify() + except Exception as exc: # noqa: BLE001 + print(f"::error::tier_classifier.py failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..b3073ec --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,396 @@ +name: Dependabot Auto-Merge + +# Automatically merges Tier 1 (patch/minor) Dependabot PRs once all CI +# checks pass. Major version bumps and blocklisted packages are routed +# to Tier 2 (needs human review). +# +# Triggers +# -------- +# pull_request_target — fires for every Dependabot PR event in this repo +# workflow_dispatch — manual trigger with dry_run flag for testing +# +# Safety gates +# ------------ +# • Only acts on PRs authored by dependabot[bot] +# • Reads .github/dependabot-exceptions.yml for per-package overrides +# • dry_run=true logs all decisions but never calls `gh pr merge` +# • Concurrency group prevents parallel merges (race-condition guard) +# +# Required secrets / permissions +# -------------------------------- +# GITHUB_TOKEN with pull-requests:write + contents:write (granted below) +# GH_PAT — used for cross-repo operations; falls back to GITHUB_TOKEN + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + + workflow_dispatch: + inputs: + pr_number: + description: "PR number to evaluate (leave blank to scan all open Dependabot PRs)" + required: false + type: string + dry_run: + description: "Dry-run mode — log decisions without merging" + required: false + default: "true" + type: choice + options: ["true", "false"] + +concurrency: + group: dependabot-automerge-${{ github.event.pull_request.number || inputs.pr_number || 'batch' }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + checks: read + +env: + PYTHON_VERSION: "3.11" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Default dry_run for pull_request_target events + DEFAULT_DRY_RUN: "false" + +jobs: + # ----------------------------------------------------------------------- + # Job 1 — guard: abort early if the PR is not from dependabot[bot] + # ----------------------------------------------------------------------- + guard: + name: "Guard — Dependabot only" + runs-on: ubuntu-latest + if: > + github.actor == 'dependabot[bot]' || + github.event_name == 'workflow_dispatch' + outputs: + is_dependabot: ${{ steps.check.outputs.is_dependabot }} + steps: + - name: Check actor + id: check + run: | + if [[ "${{ github.actor }}" == "dependabot[bot]" ]] || \ + [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "is_dependabot=true" >> "$GITHUB_OUTPUT" + else + echo "is_dependabot=false" >> "$GITHUB_OUTPUT" + fi + + # ----------------------------------------------------------------------- + # Job 2 — classify: fetch metadata and run tier_classifier.py + # ----------------------------------------------------------------------- + classify: + name: "Classify PR" + runs-on: ubuntu-latest + needs: guard + if: needs.guard.outputs.is_dependabot == 'true' + outputs: + tier: ${{ steps.classify.outputs.tier }} + merge_decision: ${{ steps.classify.outputs.merge_decision }} + reason: ${{ steps.classify.outputs.reason }} + blocklisted_names: ${{ steps.classify.outputs.blocklisted_names }} + dry_run: ${{ steps.resolve_dry_run.outputs.dry_run }} + update_type: ${{ steps.meta.outputs.update-type }} + dependency_names: ${{ steps.meta.outputs.dependency-names }} + package_ecosystem: ${{ steps.meta.outputs.package-ecosystem }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Resolve dry_run flag + id: resolve_dry_run + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "dry_run=${{ inputs.dry_run }}" >> "$GITHUB_OUTPUT" + else + echo "dry_run=${{ env.DEFAULT_DRY_RUN }}" >> "$GITHUB_OUTPUT" + fi + + - name: Fetch Dependabot metadata + id: meta + if: github.event_name == 'pull_request_target' + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install PyYAML + run: pip install -q pyyaml + + - name: Run tier classifier + id: classify + env: + PR_TITLE: ${{ github.event.pull_request.title || '' }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type || '' }} + DEPENDENCY_NAMES: ${{ steps.meta.outputs.dependency-names || '' }} + PACKAGE_ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem || 'pip' }} + EXCEPTIONS_FILE: .github/dependabot-exceptions.yml + DRY_RUN: ${{ steps.resolve_dry_run.outputs.dry_run }} + run: python .github/scripts/tier_classifier.py + + # ----------------------------------------------------------------------- + # Job 3 — auto-merge: squash-merge Tier 1 PRs once all checks pass + # ----------------------------------------------------------------------- + auto-merge: + name: "Auto-merge Tier 1" + runs-on: ubuntu-latest + needs: classify + if: > + needs.classify.outputs.tier == '1' && + needs.classify.outputs.merge_decision == 'auto-merge' && + github.event_name == 'pull_request_target' + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Approve and enable auto-merge (dry_run=false) + if: needs.classify.outputs.dry_run == 'false' + env: + GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REASON: ${{ needs.classify.outputs.reason }} + run: | + gh pr review "$PR_NUMBER" --approve \ + --body "✅ **Auto-merge approved** (Tier 1 — patch/minor bump, no blocklisted packages). + + > $REASON + + *Merged by dependabot-automerge workflow.*" + + gh pr merge "$PR_NUMBER" \ + --auto \ + --squash \ + --delete-branch + + echo "::notice::Auto-merge enabled for PR #$PR_NUMBER" + + - name: Log decision (dry_run=true) + if: needs.classify.outputs.dry_run == 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REASON: ${{ needs.classify.outputs.reason }} + run: | + echo "::notice::DRY-RUN — would auto-merge PR #$PR_NUMBER" + echo "Reason: $REASON" + cat >> "$GITHUB_STEP_SUMMARY" <<'EOF' + ## ⚠️ Dry-Run — No merge executed + + PR **#${{ github.event.pull_request.number }}** qualifies for auto-merge (Tier 1). + Re-trigger with `dry_run=false` to execute. + + **Reason:** ${{ needs.classify.outputs.reason }} + EOF + + - name: Post audit comment + env: + GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + TIER: ${{ needs.classify.outputs.tier }} + DECISION: ${{ needs.classify.outputs.merge_decision }} + REASON: ${{ needs.classify.outputs.reason }} + UPDATE_TYPE: ${{ needs.classify.outputs.update_type }} + DRY_RUN: ${{ needs.classify.outputs.dry_run }} + run: | + if [[ "$DRY_RUN" == "true" ]]; then + BADGE="🔍 Dry-run" + ACTION_TAKEN="No action taken (dry-run mode)." + else + BADGE="✅ Auto-merged" + ACTION_TAKEN="PR has been approved and queued for squash-merge." + fi + + gh pr comment "$PR_NUMBER" --body "## 🤖 Dependabot Auto-Merge — Tier ${TIER} ${BADGE} + + | Field | Value | + |-------|-------| + | **Tier** | ${TIER} | + | **Decision** | \`${DECISION}\` | + | **Update type** | \`${UPDATE_TYPE}\` | + | **Dry-run** | ${DRY_RUN} | + + **Reason:** ${REASON} + + ${ACTION_TAKEN} + + *Processed by [dependabot-automerge](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.*" + + # ----------------------------------------------------------------------- + # Job 4 — needs-review: label + comment Tier 2 PRs + # ----------------------------------------------------------------------- + needs-review: + name: "Flag Tier 2 for review" + runs-on: ubuntu-latest + needs: classify + if: > + needs.classify.outputs.tier == '2' && + github.event_name == 'pull_request_target' + + steps: + - name: Apply needs-human-review label + env: + GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Create label if it doesn't exist yet (idempotent) + gh label create "needs-human-review" \ + --color "e11d48" \ + --description "Requires manual review before merge" \ + --repo "${{ github.repository }}" \ + 2>/dev/null || true + + gh pr edit "$PR_NUMBER" \ + --add-label "needs-human-review" + + - name: Post review-request comment + env: + GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REASON: ${{ needs.classify.outputs.reason }} + BLOCKLISTED: ${{ needs.classify.outputs.blocklisted_names }} + UPDATE_TYPE: ${{ needs.classify.outputs.update_type }} + run: | + BLOCKLIST_NOTE="" + if [[ -n "$BLOCKLISTED" ]]; then + BLOCKLIST_NOTE=" + > ⚠️ **Blocklisted package(s):** \`${BLOCKLISTED}\` — these packages are on the exception list and always require human review." + fi + + gh pr comment "$PR_NUMBER" --body "## 🤖 Dependabot Auto-Merge — Tier 2: Needs Review + + This PR has been classified as **Tier 2** and will **not** be auto-merged. + ${BLOCKLIST_NOTE} + + | Field | Value | + |-------|-------| + | **Tier** | 2 | + | **Decision** | \`needs-review\` | + | **Update type** | \`${UPDATE_TYPE}\` | + + **Reason:** ${REASON} + + ### Review checklist + - [ ] Check the package CHANGELOG for breaking changes + - [ ] Verify no deprecation warnings in CI logs + - [ ] If safe, remove the \`needs-human-review\` label and merge manually + + *Processed by [dependabot-automerge](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.*" + + # ----------------------------------------------------------------------- + # Job 5 — batch-scan: workflow_dispatch without a PR number scans all + # open Dependabot PRs and posts a summary to GITHUB_STEP_SUMMARY + # ----------------------------------------------------------------------- + batch-scan: + name: "Batch scan (workflow_dispatch)" + runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' && + (inputs.pr_number == '' || inputs.pr_number == null) + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies + run: pip install -q pyyaml PyGithub + + - name: Scan open Dependabot PRs + env: + GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + EXCEPTIONS_FILE: .github/dependabot-exceptions.yml + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python3 - <<'PYEOF' + import os, sys, re + sys.path.insert(0, ".") + + import yaml + from github import Github, GithubException + + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["GITHUB_REPOSITORY"] + dry_run = os.environ.get("DRY_RUN", "true").lower() == "true" + exceptions_file = os.environ.get("EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml") + + # Load exceptions + exc_data = {} + try: + with open(exceptions_file, encoding="utf-8") as f: + exc_data = yaml.safe_load(f) or {} + except FileNotFoundError: + pass + blocklist_pip = [str(v).lower() for v in exc_data.get("pip", [])] + + g = Github(token) + repo = g.get_repo(repo_name) + + def bump_tier(title): + m = re.search(r"\bfrom\s+(\S+)\s+to\s+(\S+)", title, re.IGNORECASE) + if not m: + return "unknown" + old_major = re.match(r"(\d+)", m.group(1)) + new_major = re.match(r"(\d+)", m.group(2)) + if old_major and new_major and old_major.group(1) != new_major.group(1): + return "major" + old_minor = re.match(r"\d+\.(\d+)", m.group(1)) + new_minor = re.match(r"\d+\.(\d+)", m.group(2)) + if old_minor and new_minor and old_minor.group(1) != new_minor.group(1): + return "minor" + return "patch" + + def is_blocklisted(title): + # Extract package name heuristically from "bump X from..." + m = re.search(r"bump\s+(\S+)\s+from", title, re.IGNORECASE) + if m: + return m.group(1).lower() in blocklist_pip + return False + + prs = list(repo.get_pulls(state="open")) + dep_prs = [p for p in prs if p.user and p.user.login == "dependabot[bot]" and not p.draft] + + rows = [] + for pr in dep_prs: + bump = bump_tier(pr.title) + blocked = is_blocklisted(pr.title) + if blocked or bump == "major": + tier, decision = "2", "needs-review" + elif bump in ("minor", "patch"): + tier, decision = "1", "auto-merge" if not dry_run else "would-auto-merge" + else: + tier, decision = "2", "needs-review" + rows.append((pr.number, tier, decision, bump, pr.title[:60])) + + # Write step summary + summary_lines = [ + "## 🤖 Dependabot Auto-Merge — Batch Scan", + "", + f"**Dry-run:** {dry_run}", + f"**Open Dependabot PRs found:** {len(dep_prs)}", + "", + "| PR | Tier | Decision | Bump | Title |", + "|----|------|----------|------|-------|", + ] + for num, tier, decision, bump, title in rows: + summary_lines.append(f"| #{num} | {tier} | `{decision}` | {bump} | {title} |") + + output = "\n".join(summary_lines) + print(output) + + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + with open(summary_file, "a") as f: + f.write(output + "\n") + PYEOF diff --git a/.github/workflows/stale-pr-handler.yml b/.github/workflows/stale-pr-handler.yml new file mode 100644 index 0000000..69ea3e1 --- /dev/null +++ b/.github/workflows/stale-pr-handler.yml @@ -0,0 +1,70 @@ +name: Stale PR Handler + +# Escalates and closes stale pull requests across the repo. +# Uses actions/stale to post a warning after 30 days of inactivity +# and closes the PR after an additional 7 days with no response. +# +# Runs daily at 07:00 UTC and can be triggered manually. + +on: + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + dry_run: + description: "Dry-run — log stale PRs without posting comments or closing" + required: false + default: "true" + type: choice + options: ["true", "false"] + +permissions: + pull-requests: write + issues: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + stale: + name: "Mark and close stale PRs" + runs-on: ubuntu-latest + + steps: + - name: Run actions/stale + uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # --- Pull request settings --- + days-before-pr-stale: 30 + days-before-pr-close: 7 + stale-pr-label: "stale" + close-pr-label: "auto-closed-stale" + stale-pr-message: | + 👋 **Staleness Sentinel** — This pull request has had no activity for **30 days**. + + If this PR is still relevant, please: + - Leave a status update comment + - Rebase onto the latest `main` + - Request a fresh review + + If no activity is recorded in the next **7 days**, this PR will be closed automatically. + You can always reopen it if the work becomes relevant again. + close-pr-message: | + 🔒 **Staleness Sentinel** — This pull request has been automatically closed after + 37 days of inactivity (30-day stale warning + 7-day close window). + + To reopen, simply comment or push a new commit. + + # --- Issue settings (disabled — PRs only) --- + days-before-issue-stale: -1 + days-before-issue-close: -1 + + # --- Exemptions --- + exempt-pr-labels: "blocked,pinned,work-in-progress,do-not-close" + exempt-draft-pr: true + + # --- Safety --- + operations-per-run: 30 + debug-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true' }} diff --git a/tests/unit/test_tier_classifier.py b/tests/unit/test_tier_classifier.py new file mode 100644 index 0000000..d5104e5 --- /dev/null +++ b/tests/unit/test_tier_classifier.py @@ -0,0 +1,209 @@ +"""Unit tests for .github/scripts/tier_classifier.py""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +# Add scripts directory to path +_scripts_path = str(Path(__file__).parent.parent.parent / ".github" / "scripts") +if _scripts_path not in sys.path: + sys.path.insert(0, _scripts_path) + +import importlib +import types + +# We import the module but test its helpers directly to avoid needing +# GITHUB_OUTPUT / GITHUB_STEP_SUMMARY in the environment. +import tier_classifier + + +# --------------------------------------------------------------------------- +# _load_exceptions +# --------------------------------------------------------------------------- + +class TestLoadExceptions: + def test_loads_pip_blocklist(self, tmp_path): + cfg = tmp_path / "exc.yml" + cfg.write_text("pip:\n - stripe\n - cryptography\n") + result = tier_classifier._load_exceptions(str(cfg)) + assert "stripe" in result["pip"] + assert "cryptography" in result["pip"] + + def test_missing_file_returns_empty(self, tmp_path): + result = tier_classifier._load_exceptions(str(tmp_path / "nonexistent.yml")) + assert result == {} + + def test_normalises_to_lowercase(self, tmp_path): + cfg = tmp_path / "exc.yml" + cfg.write_text("pip:\n - Stripe\n - CRYPTOGRAPHY\n") + result = tier_classifier._load_exceptions(str(cfg)) + assert "stripe" in result["pip"] + assert "cryptography" in result["pip"] + + +# --------------------------------------------------------------------------- +# _is_blocklisted +# --------------------------------------------------------------------------- + +class TestIsBlocklisted: + _exceptions = {"pip": ["stripe", "cryptography", "scipy"]} + + def test_blocklisted_package_flagged(self): + blocked = tier_classifier._is_blocklisted(["stripe"], "pip", self._exceptions) + assert "stripe" in blocked + + def test_safe_package_not_flagged(self): + blocked = tier_classifier._is_blocklisted(["requests"], "pip", self._exceptions) + assert blocked == [] + + def test_wrong_ecosystem_not_flagged(self): + # stripe is only on the pip blocklist, not npm + blocked = tier_classifier._is_blocklisted(["stripe"], "npm", self._exceptions) + assert blocked == [] + + def test_multiple_packages_partial_match(self): + blocked = tier_classifier._is_blocklisted( + ["requests", "stripe", "pyyaml"], "pip", self._exceptions + ) + assert blocked == ["stripe"] + + def test_case_insensitive_match(self): + blocked = tier_classifier._is_blocklisted(["Stripe"], "pip", self._exceptions) + assert blocked == ["Stripe"] + + +# --------------------------------------------------------------------------- +# classify() via environment variable injection +# --------------------------------------------------------------------------- + +class TestClassify: + """Run classify() with a mocked environment and capture GITHUB_OUTPUT.""" + + def _run(self, env_overrides: dict, tmp_path: Path, exceptions_content: str = "") -> dict: + exc_file = tmp_path / "exc.yml" + if exceptions_content: + exc_file.write_text(exceptions_content) + else: + exc_file.write_text("pip:\n - stripe\n - cryptography\n - scipy\n") + + output_file = tmp_path / "github_output" + output_file.touch() + summary_file = tmp_path / "github_summary" + summary_file.touch() + + env = { + "PR_TITLE": "chore(deps): bump requests from 2.31.0 to 2.34.2", + "UPDATE_TYPE": "version-update:semver-patch", + "DEPENDENCY_NAMES": "requests", + "PACKAGE_ECOSYSTEM": "pip", + "EXCEPTIONS_FILE": str(exc_file), + "DRY_RUN": "true", + "GITHUB_OUTPUT": str(output_file), + "GITHUB_STEP_SUMMARY": str(summary_file), + } + env.update(env_overrides) + + old_env = {k: os.environ.get(k) for k in env} + for k, v in env.items(): + os.environ[k] = v + try: + tier_classifier.classify() + finally: + for k, old_v in old_env.items(): + if old_v is None: + os.environ.pop(k, None) + else: + os.environ[k] = old_v + + outputs = {} + for line in output_file.read_text().splitlines(): + if "=" in line: + k, _, v = line.partition("=") + outputs[k] = v + return outputs + + def test_patch_bump_is_tier1_auto_merge(self, tmp_path): + out = self._run( + { + "UPDATE_TYPE": "version-update:semver-patch", + "DEPENDENCY_NAMES": "requests", + }, + tmp_path, + ) + assert out["tier"] == "1" + assert out["merge_decision"] == "auto-merge" + + def test_minor_bump_is_tier1_auto_merge(self, tmp_path): + out = self._run( + { + "UPDATE_TYPE": "version-update:semver-minor", + "DEPENDENCY_NAMES": "pyyaml", + }, + tmp_path, + ) + assert out["tier"] == "1" + assert out["merge_decision"] == "auto-merge" + + def test_major_bump_is_tier2_needs_review(self, tmp_path): + out = self._run( + { + "PR_TITLE": "chore(deps): bump stripe from 8.1.0 to 9.0.0", + "UPDATE_TYPE": "version-update:semver-major", + "DEPENDENCY_NAMES": "stripe", + }, + tmp_path, + ) + assert out["tier"] == "2" + assert out["merge_decision"] == "needs-review" + + def test_blocklisted_patch_is_tier2_needs_review(self, tmp_path): + out = self._run( + { + "PR_TITLE": "chore(deps): bump stripe from 8.1.0 to 8.1.1", + "UPDATE_TYPE": "version-update:semver-patch", + "DEPENDENCY_NAMES": "stripe", + }, + tmp_path, + ) + assert out["tier"] == "2" + assert out["merge_decision"] == "needs-review" + assert "stripe" in out["blocklisted_names"] + + def test_blocklisted_minor_is_tier2_needs_review(self, tmp_path): + out = self._run( + { + "PR_TITLE": "chore(deps): bump scipy from 1.11.4 to 1.17.1", + "UPDATE_TYPE": "version-update:semver-minor", + "DEPENDENCY_NAMES": "scipy", + }, + tmp_path, + ) + assert out["tier"] == "2" + assert out["merge_decision"] == "needs-review" + + def test_unknown_update_type_is_tier2(self, tmp_path): + out = self._run( + { + "PR_TITLE": "feat: add new feature", + "UPDATE_TYPE": "", + "DEPENDENCY_NAMES": "", + }, + tmp_path, + ) + assert out["tier"] == "2" + assert out["merge_decision"] == "needs-review" + + def test_multiple_deps_one_blocklisted(self, tmp_path): + out = self._run( + { + "UPDATE_TYPE": "version-update:semver-minor", + "DEPENDENCY_NAMES": "pyyaml,stripe,requests", + }, + tmp_path, + ) + assert out["tier"] == "2" + assert "stripe" in out["blocklisted_names"] From 8fbde3512a13e9c31306c9c25979202f3987750f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:57:20 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20code=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20heredocs,=20extract=20batch-scan=20script,=20tig?= =?UTF-8?q?hten=20exception=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/batch_scan_dependabot.py | 118 ++++++++++++++++ .github/scripts/tier_classifier.py | 4 +- .github/workflows/dependabot-automerge.yml | 154 +++------------------ 3 files changed, 142 insertions(+), 134 deletions(-) create mode 100644 .github/scripts/batch_scan_dependabot.py diff --git a/.github/scripts/batch_scan_dependabot.py b/.github/scripts/batch_scan_dependabot.py new file mode 100644 index 0000000..938b305 --- /dev/null +++ b/.github/scripts/batch_scan_dependabot.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Batch scan open Dependabot PRs and print a tier classification table. + +Reads configuration from environment variables: + GITHUB_TOKEN : GitHub PAT with repo read access + GITHUB_REPOSITORY : owner/repo string + DRY_RUN : "true" / "false" (default: "true") + EXCEPTIONS_FILE : path to .github/dependabot-exceptions.yml + +Writes a Markdown summary to GITHUB_STEP_SUMMARY (if set). +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +import yaml +from github import Github, GithubException + + +def _load_blocklist_pip(exceptions_file: str) -> list[str]: + p = Path(exceptions_file) + if not p.exists(): + return [] + with open(p, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return [str(v).lower() for v in data.get("pip", [])] + + +def _bump_kind(title: str) -> str: + m = re.search(r"\bfrom\s+(\S+)\s+to\s+(\S+)", title, re.IGNORECASE) + if not m: + return "unknown" + old_major = re.match(r"(\d+)", m.group(1)) + new_major = re.match(r"(\d+)", m.group(2)) + if old_major and new_major and old_major.group(1) != new_major.group(1): + return "major" + old_minor = re.match(r"\d+\.(\d+)", m.group(1)) + new_minor = re.match(r"\d+\.(\d+)", m.group(2)) + if old_minor and new_minor and old_minor.group(1) != new_minor.group(1): + return "minor" + return "patch" + + +def _is_blocklisted(title: str, blocklist: list[str]) -> bool: + m = re.search(r"bump\s+(\S+)\s+from", title, re.IGNORECASE) + if m: + return m.group(1).lower() in blocklist + return False + + +def main() -> None: + token = os.environ.get("GITHUB_TOKEN") + if not token: + sys.exit("GITHUB_TOKEN not set") + + repo_name = os.environ.get("GITHUB_REPOSITORY", "") + if not repo_name: + sys.exit("GITHUB_REPOSITORY not set") + + dry_run = os.environ.get("DRY_RUN", "true").lower() == "true" + exceptions_file = os.environ.get("EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml") + blocklist = _load_blocklist_pip(exceptions_file) + + g = Github(token) + try: + repo = g.get_repo(repo_name) + except GithubException as exc: + sys.exit(f"Could not fetch repo {repo_name}: {exc}") + + prs = list(repo.get_pulls(state="open")) + dep_prs = [ + p for p in prs + if p.user and p.user.login == "dependabot[bot]" and not p.draft + ] + + rows: list[tuple[int, str, str, str, str]] = [] + for pr in dep_prs: + bump = _bump_kind(pr.title) + blocked = _is_blocklisted(pr.title, blocklist) + if blocked or bump == "major": + tier, decision = "2", "needs-review" + elif bump in ("minor", "patch"): + tier = "1" + decision = "would-auto-merge" if dry_run else "auto-merge" + else: + tier, decision = "2", "needs-review" + rows.append((pr.number, tier, decision, bump, pr.title[:60])) + + summary_lines = [ + "## 🤖 Dependabot Auto-Merge — Batch Scan", + "", + f"**Dry-run:** {dry_run}", + f"**Open Dependabot PRs found:** {len(dep_prs)}", + "", + "| PR | Tier | Decision | Bump | Title |", + "|----|------|----------|------|-------|", + ] + for num, tier, decision, bump, title in rows: + summary_lines.append( + f"| #{num} | {tier} | `{decision}` | {bump} | {title} |" + ) + + output = "\n".join(summary_lines) + print(output) + + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + with open(summary_file, "a", encoding="utf-8") as f: + f.write(output + "\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/tier_classifier.py b/.github/scripts/tier_classifier.py index b4b1d89..dc4896f 100644 --- a/.github/scripts/tier_classifier.py +++ b/.github/scripts/tier_classifier.py @@ -165,6 +165,6 @@ def classify() -> None: if __name__ == "__main__": try: classify() - except Exception as exc: # noqa: BLE001 - print(f"::error::tier_classifier.py failed: {exc}", file=sys.stderr) + except (OSError, yaml.YAMLError) as exc: + print(f"::error::tier_classifier.py I/O or YAML error: {exc}", file=sys.stderr) sys.exit(1) diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index b3073ec..2447d6a 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -157,12 +157,8 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REASON: ${{ needs.classify.outputs.reason }} run: | - gh pr review "$PR_NUMBER" --approve \ - --body "✅ **Auto-merge approved** (Tier 1 — patch/minor bump, no blocklisted packages). - - > $REASON - - *Merged by dependabot-automerge workflow.*" + gh pr review "$PR_NUMBER" --approve --body \ + "$(printf '✅ **Auto-merge approved** (Tier 1 — patch/minor bump, no blocklisted packages).\n\n> %s\n\n*Merged by dependabot-automerge workflow.*' "$REASON")" gh pr merge "$PR_NUMBER" \ --auto \ @@ -179,14 +175,14 @@ jobs: run: | echo "::notice::DRY-RUN — would auto-merge PR #$PR_NUMBER" echo "Reason: $REASON" - cat >> "$GITHUB_STEP_SUMMARY" <<'EOF' - ## ⚠️ Dry-Run — No merge executed - - PR **#${{ github.event.pull_request.number }}** qualifies for auto-merge (Tier 1). - Re-trigger with `dry_run=false` to execute. - - **Reason:** ${{ needs.classify.outputs.reason }} - EOF + { + echo "## ⚠️ Dry-Run — No merge executed" + echo "" + echo "PR **#${PR_NUMBER}** qualifies for auto-merge (Tier 1)." + echo "Re-trigger with \`dry_run=false\` to execute." + echo "" + echo "**Reason:** ${REASON}" + } >> "$GITHUB_STEP_SUMMARY" - name: Post audit comment env: @@ -197,6 +193,7 @@ jobs: REASON: ${{ needs.classify.outputs.reason }} UPDATE_TYPE: ${{ needs.classify.outputs.update_type }} DRY_RUN: ${{ needs.classify.outputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if [[ "$DRY_RUN" == "true" ]]; then BADGE="🔍 Dry-run" @@ -206,20 +203,9 @@ jobs: ACTION_TAKEN="PR has been approved and queued for squash-merge." fi - gh pr comment "$PR_NUMBER" --body "## 🤖 Dependabot Auto-Merge — Tier ${TIER} ${BADGE} - - | Field | Value | - |-------|-------| - | **Tier** | ${TIER} | - | **Decision** | \`${DECISION}\` | - | **Update type** | \`${UPDATE_TYPE}\` | - | **Dry-run** | ${DRY_RUN} | - - **Reason:** ${REASON} - - ${ACTION_TAKEN} - - *Processed by [dependabot-automerge](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.*" + gh pr comment "$PR_NUMBER" --body \ + "$(printf '## 🤖 Dependabot Auto-Merge — Tier %s %s\n\n| Field | Value |\n|-------|-------|\n| **Tier** | %s |\n| **Decision** | `%s` |\n| **Update type** | `%s` |\n| **Dry-run** | %s |\n\n**Reason:** %s\n\n%s\n\n*Processed by [dependabot-automerge](%s) workflow.*' \ + "$TIER" "$BADGE" "$TIER" "$DECISION" "$UPDATE_TYPE" "$DRY_RUN" "$REASON" "$ACTION_TAKEN" "$RUN_URL")" # ----------------------------------------------------------------------- # Job 4 — needs-review: label + comment Tier 2 PRs @@ -255,32 +241,17 @@ jobs: REASON: ${{ needs.classify.outputs.reason }} BLOCKLISTED: ${{ needs.classify.outputs.blocklisted_names }} UPDATE_TYPE: ${{ needs.classify.outputs.update_type }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - BLOCKLIST_NOTE="" if [[ -n "$BLOCKLISTED" ]]; then - BLOCKLIST_NOTE=" - > ⚠️ **Blocklisted package(s):** \`${BLOCKLISTED}\` — these packages are on the exception list and always require human review." + BLOCKLIST_NOTE="$(printf '\n> ⚠️ **Blocklisted package(s):** \`%s\` — these packages are on the exception list and always require human review.' "$BLOCKLISTED")" + else + BLOCKLIST_NOTE="" fi - gh pr comment "$PR_NUMBER" --body "## 🤖 Dependabot Auto-Merge — Tier 2: Needs Review - - This PR has been classified as **Tier 2** and will **not** be auto-merged. - ${BLOCKLIST_NOTE} - - | Field | Value | - |-------|-------| - | **Tier** | 2 | - | **Decision** | \`needs-review\` | - | **Update type** | \`${UPDATE_TYPE}\` | - - **Reason:** ${REASON} - - ### Review checklist - - [ ] Check the package CHANGELOG for breaking changes - - [ ] Verify no deprecation warnings in CI logs - - [ ] If safe, remove the \`needs-human-review\` label and merge manually - - *Processed by [dependabot-automerge](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.*" + gh pr comment "$PR_NUMBER" --body \ + "$(printf '## 🤖 Dependabot Auto-Merge — Tier 2: Needs Review\n\nThis PR has been classified as **Tier 2** and will **not** be auto-merged.%s\n\n| Field | Value |\n|-------|-------|\n| **Tier** | 2 |\n| **Decision** | `needs-review` |\n| **Update type** | `%s` |\n\n**Reason:** %s\n\n### Review checklist\n- [ ] Check the package CHANGELOG for breaking changes\n- [ ] Verify no deprecation warnings in CI logs\n- [ ] If safe, remove the `needs-human-review` label and merge manually\n\n*Processed by [dependabot-automerge](%s) workflow.*' \ + "$BLOCKLIST_NOTE" "$UPDATE_TYPE" "$REASON" "$RUN_URL")" # ----------------------------------------------------------------------- # Job 5 — batch-scan: workflow_dispatch without a PR number scans all @@ -312,85 +283,4 @@ jobs: DRY_RUN: ${{ inputs.dry_run }} EXCEPTIONS_FILE: .github/dependabot-exceptions.yml GITHUB_REPOSITORY: ${{ github.repository }} - run: | - python3 - <<'PYEOF' - import os, sys, re - sys.path.insert(0, ".") - - import yaml - from github import Github, GithubException - - token = os.environ["GITHUB_TOKEN"] - repo_name = os.environ["GITHUB_REPOSITORY"] - dry_run = os.environ.get("DRY_RUN", "true").lower() == "true" - exceptions_file = os.environ.get("EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml") - - # Load exceptions - exc_data = {} - try: - with open(exceptions_file, encoding="utf-8") as f: - exc_data = yaml.safe_load(f) or {} - except FileNotFoundError: - pass - blocklist_pip = [str(v).lower() for v in exc_data.get("pip", [])] - - g = Github(token) - repo = g.get_repo(repo_name) - - def bump_tier(title): - m = re.search(r"\bfrom\s+(\S+)\s+to\s+(\S+)", title, re.IGNORECASE) - if not m: - return "unknown" - old_major = re.match(r"(\d+)", m.group(1)) - new_major = re.match(r"(\d+)", m.group(2)) - if old_major and new_major and old_major.group(1) != new_major.group(1): - return "major" - old_minor = re.match(r"\d+\.(\d+)", m.group(1)) - new_minor = re.match(r"\d+\.(\d+)", m.group(2)) - if old_minor and new_minor and old_minor.group(1) != new_minor.group(1): - return "minor" - return "patch" - - def is_blocklisted(title): - # Extract package name heuristically from "bump X from..." - m = re.search(r"bump\s+(\S+)\s+from", title, re.IGNORECASE) - if m: - return m.group(1).lower() in blocklist_pip - return False - - prs = list(repo.get_pulls(state="open")) - dep_prs = [p for p in prs if p.user and p.user.login == "dependabot[bot]" and not p.draft] - - rows = [] - for pr in dep_prs: - bump = bump_tier(pr.title) - blocked = is_blocklisted(pr.title) - if blocked or bump == "major": - tier, decision = "2", "needs-review" - elif bump in ("minor", "patch"): - tier, decision = "1", "auto-merge" if not dry_run else "would-auto-merge" - else: - tier, decision = "2", "needs-review" - rows.append((pr.number, tier, decision, bump, pr.title[:60])) - - # Write step summary - summary_lines = [ - "## 🤖 Dependabot Auto-Merge — Batch Scan", - "", - f"**Dry-run:** {dry_run}", - f"**Open Dependabot PRs found:** {len(dep_prs)}", - "", - "| PR | Tier | Decision | Bump | Title |", - "|----|------|----------|------|-------|", - ] - for num, tier, decision, bump, title in rows: - summary_lines.append(f"| #{num} | {tier} | `{decision}` | {bump} | {title} |") - - output = "\n".join(summary_lines) - print(output) - - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if summary_file: - with open(summary_file, "a") as f: - f.write(output + "\n") - PYEOF + run: python .github/scripts/batch_scan_dependabot.py From bd9d2a809e6a5c224b5e78346f396480a56585f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Jul 2026 06:59:25 +0000 Subject: [PATCH 4/7] style: auto-fix pre-commit issues [skip ci] --- .github/scripts/batch_scan_dependabot.py | 11 +++++------ .github/scripts/tier_classifier.py | 11 ++++++++--- tests/unit/test_tier_classifier.py | 7 ++++++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/scripts/batch_scan_dependabot.py b/.github/scripts/batch_scan_dependabot.py index 938b305..4757e18 100644 --- a/.github/scripts/batch_scan_dependabot.py +++ b/.github/scripts/batch_scan_dependabot.py @@ -63,7 +63,9 @@ def main() -> None: sys.exit("GITHUB_REPOSITORY not set") dry_run = os.environ.get("DRY_RUN", "true").lower() == "true" - exceptions_file = os.environ.get("EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml") + exceptions_file = os.environ.get( + "EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml" + ) blocklist = _load_blocklist_pip(exceptions_file) g = Github(token) @@ -74,8 +76,7 @@ def main() -> None: prs = list(repo.get_pulls(state="open")) dep_prs = [ - p for p in prs - if p.user and p.user.login == "dependabot[bot]" and not p.draft + p for p in prs if p.user and p.user.login == "dependabot[bot]" and not p.draft ] rows: list[tuple[int, str, str, str, str]] = [] @@ -101,9 +102,7 @@ def main() -> None: "|----|------|----------|------|-------|", ] for num, tier, decision, bump, title in rows: - summary_lines.append( - f"| #{num} | {tier} | `{decision}` | {bump} | {title} |" - ) + summary_lines.append(f"| #{num} | {tier} | `{decision}` | {bump} | {title} |") output = "\n".join(summary_lines) print(output) diff --git a/.github/scripts/tier_classifier.py b/.github/scripts/tier_classifier.py index dc4896f..9039be4 100644 --- a/.github/scripts/tier_classifier.py +++ b/.github/scripts/tier_classifier.py @@ -38,13 +38,18 @@ # Helpers # --------------------------------------------------------------------------- + def _load_exceptions(path: str) -> dict[str, list[str]]: p = Path(path) if not p.exists(): return {} with open(p, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return {k: [str(v).lower() for v in vs] for k, vs in data.items() if isinstance(vs, list)} + return { + k: [str(v).lower() for v in vs] + for k, vs in data.items() + if isinstance(vs, list) + } def _is_blocklisted( @@ -84,6 +89,7 @@ def _write_summary(lines: list[str]) -> None: # Main classification logic # --------------------------------------------------------------------------- + def classify() -> None: pr_title = os.environ.get("PR_TITLE", "") update_type = os.environ.get("UPDATE_TYPE", "") @@ -109,8 +115,7 @@ def classify() -> None: tier = "2" merge_decision = "needs-review" reason = ( - f"Blocklisted package(s) require human review: " - f"{', '.join(blocklisted)}" + f"Blocklisted package(s) require human review: " f"{', '.join(blocklisted)}" ) elif is_major: tier = "2" diff --git a/tests/unit/test_tier_classifier.py b/tests/unit/test_tier_classifier.py index d5104e5..83bf187 100644 --- a/tests/unit/test_tier_classifier.py +++ b/tests/unit/test_tier_classifier.py @@ -25,6 +25,7 @@ # _load_exceptions # --------------------------------------------------------------------------- + class TestLoadExceptions: def test_loads_pip_blocklist(self, tmp_path): cfg = tmp_path / "exc.yml" @@ -49,6 +50,7 @@ def test_normalises_to_lowercase(self, tmp_path): # _is_blocklisted # --------------------------------------------------------------------------- + class TestIsBlocklisted: _exceptions = {"pip": ["stripe", "cryptography", "scipy"]} @@ -80,10 +82,13 @@ def test_case_insensitive_match(self): # classify() via environment variable injection # --------------------------------------------------------------------------- + class TestClassify: """Run classify() with a mocked environment and capture GITHUB_OUTPUT.""" - def _run(self, env_overrides: dict, tmp_path: Path, exceptions_content: str = "") -> dict: + def _run( + self, env_overrides: dict, tmp_path: Path, exceptions_content: str = "" + ) -> dict: exc_file = tmp_path / "exc.yml" if exceptions_content: exc_file.write_text(exceptions_content) From ed560489fb02f89b37f30f835af32065af922c27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:03:28 +0000 Subject: [PATCH 5/7] fix: address 6 code review comments on dependabot auto-merge pipeline --- .github/scripts/batch_scan_dependabot.py | 26 ++++++++++++------- .github/scripts/tier_classifier.py | 11 +++++--- .github/workflows/dependabot-automerge.yml | 9 ++----- tests/unit/test_tier_classifier.py | 29 ++++++++++++++++++---- 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/scripts/batch_scan_dependabot.py b/.github/scripts/batch_scan_dependabot.py index 4757e18..8a1bfcd 100644 --- a/.github/scripts/batch_scan_dependabot.py +++ b/.github/scripts/batch_scan_dependabot.py @@ -22,31 +22,39 @@ from github import Github, GithubException -def _load_blocklist_pip(exceptions_file: str) -> list[str]: +def _load_blocklist(exceptions_file: str) -> set[str]: + """Return a flat set of all blocklisted package names across all ecosystems.""" p = Path(exceptions_file) if not p.exists(): - return [] + return set() with open(p, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return [str(v).lower() for v in data.get("pip", [])] + names: set[str] = set() + for vs in data.values(): + if isinstance(vs, list): + names.update(str(v).lower() for v in vs) + return names def _bump_kind(title: str) -> str: m = re.search(r"\bfrom\s+(\S+)\s+to\s+(\S+)", title, re.IGNORECASE) if not m: return "unknown" - old_major = re.match(r"(\d+)", m.group(1)) - new_major = re.match(r"(\d+)", m.group(2)) + # Strip leading "v"/"V" so Actions-style tags (v4 → v5) are classified correctly. + old_ver = m.group(1).lstrip("vV") + new_ver = m.group(2).lstrip("vV") + old_major = re.match(r"(\d+)", old_ver) + new_major = re.match(r"(\d+)", new_ver) if old_major and new_major and old_major.group(1) != new_major.group(1): return "major" - old_minor = re.match(r"\d+\.(\d+)", m.group(1)) - new_minor = re.match(r"\d+\.(\d+)", m.group(2)) + old_minor = re.match(r"\d+\.(\d+)", old_ver) + new_minor = re.match(r"\d+\.(\d+)", new_ver) if old_minor and new_minor and old_minor.group(1) != new_minor.group(1): return "minor" return "patch" -def _is_blocklisted(title: str, blocklist: list[str]) -> bool: +def _is_blocklisted(title: str, blocklist: set[str]) -> bool: m = re.search(r"bump\s+(\S+)\s+from", title, re.IGNORECASE) if m: return m.group(1).lower() in blocklist @@ -66,7 +74,7 @@ def main() -> None: exceptions_file = os.environ.get( "EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml" ) - blocklist = _load_blocklist_pip(exceptions_file) + blocklist = _load_blocklist(exceptions_file) g = Github(token) try: diff --git a/.github/scripts/tier_classifier.py b/.github/scripts/tier_classifier.py index 9039be4..7c27edf 100644 --- a/.github/scripts/tier_classifier.py +++ b/.github/scripts/tier_classifier.py @@ -33,11 +33,14 @@ import yaml - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- +# Dependabot fetch-metadata reports GitHub Actions updates with ecosystem +# "github_actions"; normalise to "actions" to match the exceptions file key. +_ECOSYSTEM_ALIASES: dict[str, str] = {"github_actions": "actions"} + def _load_exceptions(path: str) -> dict[str, list[str]]: p = Path(path) @@ -46,7 +49,7 @@ def _load_exceptions(path: str) -> dict[str, list[str]]: with open(p, encoding="utf-8") as f: data = yaml.safe_load(f) or {} return { - k: [str(v).lower() for v in vs] + k.lower(): [str(v).lower() for v in vs] for k, vs in data.items() if isinstance(vs, list) } @@ -58,7 +61,9 @@ def _is_blocklisted( exceptions: dict[str, list[str]], ) -> list[str]: blocked: list[str] = [] - ecosystem_key = ecosystem.lower() if ecosystem else "" + # Normalise ecosystem name (e.g. "github_actions" → "actions"). + normalised = ecosystem.lower() if ecosystem else "" + ecosystem_key = _ECOSYSTEM_ALIASES.get(normalised, normalised) blocklist = exceptions.get(ecosystem_key, []) for name in dep_names: if name.lower() in blocklist: diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 2447d6a..90eb153 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -27,10 +27,6 @@ on: workflow_dispatch: inputs: - pr_number: - description: "PR number to evaluate (leave blank to scan all open Dependabot PRs)" - required: false - type: string dry_run: description: "Dry-run mode — log decisions without merging" required: false @@ -39,7 +35,7 @@ on: options: ["true", "false"] concurrency: - group: dependabot-automerge-${{ github.event.pull_request.number || inputs.pr_number || 'batch' }} + group: dependabot-automerge-${{ github.event.pull_request.number || 'batch' }} cancel-in-progress: false permissions: @@ -261,8 +257,7 @@ jobs: name: "Batch scan (workflow_dispatch)" runs-on: ubuntu-latest if: > - github.event_name == 'workflow_dispatch' && - (inputs.pr_number == '' || inputs.pr_number == null) + github.event_name == 'workflow_dispatch' steps: - name: Checkout diff --git a/tests/unit/test_tier_classifier.py b/tests/unit/test_tier_classifier.py index 83bf187..b5e8764 100644 --- a/tests/unit/test_tier_classifier.py +++ b/tests/unit/test_tier_classifier.py @@ -6,21 +6,16 @@ import sys from pathlib import Path -import pytest - # Add scripts directory to path _scripts_path = str(Path(__file__).parent.parent.parent / ".github" / "scripts") if _scripts_path not in sys.path: sys.path.insert(0, _scripts_path) -import importlib -import types # We import the module but test its helpers directly to avoid needing # GITHUB_OUTPUT / GITHUB_STEP_SUMMARY in the environment. import tier_classifier - # --------------------------------------------------------------------------- # _load_exceptions # --------------------------------------------------------------------------- @@ -45,6 +40,15 @@ def test_normalises_to_lowercase(self, tmp_path): assert "stripe" in result["pip"] assert "cryptography" in result["pip"] + def test_normalises_keys_to_lowercase(self, tmp_path): + cfg = tmp_path / "exc.yml" + cfg.write_text("Pip:\n - stripe\nActions:\n - actions/checkout\n") + result = tier_classifier._load_exceptions(str(cfg)) + assert "pip" in result + assert "actions" in result + assert "Pip" not in result + assert "Actions" not in result + # --------------------------------------------------------------------------- # _is_blocklisted @@ -77,6 +81,21 @@ def test_case_insensitive_match(self): blocked = tier_classifier._is_blocklisted(["Stripe"], "pip", self._exceptions) assert blocked == ["Stripe"] + def test_github_actions_ecosystem_alias(self): + exceptions = {"actions": ["actions/checkout", "actions/upload-artifact"]} + # Dependabot fetch-metadata reports "github_actions"; must map to "actions" + blocked = tier_classifier._is_blocklisted( + ["actions/checkout"], "github_actions", exceptions + ) + assert blocked == ["actions/checkout"] + + def test_github_actions_safe_package_not_flagged(self): + exceptions = {"actions": ["actions/checkout"]} + blocked = tier_classifier._is_blocklisted( + ["actions/setup-python"], "github_actions", exceptions + ) + assert blocked == [] + # --------------------------------------------------------------------------- # classify() via environment variable injection From 7b31dc2487934188d163af51920085b6e6b266de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:04:59 +0000 Subject: [PATCH 6/7] docs: clarify test module comment about env var requirements --- tests/unit/test_tier_classifier.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_tier_classifier.py b/tests/unit/test_tier_classifier.py index b5e8764..11f18b6 100644 --- a/tests/unit/test_tier_classifier.py +++ b/tests/unit/test_tier_classifier.py @@ -12,8 +12,9 @@ sys.path.insert(0, _scripts_path) -# We import the module but test its helpers directly to avoid needing -# GITHUB_OUTPUT / GITHUB_STEP_SUMMARY in the environment. +# We import the module and call its helpers directly. +# Helper tests (TestLoadExceptions, TestIsBlocklisted) need no GitHub env vars. +# TestClassify supplies GITHUB_OUTPUT / GITHUB_STEP_SUMMARY via tmp_path. import tier_classifier # --------------------------------------------------------------------------- From 9215bf378b0c5704aba42b961ef67e517315ddc5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Jul 2026 10:07:34 +0000 Subject: [PATCH 7/7] style: auto-fix pre-commit issues [skip ci] --- .github/scripts/generate_rollback_manifest.py | 4 ++-- autopilot/staleness_engine.py | 10 +++++----- tests/test_autonomous_agents.py | 10 ++++------ tests/unit/test_dependency_agent.py | 1 - tests/unit/test_security_scan_agent.py | 2 -- tests/unit/test_staleness_engine.py | 8 +++----- tests/unit/test_triage_agent.py | 13 +++++++------ 7 files changed, 21 insertions(+), 27 deletions(-) diff --git a/.github/scripts/generate_rollback_manifest.py b/.github/scripts/generate_rollback_manifest.py index 54dcb7a..c7c9de9 100644 --- a/.github/scripts/generate_rollback_manifest.py +++ b/.github/scripts/generate_rollback_manifest.py @@ -7,7 +7,7 @@ """ import os -from datetime import datetime, timezone +from datetime import UTC, datetime def main(): @@ -17,7 +17,7 @@ def main(): author = os.environ.get("AUTHOR", "unknown") files_changed = os.environ.get("FILES_CHANGED", "").split() - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") path = "docs/ROLLBACK.md" os.makedirs("docs", exist_ok=True) diff --git a/autopilot/staleness_engine.py b/autopilot/staleness_engine.py index 5df6cb8..ff98769 100644 --- a/autopilot/staleness_engine.py +++ b/autopilot/staleness_engine.py @@ -26,7 +26,7 @@ import logging import os import sys -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -204,7 +204,7 @@ def generate_comment( cost = result.get("usage", {}) self._cost_log.append( { - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "repo": repo_full_name, "tier": tier, "usage": cost, @@ -231,7 +231,7 @@ def scan_repos(self, repos: list[dict[str, Any]]) -> list[dict[str, Any]]: repo_full_name, pr_number, pr_title, pr_url, age_days, tier """ stale: list[dict[str, Any]] = [] - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for repo_cfg in repos: owner = repo_cfg["owner"] @@ -243,7 +243,7 @@ def scan_repos(self, repos: list[dict[str, Any]]) -> list[dict[str, Any]]: for pr in pulls: created = pr.created_at if created.tzinfo is None: - created = created.replace(tzinfo=timezone.utc) + created = created.replace(tzinfo=UTC) age_days = (now - created).days tier = self.assign_tier(age_days) if tier > 0: @@ -291,7 +291,7 @@ def process_stale_prs( Returns a summary dict with counts and per-PR actions taken. """ state = self.load_state() - now_iso = datetime.now(timezone.utc).isoformat() + now_iso = datetime.now(UTC).isoformat() actions: list[dict[str, Any]] = [] for pr_info in stale_prs: diff --git a/tests/test_autonomous_agents.py b/tests/test_autonomous_agents.py index 4a88150..822dc8c 100644 --- a/tests/test_autonomous_agents.py +++ b/tests/test_autonomous_agents.py @@ -1,8 +1,7 @@ """Tests for autonomous_agent package — agents and CLI.""" -import re -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from datetime import UTC, datetime +from unittest.mock import Mock, patch import pytest from click.testing import CliRunner @@ -20,7 +19,6 @@ main, ) - # ── helpers ─────────────────────────────────────────────────────────────────── @@ -73,7 +71,7 @@ async def test_execute_stale_branch_requires_approval(self): branch = Mock() branch.name = "old-feature" - old_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + old_date = datetime(2025, 1, 1, tzinfo=UTC) branch.commit.commit.author.date = old_date repo.get_branches.return_value = [branch] self.github.get_repository.return_value = repo @@ -92,7 +90,7 @@ async def test_execute_stale_branch_auto_delete(self): branch = Mock() branch.name = "old-feature" - old_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + old_date = datetime(2025, 1, 1, tzinfo=UTC) branch.commit.commit.author.date = old_date branch.commit.sha = "abc123" diff --git a/tests/unit/test_dependency_agent.py b/tests/unit/test_dependency_agent.py index c385473..52641f0 100644 --- a/tests/unit/test_dependency_agent.py +++ b/tests/unit/test_dependency_agent.py @@ -4,7 +4,6 @@ import asyncio import json -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/unit/test_security_scan_agent.py b/tests/unit/test_security_scan_agent.py index a7702af..d0fb780 100644 --- a/tests/unit/test_security_scan_agent.py +++ b/tests/unit/test_security_scan_agent.py @@ -4,8 +4,6 @@ import asyncio import json -import tempfile -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, mock_open, patch diff --git a/tests/unit/test_staleness_engine.py b/tests/unit/test_staleness_engine.py index 5f47706..5a735e9 100644 --- a/tests/unit/test_staleness_engine.py +++ b/tests/unit/test_staleness_engine.py @@ -2,14 +2,12 @@ from __future__ import annotations -import json -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path -from unittest.mock import MagicMock, patch, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -41,7 +39,7 @@ def _make_pr(number=1, title="Test PR", days_old=10, html_url="http://example.co pr.number = number pr.title = title pr.html_url = html_url - pr.created_at = datetime.now(timezone.utc) - timedelta(days=days_old) + pr.created_at = datetime.now(UTC) - timedelta(days=days_old) return pr diff --git a/tests/unit/test_triage_agent.py b/tests/unit/test_triage_agent.py index 0745683..023f63b 100644 --- a/tests/unit/test_triage_agent.py +++ b/tests/unit/test_triage_agent.py @@ -4,9 +4,7 @@ import asyncio import json -import tempfile -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch def run(coro): @@ -94,9 +92,10 @@ def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch): assert result == [] def test_returns_records_within_window(self, tmp_path, monkeypatch): - import agents.triage_agent as mod from datetime import datetime, timedelta + import agents.triage_agent as mod + monkeypatch.setattr(mod, "project_root", tmp_path) costs_path = tmp_path / ".llm_costs.jsonl" recent = (datetime.now() - timedelta(days=1)).isoformat() @@ -138,9 +137,10 @@ def setup_method(self): self.agent = _make_agent() def test_no_slack_url_prints_and_returns(self, tmp_path, monkeypatch, capsys): - import agents.triage_agent as mod from datetime import datetime, timedelta + import agents.triage_agent as mod + monkeypatch.setattr(mod, "project_root", tmp_path) monkeypatch.delenv("SLACK_WEBHOOK_URL", raising=False) @@ -174,9 +174,10 @@ def test_empty_costs_returns_zero(self, tmp_path, monkeypatch): assert result["records"] == 0 def test_multiple_models_aggregated(self, tmp_path, monkeypatch): - import agents.triage_agent as mod from datetime import datetime, timedelta + import agents.triage_agent as mod + monkeypatch.setattr(mod, "project_root", tmp_path) monkeypatch.delenv("SLACK_WEBHOOK_URL", raising=False)