diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2b3fd..3a614b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ jobs: matrix: python-version: ["3.10", "3.12", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - run: python -m pip install . @@ -24,7 +24,7 @@ jobs: action-smoke-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - id: sheriff @@ -40,3 +40,15 @@ jobs: run: | test -n "$RISK" test "$POLICY_PASSED" = "true" -o "$POLICY_PASSED" = "false" + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python -m pip install build ruff twine + - run: ruff check src tests + - run: python -m build + - run: python -m twine check dist/* diff --git a/.github/workflows/pr-sheriff.yml b/.github/workflows/pr-sheriff.yml index 114cd0c..cf8ff0e 100644 --- a/.github/workflows/pr-sheriff.yml +++ b/.github/workflows/pr-sheriff.yml @@ -11,7 +11,7 @@ jobs: review-risk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./ diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..a64389e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,33 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag to publish, for example v0.5.0 + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Verify immutable release tag + env: + RELEASE_TAG: ${{ inputs.tag }} + run: test "$(git describe --exact-match --tags HEAD)" = "$RELEASE_TAG" + - run: python -m pip install build twine + - run: python -m build + - run: python -m twine check dist/* + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8683a..92db3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to PR Sheriff are documented here. +## 0.5.0 - 2026-06-07 + +- Add ready-made Python and JavaScript/TypeScript policy presets. +- Add `pr-sheriff install-github` for a one-command GitHub Action setup. +- Default generated workflows to advisory mode for a gradual rollout. +- Add a manual trusted-publishing workflow for PyPI. +- Validate installer paths, custom config wiring, annotations, and package builds. +- Generate Node.js 24-compatible GitHub Actions workflows. + ## 0.4.0 - 2026-06-06 - Add path-specific thresholds and test requirements. diff --git a/README.md b/README.md index f0ff5db..4a2c02a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI](https://github.com/Hayal08/pr-sheriff/actions/workflows/ci.yml/badge.svg)](https://github.com/Hayal08/pr-sheriff/actions/workflows/ci.yml) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-PR%20Sheriff-blue?logo=github)](https://github.com/marketplace/actions/pr-sheriff) [![Good first issues](https://img.shields.io/github/issues/Hayal08/pr-sheriff/good%20first%20issue)](https://github.com/Hayal08/pr-sheriff/labels/good%20first%20issue) +[![PyPI](https://img.shields.io/pypi/v/pr-sheriff)](https://pypi.org/project/pr-sheriff/) PR Sheriff is a tiny, deterministic pull request risk checker for busy open-source maintainers. It catches oversized changes, missing tests, and edits to sensitive @@ -36,8 +37,19 @@ reviewing: ## Quick start ```bash -python -m pip install . -pr-sheriff init +python -m pip install pr-sheriff +pr-sheriff install-github --preset python +``` + +Commit the two generated files and open a pull request. PR Sheriff starts in +advisory mode, so it reports risks without blocking contributors. Use +`--preset javascript` for JavaScript and TypeScript repositories, or add +`--mode enforce` when the policy is ready to become required. + +For local-only checks: + +```bash +pr-sheriff init --preset python pr-sheriff check --base origin/main ``` @@ -89,10 +101,10 @@ jobs: review-risk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: Hayal08/pr-sheriff@v0.4.0 + - uses: Hayal08/pr-sheriff@v0.5.0 with: base: origin/${{ github.base_ref }} ``` @@ -121,7 +133,7 @@ Use advisory mode to learn what the policy would flag before making it a required check: ```yaml -- uses: Hayal08/pr-sheriff@v0.4.0 +- uses: Hayal08/pr-sheriff@v0.5.0 with: base: origin/${{ github.base_ref }} mode: advisory @@ -132,7 +144,16 @@ turns policy errors into warnings and returns a successful exit code. ## Configuration -Run `pr-sheriff init` or add `.pr-sheriff.json` manually: +Run `pr-sheriff init`, choose a ready-made `python` or `javascript` preset, or +add `.pr-sheriff.json` manually: + +```bash +pr-sheriff init --preset python +``` + +The presets recognize ecosystem-specific test files, manifests, lockfiles, and +database migrations. Generated policies remain ordinary JSON and can be +customized at any time. ```json { diff --git a/pyproject.toml b/pyproject.toml index 0e7551c..49c6b94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,29 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "pr-sheriff" -version = "0.4.0" +version = "0.5.0" description = "Deterministic pull request risk checks for busy maintainers" readme = "README.md" requires-python = ">=3.10" -license = {text = "MIT"} +license = "MIT" authors = [{name = "PR Sheriff contributors"}] keywords = ["git", "pull-request", "maintainer", "code-review"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", ] dependencies = [] +[project.urls] +Homepage = "https://github.com/Hayal08/pr-sheriff" +Repository = "https://github.com/Hayal08/pr-sheriff" +Issues = "https://github.com/Hayal08/pr-sheriff/issues" +Marketplace = "https://github.com/marketplace/actions/pr-sheriff" + [project.scripts] pr-sheriff = "pr_sheriff.cli:main" diff --git a/src/pr_sheriff/__init__.py b/src/pr_sheriff/__init__.py index dc4a4fe..24e1a4b 100644 --- a/src/pr_sheriff/__init__.py +++ b/src/pr_sheriff/__init__.py @@ -1,3 +1,3 @@ """PR Sheriff: deterministic pull request risk checks.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/pr_sheriff/cli.py b/src/pr_sheriff/cli.py index c7ca2ed..b6bc340 100644 --- a/src/pr_sheriff/cli.py +++ b/src/pr_sheriff/cli.py @@ -6,8 +6,33 @@ from pathlib import Path import sys -from .core import DEFAULT_CONFIG, analyze, git_changes, load_config +from .core import analyze, git_changes, load_config from .github import pull_request_number, upsert_pull_request_comment +from .presets import PRESETS, get_preset + + +WORKFLOW_TEMPLATE = """name: PR Sheriff + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + review-risk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: Hayal08/pr-sheriff@v0.5.0 + with: + base: origin/${{{{ github.base_ref }}}} + config: {config} + mode: {mode} +""" def build_parser() -> argparse.ArgumentParser: @@ -29,9 +54,69 @@ def build_parser() -> argparse.ArgumentParser: check.add_argument("--github-comment", action="store_true", help=argparse.SUPPRESS) init = subparsers.add_parser("init", help="write a starter configuration") init.add_argument("--config", default=".pr-sheriff.json", type=Path) + init.add_argument("--preset", choices=PRESETS, default="default") + install = subparsers.add_parser( + "install-github", help="install configuration and a GitHub Actions workflow" + ) + install.add_argument("--config", default=".pr-sheriff.json", type=Path) + install.add_argument( + "--workflow", default=".github/workflows/pr-sheriff.yml", type=Path + ) + install.add_argument("--preset", choices=PRESETS, default="default") + install.add_argument("--mode", choices=("advisory", "enforce"), default="advisory") + install.add_argument("--force", action="store_true") return parser +def write_file(path: Path, content: str, force: bool = False) -> bool: + if path.exists() and not force: + print(f"{path} already exists", file=sys.stderr) + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"Wrote {path}") + return True + + +def install_files(files: list[tuple[Path, str]]) -> None: + originals: dict[Path, bytes | None] = {} + written = [] + try: + for path, content in files: + if path.exists() and not path.is_file(): + raise OSError(f"{path} exists and is not a file") + originals[path] = path.read_bytes() if path.exists() else None + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + written.append(path) + except OSError: + for path in reversed(written): + original = originals[path] + if original is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(original) + raise + for path, _ in files: + print(f"Wrote {path}") + + +def repository_path(path: Path, label: str) -> Path: + root = Path.cwd().resolve() + resolved = path.resolve() if path.is_absolute() else (root / path).resolve() + try: + relative = resolved.relative_to(root) + except ValueError as exc: + raise ValueError(f"{label} must be a path inside the repository") from exc + if not relative.parts: + raise ValueError(f"{label} must point to a file") + return relative + + +def render_workflow(mode: str, config: Path) -> str: + return WORKFLOW_TEMPLATE.format(mode=mode, config=json.dumps(config.as_posix())) + + def print_report(report) -> None: print(f"PR risk: {report.risk.upper()} ({report.score}/100)") print(f"Changed: {report.changed_files} files, {report.changed_lines} lines") @@ -100,8 +185,11 @@ def markdown_report(report, advisory: bool = False) -> str: return "\n".join(lines) + "\n" -def github_escape(value: str) -> str: - return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") +def github_escape(value: str, property_value: bool = False) -> str: + escaped = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + if property_value: + escaped = escaped.replace(":", "%3A").replace(",", "%2C") + return escaped def write_github_output(path: Path, report) -> None: @@ -120,7 +208,8 @@ def write_github_output(path: Path, report) -> None: def print_github_annotations(report, advisory: bool = False) -> None: for path in report.sensitive_files: - print(f"::warning file={github_escape(path)}::Sensitive file changed") + escaped_path = github_escape(path, property_value=True) + print(f"::warning file={escaped_path}::Sensitive file changed") for violation in report.violations: level = "warning" if advisory else "error" print(f"::{level}::{github_escape(violation)}") @@ -129,11 +218,39 @@ def print_github_annotations(report, advisory: bool = False) -> None: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.command == "init": - if args.config.exists(): - print(f"{args.config} already exists", file=sys.stderr) + content = json.dumps(get_preset(args.preset), indent=2) + "\n" + try: + return 0 if write_file(args.config, content) else 2 + except OSError as exc: + print(f"pr-sheriff: could not write configuration: {exc}", file=sys.stderr) + return 2 + if args.command == "install-github": + try: + config_path = repository_path(args.config, "config") + workflow_path = repository_path(args.workflow, "workflow") + if config_path == workflow_path: + raise ValueError("config and workflow must point to different files") + except (OSError, ValueError) as exc: + print(f"pr-sheriff: {exc}", file=sys.stderr) + return 2 + existing = [path for path in (config_path, workflow_path) if path.exists()] + if existing and not args.force: + for path in existing: + print(f"{path} already exists", file=sys.stderr) + print("Use --force to overwrite existing files.", file=sys.stderr) + return 2 + config = json.dumps(get_preset(args.preset), indent=2) + "\n" + try: + install_files( + [ + (config_path, config), + (workflow_path, render_workflow(args.mode, config_path)), + ] + ) + except OSError as exc: + print(f"pr-sheriff: could not install GitHub workflow: {exc}", file=sys.stderr) return 2 - args.config.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8") - print(f"Wrote {args.config}") + print("PR Sheriff is installed. Open a pull request to see the first report.") return 0 try: diff --git a/src/pr_sheriff/presets.py b/src/pr_sheriff/presets.py new file mode 100644 index 0000000..baab476 --- /dev/null +++ b/src/pr_sheriff/presets.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from copy import deepcopy + +from .core import DEFAULT_CONFIG + + +PYTHON_CONFIG = { + **DEFAULT_CONFIG, + "test_patterns": [ + "tests/**", + "test/**", + "**/test_*.py", + "**/*_test.py", + ], + "sensitive_patterns": [ + ".github/workflows/**", + "**/auth/**", + "**/security/**", + "**/migrations/**", + "Dockerfile", + "**/Dockerfile", + "pyproject.toml", + "poetry.lock", + "uv.lock", + "requirements*.txt", + ], + "path_rules": [ + { + "name": "database migrations", + "patterns": ["**/migrations/**"], + "max_changed_lines": 100, + "require_tests_after_lines": 1, + } + ], +} + +JAVASCRIPT_CONFIG = { + **DEFAULT_CONFIG, + "test_patterns": [ + "test/**", + "tests/**", + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + ], + "sensitive_patterns": [ + ".github/workflows/**", + "**/auth/**", + "**/security/**", + "**/migrations/**", + "Dockerfile", + "**/Dockerfile", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + ], + "path_rules": [ + { + "name": "database migrations", + "patterns": ["**/migrations/**"], + "max_changed_lines": 100, + "require_tests_after_lines": 1, + } + ], +} + +PRESETS = { + "default": DEFAULT_CONFIG, + "python": PYTHON_CONFIG, + "javascript": JAVASCRIPT_CONFIG, +} + + +def get_preset(name: str) -> dict: + return deepcopy(PRESETS[name]) diff --git a/tests/test_cli.py b/tests/test_cli.py index fbabdff..96ed9a2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,28 +1,176 @@ import json from contextlib import redirect_stdout from io import StringIO +import os from pathlib import Path import tempfile import unittest from unittest.mock import patch from pr_sheriff.cli import ( - github_escape, markdown_report, print_github_annotations, write_github_output, ) from pr_sheriff.cli import main from pr_sheriff.core import DEFAULT_CONFIG, Report, load_config +from pr_sheriff.presets import JAVASCRIPT_CONFIG, PYTHON_CONFIG class CliTests(unittest.TestCase): + def run_in(self, directory, argv): + previous = Path.cwd() + try: + os.chdir(directory) + return main(argv) + finally: + os.chdir(previous) + def test_init_writes_default_config(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "config.json" self.assertEqual(main(["init", "--config", str(path)]), 0) self.assertEqual(json.loads(path.read_text()), DEFAULT_CONFIG) + def test_init_writes_selected_preset(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "config.json" + self.assertEqual( + main(["init", "--config", str(path), "--preset", "python"]), 0 + ) + self.assertEqual(json.loads(path.read_text()), PYTHON_CONFIG) + + def test_init_reports_write_error_without_traceback(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "blocked").write_text("not a directory") + self.assertEqual(main(["init", "--config", str(root / "blocked/config")]), 2) + + def test_install_github_writes_config_and_advisory_workflow(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / ".pr-sheriff.json" + workflow = root / ".github/workflows/pr-sheriff.yml" + self.assertEqual( + self.run_in( + root, + [ + "install-github", + "--config", + str(config), + "--workflow", + str(workflow), + "--preset", + "javascript", + ], + ), + 0, + ) + self.assertEqual(json.loads(config.read_text()), JAVASCRIPT_CONFIG) + self.assertIn("mode: advisory", workflow.read_text()) + self.assertIn("Hayal08/pr-sheriff@v0.5.0", workflow.read_text()) + self.assertIn("origin/${{ github.base_ref }}", workflow.read_text()) + self.assertIn("actions/checkout@v6", workflow.read_text()) + + def test_install_github_uses_custom_config_path_in_workflow(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "policies/review policy.json" + workflow = root / ".github/workflows/review.yml" + self.assertEqual( + self.run_in( + root, + [ + "install-github", + "--config", + str(config), + "--workflow", + str(workflow), + ], + ), + 0, + ) + self.assertIn('config: "policies/review policy.json"', workflow.read_text()) + + def test_install_github_rejects_same_destination(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "same" + self.assertEqual( + self.run_in( + directory, + [ + "install-github", + "--config", + str(path), + "--workflow", + str(path), + ], + ), + 2, + ) + self.assertFalse(path.exists()) + + def test_install_github_rejects_paths_outside_repository(self): + self.assertEqual( + main(["install-github", "--config", "../policy.json"]), + 2, + ) + + def test_install_github_does_not_partially_overwrite_without_force(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / ".pr-sheriff.json" + workflow = root / ".github/workflows/pr-sheriff.yml" + config.write_text("keep me") + self.assertEqual( + self.run_in( + root, + [ + "install-github", + "--config", + str(config), + "--workflow", + str(workflow), + ], + ), + 2, + ) + self.assertEqual(config.read_text(), "keep me") + self.assertFalse(workflow.exists()) + + def test_install_github_force_overwrites_existing_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / ".pr-sheriff.json" + workflow = root / "pr-sheriff.yml" + config.write_text("old") + workflow.write_text("old") + self.assertEqual( + self.run_in( + root, + [ + "install-github", + "--config", + str(config), + "--workflow", + str(workflow), + "--mode", + "enforce", + "--force", + ], + ), + 0, + ) + self.assertEqual(json.loads(config.read_text()), DEFAULT_CONFIG) + self.assertIn("mode: enforce", workflow.read_text()) + + def test_install_github_rolls_back_when_workflow_write_fails(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / ".github").write_text("not a directory") + self.assertEqual(self.run_in(root, ["install-github"]), 2) + self.assertFalse((root / ".pr-sheriff.json").exists()) + def test_unknown_config_key_is_rejected(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "config.json" @@ -48,11 +196,11 @@ def test_github_outputs_are_appended(self): self.assertIn("policy-passed=true\n", output) def test_annotations_escape_workflow_commands(self): - report = Report("high", 80, 4, 900, False, ["a%b.py"], ["bad\nchange"]) + report = Report("high", 80, 4, 900, False, ["a,b:c%d.py"], ["bad\nchange"]) output = StringIO() with redirect_stdout(output): print_github_annotations(report) - self.assertIn("file=a%25b.py", output.getvalue()) + self.assertIn("file=a%2Cb%3Ac%25d.py", output.getvalue()) self.assertIn("bad%0Achange", output.getvalue()) def test_advisory_mode_returns_success_for_violations(self): diff --git a/tests/test_github.py b/tests/test_github.py index a0fc8db..02cd83d 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,4 +1,3 @@ -import json from pathlib import Path import tempfile import unittest