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
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: CI

on:
Expand All @@ -14,8 +14,8 @@
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 .
Expand All @@ -24,7 +24,7 @@
action-smoke-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- id: sheriff
Expand All @@ -40,3 +40,15 @@
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/*
2 changes: 1 addition & 1 deletion .github/workflows/pr-sheriff.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: PR Sheriff

on:
Expand All @@ -11,7 +11,7 @@
review-risk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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 }}
```
Expand Down Expand Up @@ -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
Expand All @@ -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
{
Expand Down
13 changes: 9 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

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.4.0"
__version__ = "0.5.0"
133 changes: 125 additions & 8 deletions src/pr_sheriff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)}")
Expand All @@ -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:
Expand Down
Loading