pr-policy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pr_policy/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pr-policy: check a pull request against the policy the project wrote down."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
pr_policy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pr_policy.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
pr_policy/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """Command line interface for pr-policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from pr_policy import __version__
11
+ from pr_policy.config import DEFAULTS, ConfigError, load_config
12
+ from pr_policy.context import GitError, from_git, load_event
13
+ from pr_policy.infer import render, scan
14
+ from pr_policy.report import ERROR, INFO, WARN, Report, summarise
15
+ from pr_policy.rules import evaluate
16
+
17
+ MARKS = {ERROR: "error", WARN: "warn", INFO: "info"}
18
+ COLOURS = {ERROR: "31", WARN: "33", INFO: "36"}
19
+
20
+
21
+ class Style:
22
+ def __init__(self, enabled: bool) -> None:
23
+ self.enabled = enabled
24
+
25
+ def paint(self, code: str, text: str) -> str:
26
+ return f"\033[{code}m{text}\033[0m" if self.enabled else text
27
+
28
+ def severity(self, severity: str) -> str:
29
+ return self.paint(COLOURS.get(severity, "0"), MARKS.get(severity, severity))
30
+
31
+ def bold(self, text: str) -> str:
32
+ return self.paint("1", text)
33
+
34
+ def dim(self, text: str) -> str:
35
+ return self.paint("2", text)
36
+
37
+ def green(self, text: str) -> str:
38
+ return self.paint("32", text)
39
+
40
+
41
+ def _colour_enabled(stream) -> bool:
42
+ if os.environ.get("NO_COLOR"):
43
+ return False
44
+ return bool(getattr(stream, "isatty", lambda: False)())
45
+
46
+
47
+ def render_text(report: Report, style: Style, skipped: list[str]) -> str:
48
+ lines = [style.bold("pr-policy"), ""]
49
+
50
+ if report.clean:
51
+ lines.append(style.green(" No findings. This pull request follows the policy."))
52
+ else:
53
+ for finding in report.ordered:
54
+ lines.append(f" {style.severity(finding.severity)} {finding.message}")
55
+ if finding.hint:
56
+ lines.append(f" {style.dim(finding.hint)}")
57
+ lines.append("")
58
+ lines.pop()
59
+
60
+ lines += ["", style.dim(f" rules run: {', '.join(report.checked) or 'none'}")]
61
+ if skipped:
62
+ lines.append(
63
+ style.dim(f" skipped (no pull request title or body available): {', '.join(skipped)}")
64
+ )
65
+ lines.append(f" {summarise(report)}")
66
+
67
+ if report.errors and not report.enforce:
68
+ lines.append(
69
+ style.dim(" reporting only — set 'enforce: true' to fail the build on errors")
70
+ )
71
+ return "\n".join(lines)
72
+
73
+
74
+ def render_markdown(report: Report, skipped: list[str]) -> str:
75
+ """A comment body suitable for posting on the pull request."""
76
+ if report.clean:
77
+ return (
78
+ "### pr-policy\n\nNo findings — this pull request follows the project's "
79
+ "contribution policy.\n"
80
+ )
81
+
82
+ icons = {ERROR: "🔴", WARN: "🟡", INFO: "🔵"}
83
+ lines = ["### pr-policy", "", f"Found {summarise(report)}.", ""]
84
+ for finding in report.ordered:
85
+ lines.append(f"- {icons.get(finding.severity, '•')} **{finding.message}**")
86
+ if finding.hint:
87
+ lines.append(f" {finding.hint}")
88
+ lines.append("")
89
+ lines.append(
90
+ f"<sub>Rules run: {', '.join(report.checked) or 'none'}."
91
+ + (f" Skipped: {', '.join(skipped)}." if skipped else "")
92
+ + " These are signals for the maintainer, not a verdict on your change.</sub>"
93
+ )
94
+ return "\n".join(lines) + "\n"
95
+
96
+
97
+ def _add_check_arguments(parser: argparse.ArgumentParser) -> None:
98
+ parser.add_argument("--repo", type=Path, default=Path("."), help="repository root")
99
+ parser.add_argument("--base", default="origin/HEAD", help="base ref to compare against")
100
+ parser.add_argument("--head", default="HEAD", help="head ref of the pull request")
101
+ parser.add_argument("--config", type=Path, default=None, help="path to pr-policy.yml")
102
+ parser.add_argument("--title", default=None, help="pull request title")
103
+ parser.add_argument(
104
+ "--body-file",
105
+ type=Path,
106
+ default=None,
107
+ help="file holding the pull request body, or - for stdin",
108
+ )
109
+ parser.add_argument("-f", "--format", choices=("text", "json", "markdown"), default="text")
110
+ parser.add_argument(
111
+ "--strict",
112
+ action="store_true",
113
+ help="fail on error findings even if the configuration does not enforce",
114
+ )
115
+
116
+
117
+ def build_parser() -> argparse.ArgumentParser:
118
+ parser = argparse.ArgumentParser(
119
+ prog="pr-policy",
120
+ description=(
121
+ "Check a pull request against the contribution policy the project "
122
+ "wrote down. Deterministic: it never tries to detect AI."
123
+ ),
124
+ )
125
+ parser.add_argument("--version", action="version", version=f"pr-policy {__version__}")
126
+ sub = parser.add_subparsers(dest="command")
127
+
128
+ check = sub.add_parser("check", help="check a pull request against the policy")
129
+ _add_check_arguments(check)
130
+
131
+ init = sub.add_parser(
132
+ "init", help="write a starter config inferred from CONTRIBUTING.md and the PR template"
133
+ )
134
+ init.add_argument("--repo", type=Path, default=Path("."), help="repository root")
135
+ init.add_argument(
136
+ "--stdout", action="store_true", help="print the configuration instead of writing it"
137
+ )
138
+ init.add_argument("--force", action="store_true", help="overwrite an existing config")
139
+ return parser
140
+
141
+
142
+ def run_check(args: argparse.Namespace) -> int:
143
+ try:
144
+ config = load_config(args.repo, args.config)
145
+ except ConfigError as exc:
146
+ print(f"pr-policy: {exc}", file=sys.stderr)
147
+ return 2
148
+
149
+ body = None
150
+ if args.body_file is not None:
151
+ try:
152
+ # Read rather than stat: the body often arrives through a pipe or a
153
+ # process substitution, neither of which is a regular file.
154
+ if str(args.body_file) == "-":
155
+ body = sys.stdin.read()
156
+ else:
157
+ body = args.body_file.read_text(encoding="utf-8")
158
+ except OSError as exc:
159
+ print(f"pr-policy: cannot read {args.body_file}: {exc.strerror}", file=sys.stderr)
160
+ return 2
161
+
162
+ try:
163
+ pr = from_git(
164
+ args.repo, args.base, args.head, event=load_event(), title=args.title, body=body
165
+ )
166
+ except GitError as exc:
167
+ print(f"pr-policy: {exc}", file=sys.stderr)
168
+ return 2
169
+
170
+ findings, checked, skipped = evaluate(pr, config)
171
+ report = Report(findings=findings, enforce=config.enforce or args.strict, checked=checked)
172
+
173
+ if args.format == "json":
174
+ print(report.to_json())
175
+ elif args.format == "markdown":
176
+ print(render_markdown(report, skipped), end="")
177
+ else:
178
+ print(render_text(report, Style(_colour_enabled(sys.stdout)), skipped))
179
+ return report.exit_code
180
+
181
+
182
+ def run_init(args: argparse.Namespace) -> int:
183
+ if not args.repo.is_dir():
184
+ print(f"pr-policy: {args.repo} is not a directory", file=sys.stderr)
185
+ return 2
186
+
187
+ signals = scan(args.repo)
188
+ content = render(args.repo, signals, DEFAULTS)
189
+
190
+ if args.stdout:
191
+ print(content, end="")
192
+ return 0
193
+
194
+ target = args.repo / ".github" / "pr-policy.yml"
195
+ if target.exists() and not args.force:
196
+ print(f"pr-policy: {target} already exists (use --force to overwrite)", file=sys.stderr)
197
+ return 2
198
+
199
+ target.parent.mkdir(parents=True, exist_ok=True)
200
+ target.write_text(content, encoding="utf-8")
201
+
202
+ print(f"Wrote {target}")
203
+ if signals:
204
+ print("\nInferred from this project's own documentation:")
205
+ for signal in signals:
206
+ print(f" {signal.rule}.{signal.option} <- {signal.source}")
207
+ else:
208
+ print("\nNo project-specific policy found; wrote the defaults.")
209
+ print("\nReview it, then run: pr-policy check --base origin/main")
210
+ return 0
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> int:
214
+ parser = build_parser()
215
+ args = parser.parse_args(argv)
216
+ if args.command == "check":
217
+ return run_check(args)
218
+ if args.command == "init":
219
+ return run_init(args)
220
+ parser.print_help()
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__": # pragma: no cover
225
+ raise SystemExit(main())
pr_policy/config.py ADDED
@@ -0,0 +1,182 @@
1
+ """Configuration loading for pr-policy.
2
+
3
+ The configuration is deliberately small. Every rule has an ``enabled`` flag, a
4
+ ``severity``, and its own options; anything not set falls back to the defaults
5
+ below. Defaults are chosen so that a repository which installs pr-policy and
6
+ writes no configuration at all gets useful signal without ever failing a build.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ CONFIG_PATHS = (
18
+ ".github/pr-policy.yml",
19
+ ".github/pr-policy.yaml",
20
+ "pr-policy.yml",
21
+ "pr-policy.yaml",
22
+ )
23
+
24
+ SEVERITIES = ("error", "warn", "info")
25
+
26
+ # Identity fragments that coding agents put in their own commit trailers.
27
+ # This is not AI detection: it matches text the agent writes about itself, and
28
+ # a contributor who does not want to be matched simply does not add the trailer.
29
+ AGENT_IDENTITIES = (
30
+ "claude",
31
+ "copilot",
32
+ "codex",
33
+ "chatgpt",
34
+ "cursor",
35
+ "devin",
36
+ "aider",
37
+ "gemini",
38
+ "windsurf",
39
+ "amazon q",
40
+ "noreply@anthropic.com",
41
+ "noreply@openai.com",
42
+ )
43
+
44
+ DEFAULTS: dict[str, dict[str, Any]] = {
45
+ "attribution": {
46
+ "enabled": True,
47
+ "severity": "warn",
48
+ # The kernel policy: agents must never certify the DCO.
49
+ "forbid_agent_sign_off": True,
50
+ # Prefer `Assisted-by:` over `Co-authored-by:` for tool assistance.
51
+ "prefer_assisted_by": True,
52
+ # Off by default: only projects that actually run the DCO want this.
53
+ "require_signed_off": False,
54
+ "agent_identities": list(AGENT_IDENTITIES),
55
+ },
56
+ "disclosure": {
57
+ "enabled": False,
58
+ "severity": "warn",
59
+ # Any one of these, present and ticked, satisfies the rule.
60
+ "checkbox_patterns": [
61
+ r"generative ai",
62
+ r"\bai\b.*(tool|assist|generat)",
63
+ r"(tool|assist|generat).*\bai\b",
64
+ ],
65
+ },
66
+ "template": {
67
+ "enabled": True,
68
+ "severity": "warn",
69
+ "min_body_chars": 30,
70
+ "forbid_unedited_comments": True,
71
+ },
72
+ "linked_issue": {
73
+ "enabled": False,
74
+ "severity": "warn",
75
+ "keywords": ["closes", "fixes", "resolves", "refs", "part of"],
76
+ },
77
+ "size": {
78
+ "enabled": True,
79
+ "severity": "info",
80
+ "max_lines": 1000,
81
+ "max_files": 100,
82
+ },
83
+ }
84
+
85
+
86
+ class ConfigError(ValueError):
87
+ """Raised when a configuration file is present but cannot be used."""
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class RuleConfig:
92
+ name: str
93
+ enabled: bool
94
+ severity: str
95
+ options: dict[str, Any] = field(default_factory=dict)
96
+
97
+ def get(self, key: str, fallback: Any = None) -> Any:
98
+ return self.options.get(key, fallback)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Config:
103
+ enforce: bool = False
104
+ rules: dict[str, RuleConfig] = field(default_factory=dict)
105
+ source: str | None = None
106
+
107
+ def rule(self, name: str) -> RuleConfig:
108
+ return self.rules[name]
109
+
110
+
111
+ def _merge(name: str, defaults: dict[str, Any], overrides: Any) -> RuleConfig:
112
+ if overrides is None:
113
+ overrides = {}
114
+ if isinstance(overrides, bool): # `attribution: false` is a natural shorthand
115
+ overrides = {"enabled": overrides}
116
+ if not isinstance(overrides, dict):
117
+ raise ConfigError(f"rule '{name}' must be a mapping or a boolean")
118
+
119
+ unknown = set(overrides) - set(defaults)
120
+ if unknown:
121
+ raise ConfigError(f"rule '{name}' has unknown option(s): {', '.join(sorted(unknown))}")
122
+
123
+ merged = {**defaults, **overrides}
124
+ severity = merged["severity"]
125
+ if severity not in SEVERITIES:
126
+ raise ConfigError(
127
+ f"rule '{name}' has severity '{severity}', expected one of {', '.join(SEVERITIES)}"
128
+ )
129
+
130
+ options = {k: v for k, v in merged.items() if k not in ("enabled", "severity")}
131
+ return RuleConfig(
132
+ name=name, enabled=bool(merged["enabled"]), severity=severity, options=options
133
+ )
134
+
135
+
136
+ def default_config() -> Config:
137
+ return Config(
138
+ enforce=False,
139
+ rules={name: _merge(name, defaults, {}) for name, defaults in DEFAULTS.items()},
140
+ )
141
+
142
+
143
+ def find_config(root: Path) -> Path | None:
144
+ for candidate in CONFIG_PATHS:
145
+ path = root / candidate
146
+ if path.is_file():
147
+ return path
148
+ return None
149
+
150
+
151
+ def load_config(root: Path, explicit: Path | None = None) -> Config:
152
+ """Load configuration from ``explicit``, or from the first conventional path."""
153
+ path = explicit or find_config(root)
154
+ if path is None:
155
+ return default_config()
156
+ if not path.is_file():
157
+ raise ConfigError(f"no such configuration file: {path}")
158
+
159
+ try:
160
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
161
+ except yaml.YAMLError as exc:
162
+ raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
163
+
164
+ if not isinstance(raw, dict):
165
+ raise ConfigError(f"{path}: top level must be a mapping")
166
+
167
+ unknown_top = set(raw) - {"version", "enforce", "rules"}
168
+ if unknown_top:
169
+ raise ConfigError(f"{path}: unknown top-level key(s): {', '.join(sorted(unknown_top))}")
170
+
171
+ rules_raw = raw.get("rules") or {}
172
+ if not isinstance(rules_raw, dict):
173
+ raise ConfigError(f"{path}: 'rules' must be a mapping")
174
+
175
+ unknown_rules = set(rules_raw) - set(DEFAULTS)
176
+ if unknown_rules:
177
+ raise ConfigError(f"{path}: unknown rule(s): {', '.join(sorted(unknown_rules))}")
178
+
179
+ rules = {
180
+ name: _merge(name, defaults, rules_raw.get(name)) for name, defaults in DEFAULTS.items()
181
+ }
182
+ return Config(enforce=bool(raw.get("enforce", False)), rules=rules, source=str(path))
pr_policy/context.py ADDED
@@ -0,0 +1,182 @@
1
+ """The pull request under inspection.
2
+
3
+ Everything pr-policy evaluates comes from two places: the git history of the
4
+ branch, and — when running inside a GitHub Action — the event payload, which is
5
+ the only source for the pull request's title, body and labels.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ # A git trailer: "Key: value", where the key has no whitespace.
18
+ TRAILER = re.compile(r"^(?P<key>[A-Za-z][A-Za-z0-9-]*):[ \t]*(?P<value>.+?)\s*$")
19
+
20
+ UNIT = "\x1f"
21
+ RECORD = "\x1e"
22
+
23
+
24
+ class GitError(RuntimeError):
25
+ """Raised when git is unavailable or a revision cannot be resolved."""
26
+
27
+
28
+ def _git(root: Path, *args: str) -> str:
29
+ try:
30
+ proc = subprocess.run(
31
+ ["git", *args],
32
+ cwd=root,
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ except FileNotFoundError as exc: # pragma: no cover - depends on the host
38
+ raise GitError("git is not installed or not on PATH") from exc
39
+ if proc.returncode != 0:
40
+ raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
41
+ return proc.stdout
42
+
43
+
44
+ def parse_trailers(message: str) -> dict[str, list[str]]:
45
+ """Return the trailers from a commit message, keyed by lowercased name.
46
+
47
+ Git treats only the final paragraph as the trailer block, and so do we: a
48
+ line reading "Note: see below" halfway through a commit body is prose, not
49
+ a trailer.
50
+ """
51
+ paragraphs = [p for p in re.split(r"\n\s*\n", message.strip()) if p.strip()]
52
+ if not paragraphs:
53
+ return {}
54
+
55
+ trailers: dict[str, list[str]] = {}
56
+ for line in paragraphs[-1].splitlines():
57
+ match = TRAILER.match(line)
58
+ if match:
59
+ trailers.setdefault(match.group("key").lower(), []).append(match.group("value"))
60
+ return trailers
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Commit:
65
+ sha: str
66
+ author_name: str
67
+ author_email: str
68
+ message: str
69
+
70
+ @property
71
+ def subject(self) -> str:
72
+ return self.message.strip().splitlines()[0] if self.message.strip() else ""
73
+
74
+ @property
75
+ def short_sha(self) -> str:
76
+ return self.sha[:8]
77
+
78
+ @property
79
+ def trailers(self) -> dict[str, list[str]]:
80
+ return parse_trailers(self.message)
81
+
82
+ def trailer(self, name: str) -> list[str]:
83
+ return self.trailers.get(name.lower(), [])
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PullRequest:
88
+ """A pull request, as far as pr-policy can see it."""
89
+
90
+ title: str = ""
91
+ body: str = ""
92
+ author: str = ""
93
+ commits: list[Commit] = field(default_factory=list)
94
+ changed_files: list[str] = field(default_factory=list)
95
+ additions: int = 0
96
+ deletions: int = 0
97
+ labels: list[str] = field(default_factory=list)
98
+ # False when nothing supplied the title and body — running over a local
99
+ # branch rather than inside a pull request. Rules that read the body are
100
+ # skipped rather than guessing, because an absent body is not an empty one.
101
+ metadata_available: bool = False
102
+
103
+ @property
104
+ def total_lines(self) -> int:
105
+ return self.additions + self.deletions
106
+
107
+
108
+ def commits_between(root: Path, base: str, head: str) -> list[Commit]:
109
+ raw = _git(
110
+ root,
111
+ "log",
112
+ f"--format=%H{UNIT}%an{UNIT}%ae{UNIT}%B{RECORD}",
113
+ f"{base}..{head}",
114
+ )
115
+ commits = []
116
+ for record in raw.split(RECORD):
117
+ record = record.strip("\n")
118
+ if not record.strip():
119
+ continue
120
+ sha, name, email, message = record.split(UNIT, 3)
121
+ commits.append(Commit(sha=sha, author_name=name, author_email=email, message=message))
122
+ return commits
123
+
124
+
125
+ def diff_stats(root: Path, base: str, head: str) -> tuple[list[str], int, int]:
126
+ """Return changed files and line counts for ``base...head`` (merge base)."""
127
+ raw = _git(root, "diff", "--numstat", f"{base}...{head}")
128
+ files: list[str] = []
129
+ additions = deletions = 0
130
+ for line in raw.splitlines():
131
+ parts = line.split("\t")
132
+ if len(parts) != 3:
133
+ continue
134
+ added, removed, path = parts
135
+ files.append(path)
136
+ # Binary files report "-" rather than a count.
137
+ additions += int(added) if added.isdigit() else 0
138
+ deletions += int(removed) if removed.isdigit() else 0
139
+ return files, additions, deletions
140
+
141
+
142
+ def load_event(path: str | None = None) -> dict:
143
+ """Read the GitHub Actions event payload, if this is running in one."""
144
+ event_path = path or os.environ.get("GITHUB_EVENT_PATH")
145
+ if not event_path or not Path(event_path).is_file():
146
+ return {}
147
+ try:
148
+ return json.loads(Path(event_path).read_text(encoding="utf-8")) or {}
149
+ except (OSError, json.JSONDecodeError):
150
+ return {}
151
+
152
+
153
+ def from_git(
154
+ root: Path,
155
+ base: str,
156
+ head: str = "HEAD",
157
+ event: dict | None = None,
158
+ title: str | None = None,
159
+ body: str | None = None,
160
+ ) -> PullRequest:
161
+ """Build a :class:`PullRequest` from the repository and, optionally, an event.
162
+
163
+ ``title`` and ``body`` override the event payload, which is what makes the
164
+ body rules runnable outside a pull request.
165
+ """
166
+ commits = commits_between(root, base, head)
167
+ files, additions, deletions = diff_stats(root, base, head)
168
+
169
+ payload = (event or {}).get("pull_request") or {}
170
+ has_metadata = bool(payload) or title is not None or body is not None
171
+
172
+ return PullRequest(
173
+ title=title if title is not None else (payload.get("title") or ""),
174
+ body=body if body is not None else (payload.get("body") or ""),
175
+ author=(payload.get("user") or {}).get("login") or "",
176
+ commits=commits,
177
+ changed_files=files,
178
+ additions=payload.get("additions", additions),
179
+ deletions=payload.get("deletions", deletions),
180
+ labels=[label.get("name", "") for label in payload.get("labels") or []],
181
+ metadata_available=has_metadata,
182
+ )