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 +5 -0
- pr_policy/__main__.py +4 -0
- pr_policy/cli.py +225 -0
- pr_policy/config.py +182 -0
- pr_policy/context.py +182 -0
- pr_policy/infer.py +224 -0
- pr_policy/report.py +83 -0
- pr_policy/rules.py +256 -0
- pr_policy-0.1.0.dist-info/METADATA +259 -0
- pr_policy-0.1.0.dist-info/RECORD +17 -0
- pr_policy-0.1.0.dist-info/WHEEL +4 -0
- pr_policy-0.1.0.dist-info/entry_points.txt +3 -0
- pr_policy-0.1.0.dist-info/licenses/LICENSE +21 -0
- repo_ready/__init__.py +7 -0
- repo_ready/__main__.py +4 -0
- repo_ready/checks.py +412 -0
- repo_ready/cli.py +136 -0
pr_policy/__init__.py
ADDED
pr_policy/__main__.py
ADDED
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
|
+
)
|