From c9e181ebff8d3d640905e9133864383c3a1bc636 Mon Sep 17 00:00:00 2001 From: Hayal08 Date: Sun, 7 Jun 2026 13:20:49 +0300 Subject: [PATCH] Add automatic policy detection --- CHANGELOG.md | 6 ++++ README.md | 18 ++++++++---- ROADMAP.md | 3 +- pyproject.toml | 2 +- src/pr_sheriff/__init__.py | 2 +- src/pr_sheriff/cli.py | 21 ++++++++++++-- src/pr_sheriff/detect.py | 56 ++++++++++++++++++++++++++++++++++++++ src/pr_sheriff/presets.py | 17 ++++++++++++ tests/test_cli.py | 28 ++++++++++++++++++- tests/test_detect.py | 53 ++++++++++++++++++++++++++++++++++++ 10 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 src/pr_sheriff/detect.py create mode 100644 tests/test_detect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92db3bf..fcb557d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to PR Sheriff are documented here. +## 0.6.0 - 2026-06-07 + +- Add `pr-sheriff install-github --detect` for automatic policy selection. +- Detect Python, JavaScript/TypeScript, mixed, and unknown repositories. +- Explain the selected preset and the project markers used as evidence. + ## 0.5.0 - 2026-06-07 - Add ready-made Python and JavaScript/TypeScript policy presets. diff --git a/README.md b/README.md index 4a2c02a..b3e687c 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,19 @@ reviewing: ```bash python -m pip install pr-sheriff -pr-sheriff install-github --preset python +pr-sheriff install-github --detect ``` 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. +advisory mode, so it reports risks without blocking contributors. Detection +uses root project manifests and lockfiles, and falls back to the safe default +policy when no known markers exist. Use `--preset python` or +`--preset javascript` to choose explicitly, or add `--mode enforce` when the +policy is ready to become required. + +For mixed Python and JavaScript repositories, detection combines both presets. +Only root manifests and lockfiles are considered, so nested examples and +vendored projects cannot silently change the selected policy. For local-only checks: @@ -104,7 +110,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: Hayal08/pr-sheriff@v0.5.0 + - uses: Hayal08/pr-sheriff@v0.6.0 with: base: origin/${{ github.base_ref }} ``` @@ -133,7 +139,7 @@ Use advisory mode to learn what the policy would flag before making it a required check: ```yaml -- uses: Hayal08/pr-sheriff@v0.5.0 +- uses: Hayal08/pr-sheriff@v0.6.0 with: base: origin/${{ github.base_ref }} mode: advisory diff --git a/ROADMAP.md b/ROADMAP.md index e8313e0..6d3449e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ review. The roadmap is intentionally small and driven by maintainer feedback. ## Now: make adoption easy -- Add ready-to-copy policy presets for Python, JavaScript, and Rust projects. +- Add a ready-to-copy policy preset for Rust projects. - Improve error messages and configuration validation. - Add documentation examples for common repository layouts. - Collect feedback from the first repositories using the Action. @@ -17,6 +17,7 @@ review. The roadmap is intentionally small and driven by maintainer feedback. Recently completed: +- Automatically detect Python, JavaScript/TypeScript, and mixed repositories. - Support path-specific thresholds and policies. - Explain how each part of the risk score was calculated. - Add a non-blocking advisory mode for gradual adoption. diff --git a/pyproject.toml b/pyproject.toml index 49c6b94..4af69fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pr-sheriff" -version = "0.5.0" +version = "0.6.0" description = "Deterministic pull request risk checks for busy maintainers" readme = "README.md" requires-python = ">=3.10" diff --git a/src/pr_sheriff/__init__.py b/src/pr_sheriff/__init__.py index 24e1a4b..a4481a7 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.5.0" +__version__ = "0.6.0" diff --git a/src/pr_sheriff/cli.py b/src/pr_sheriff/cli.py index b6bc340..f16fabb 100644 --- a/src/pr_sheriff/cli.py +++ b/src/pr_sheriff/cli.py @@ -7,6 +7,7 @@ import sys from .core import analyze, git_changes, load_config +from .detect import detect_repository from .github import pull_request_number, upsert_pull_request_comment from .presets import PRESETS, get_preset @@ -27,7 +28,7 @@ - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: Hayal08/pr-sheriff@v0.5.0 + - uses: Hayal08/pr-sheriff@v0.6.0 with: base: origin/${{{{ github.base_ref }}}} config: {config} @@ -62,7 +63,11 @@ def build_parser() -> argparse.ArgumentParser: install.add_argument( "--workflow", default=".github/workflows/pr-sheriff.yml", type=Path ) - install.add_argument("--preset", choices=PRESETS, default="default") + install_policy = install.add_mutually_exclusive_group() + install_policy.add_argument("--preset", choices=PRESETS, default="default") + install_policy.add_argument( + "--detect", action="store_true", help="detect a policy preset from repository files" + ) install.add_argument("--mode", choices=("advisory", "enforce"), default="advisory") install.add_argument("--force", action="store_true") return parser @@ -239,7 +244,17 @@ def main(argv: list[str] | None = None) -> int: 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" + if args.detect: + detection = detect_repository(Path.cwd()) + preset = detection.config + print(f"Detected preset: {detection.preset}") + if detection.evidence: + print(f"Evidence: {', '.join(detection.evidence)}") + else: + print("Evidence: no known project markers; using the default policy") + else: + preset = get_preset(args.preset) + config = json.dumps(preset, indent=2) + "\n" try: install_files( [ diff --git a/src/pr_sheriff/detect.py b/src/pr_sheriff/detect.py new file mode 100644 index 0000000..1b2b221 --- /dev/null +++ b/src/pr_sheriff/detect.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .presets import get_preset, merge_presets + + +PYTHON_MARKERS = ( + "pyproject.toml", + "setup.py", + "setup.cfg", + "poetry.lock", + "uv.lock", + "Pipfile", +) +JAVASCRIPT_MARKERS = ( + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", + "tsconfig.json", +) + + +@dataclass(frozen=True) +class Detection: + preset: str + evidence: tuple[str, ...] + config: dict + + +def existing_markers(root: Path, markers: tuple[str, ...]) -> list[str]: + return [marker for marker in markers if (root / marker).is_file()] + + +def detect_repository(root: Path) -> Detection: + python = existing_markers(root, PYTHON_MARKERS) + python.extend( + path.name for path in sorted(root.glob("requirements*.txt")) if path.is_file() + ) + javascript = existing_markers(root, JAVASCRIPT_MARKERS) + evidence = tuple(python + javascript) + if python and javascript: + return Detection( + "python + javascript", + evidence, + merge_presets("python", "javascript"), + ) + if python: + return Detection("python", evidence, get_preset("python")) + if javascript: + return Detection("javascript", evidence, get_preset("javascript")) + return Detection("default", (), get_preset("default")) diff --git a/src/pr_sheriff/presets.py b/src/pr_sheriff/presets.py index baab476..76fe7e5 100644 --- a/src/pr_sheriff/presets.py +++ b/src/pr_sheriff/presets.py @@ -75,3 +75,20 @@ def get_preset(name: str) -> dict: return deepcopy(PRESETS[name]) + + +def merge_presets(*names: str) -> dict: + configs = [get_preset(name) for name in names] + merged = get_preset("default") + for key in ("max_changed_lines", "max_changed_files", "require_tests_after_lines"): + merged[key] = min(config[key] for config in configs) + for key in ("test_patterns", "sensitive_patterns", "ignore_patterns"): + merged[key] = list( + dict.fromkeys(item for config in configs for item in config[key]) + ) + rules = {} + for config in configs: + for rule in config["path_rules"]: + rules.setdefault(rule["name"], rule) + merged["path_rules"] = list(rules.values()) + return merged diff --git a/tests/test_cli.py b/tests/test_cli.py index 96ed9a2..a6b7e7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,10 +68,36 @@ def test_install_github_writes_config_and_advisory_workflow(self): ) 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("Hayal08/pr-sheriff@v0.6.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_detects_python_repository(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "pyproject.toml").write_text("[project]\n") + output = StringIO() + with redirect_stdout(output): + result = self.run_in(root, ["install-github", "--detect"]) + self.assertEqual(result, 0) + self.assertEqual(json.loads((root / ".pr-sheriff.json").read_text()), PYTHON_CONFIG) + self.assertIn("Detected preset: python", output.getvalue()) + self.assertIn("Evidence: pyproject.toml", output.getvalue()) + + def test_install_github_detect_falls_back_to_default(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = StringIO() + with redirect_stdout(output): + result = self.run_in(root, ["install-github", "--detect"]) + self.assertEqual(result, 0) + self.assertEqual(json.loads((root / ".pr-sheriff.json").read_text()), DEFAULT_CONFIG) + self.assertIn("using the default policy", output.getvalue()) + + def test_install_github_rejects_detect_with_explicit_preset(self): + with self.assertRaises(SystemExit): + main(["install-github", "--detect", "--preset", "python"]) + def test_install_github_uses_custom_config_path_in_workflow(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..df91c62 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path +import tempfile +import unittest + +from pr_sheriff.detect import detect_repository +from pr_sheriff.presets import JAVASCRIPT_CONFIG, PYTHON_CONFIG + + +class DetectTests(unittest.TestCase): + def detect(self, files): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for filename in files: + path = root / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}" if path.suffix == ".json" else "") + return detect_repository(root) + + def test_detects_python_repository(self): + detection = self.detect(["pyproject.toml", "requirements-dev.txt"]) + self.assertEqual(detection.preset, "python") + self.assertEqual(detection.evidence, ("pyproject.toml", "requirements-dev.txt")) + self.assertEqual(detection.config, PYTHON_CONFIG) + + def test_detects_javascript_repository(self): + detection = self.detect(["package.json", "tsconfig.json"]) + self.assertEqual(detection.preset, "javascript") + self.assertEqual(detection.evidence, ("package.json", "tsconfig.json")) + self.assertEqual(detection.config, JAVASCRIPT_CONFIG) + + def test_mixed_repository_combines_test_and_sensitive_patterns(self): + detection = self.detect(["pyproject.toml", "package.json"]) + self.assertEqual(detection.preset, "python + javascript") + self.assertIn("**/test_*.py", detection.config["test_patterns"]) + self.assertIn("**/__tests__/**", detection.config["test_patterns"]) + self.assertIn("pyproject.toml", detection.config["sensitive_patterns"]) + self.assertIn("package.json", detection.config["sensitive_patterns"]) + self.assertEqual(len(detection.config["path_rules"]), 1) + json.dumps(detection.config) + + def test_unknown_repository_uses_default(self): + detection = self.detect(["README.md"]) + self.assertEqual(detection.preset, "default") + self.assertEqual(detection.evidence, ()) + + def test_nested_manifests_do_not_guess_monorepo_policy(self): + detection = self.detect(["examples/package.json"]) + self.assertEqual(detection.preset, "default") + + +if __name__ == "__main__": + unittest.main()