Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 }}
```
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pr_sheriff/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""PR Sheriff: deterministic pull request risk checks."""

__version__ = "0.5.0"
__version__ = "0.6.0"
21 changes: 18 additions & 3 deletions src/pr_sheriff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
[
Expand Down
56 changes: 56 additions & 0 deletions src/pr_sheriff/detect.py
Original file line number Diff line number Diff line change
@@ -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"))
17 changes: 17 additions & 0 deletions src/pr_sheriff/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 27 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
@@ -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()