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/batch_scan_dependabot.py b/.github/scripts/batch_scan_dependabot.py new file mode 100644 index 0000000..8a1bfcd --- /dev/null +++ b/.github/scripts/batch_scan_dependabot.py @@ -0,0 +1,125 @@ +#!/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(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 set() + with open(p, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + 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" + # 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+)", 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: set[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(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/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/.github/scripts/tier_classifier.py b/.github/scripts/tier_classifier.py new file mode 100644 index 0000000..7c27edf --- /dev/null +++ b/.github/scripts/tier_classifier.py @@ -0,0 +1,180 @@ +#!/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 +# --------------------------------------------------------------------------- + +# 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) + if not p.exists(): + return {} + with open(p, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return { + k.lower(): [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] = [] + # 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: + 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 (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 new file mode 100644 index 0000000..90eb153 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,281 @@ +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: + 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 || '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 \ + "$(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 \ + --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" + { + 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: + 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_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + 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 \ + "$(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 + # ----------------------------------------------------------------------- + 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_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [[ -n "$BLOCKLISTED" ]]; then + 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 \ + "$(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 + # 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' + + 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: python .github/scripts/batch_scan_dependabot.py 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/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_tier_classifier.py b/tests/unit/test_tier_classifier.py new file mode 100644 index 0000000..11f18b6 --- /dev/null +++ b/tests/unit/test_tier_classifier.py @@ -0,0 +1,234 @@ +"""Unit tests for .github/scripts/tier_classifier.py""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# 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) + + +# 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 + +# --------------------------------------------------------------------------- +# _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"] + + 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 +# --------------------------------------------------------------------------- + + +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"] + + 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 +# --------------------------------------------------------------------------- + + +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"] 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)