mergeproof 0.2.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.
Files changed (41) hide show
  1. mergeproof/__init__.py +32 -0
  2. mergeproof/__main__.py +4 -0
  3. mergeproof/checks/__init__.py +4 -0
  4. mergeproof/checks/agent_verdict.py +88 -0
  5. mergeproof/checks/base.py +71 -0
  6. mergeproof/checks/body.py +46 -0
  7. mergeproof/checks/ci_job.py +74 -0
  8. mergeproof/checks/evidence_field.py +81 -0
  9. mergeproof/checks/evidence_links.py +128 -0
  10. mergeproof/checks/files.py +47 -0
  11. mergeproof/checks/human_verified.py +65 -0
  12. mergeproof/checks/labels.py +44 -0
  13. mergeproof/checks/registry.py +66 -0
  14. mergeproof/checks/shell.py +61 -0
  15. mergeproof/checks/tests_changed.py +72 -0
  16. mergeproof/cli.py +384 -0
  17. mergeproof/context.py +79 -0
  18. mergeproof/engine.py +86 -0
  19. mergeproof/evidence.py +90 -0
  20. mergeproof/mcp_server.py +105 -0
  21. mergeproof/patterns.py +67 -0
  22. mergeproof/policy.py +135 -0
  23. mergeproof/providers/__init__.py +0 -0
  24. mergeproof/providers/git.py +98 -0
  25. mergeproof/providers/github.py +238 -0
  26. mergeproof/render/__init__.py +20 -0
  27. mergeproof/render/agent.py +71 -0
  28. mergeproof/render/junit.py +52 -0
  29. mergeproof/render/markdown.py +174 -0
  30. mergeproof/render/rdjson.py +33 -0
  31. mergeproof/render/text.py +38 -0
  32. mergeproof/report.py +131 -0
  33. mergeproof/telemetry.py +48 -0
  34. mergeproof/verifiers/__init__.py +3 -0
  35. mergeproof/verifiers/base.py +39 -0
  36. mergeproof/verifiers/http.py +32 -0
  37. mergeproof-0.2.0.dist-info/METADATA +536 -0
  38. mergeproof-0.2.0.dist-info/RECORD +41 -0
  39. mergeproof-0.2.0.dist-info/WHEEL +4 -0
  40. mergeproof-0.2.0.dist-info/entry_points.txt +17 -0
  41. mergeproof-0.2.0.dist-info/licenses/LICENSE +21 -0
mergeproof/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Evidence gates for pull requests.
2
+
3
+ A policy file declares what a change must prove before it merges. The same
4
+ policy is enforced in CI, explained to contributors and coding agents, and
5
+ served over MCP.
6
+ """
7
+
8
+ from mergeproof.checks.base import Check
9
+ from mergeproof.context import ChangedFile, CheckRun, Comment, Context
10
+ from mergeproof.policy import Policy, PolicyError, Requirement, Rule, Severity
11
+ from mergeproof.report import Outcome, Report, Status
12
+ from mergeproof.verifiers.base import Verifier
13
+
14
+ __version__ = "0.2.0" # x-release-please-version
15
+
16
+ __all__ = [
17
+ "ChangedFile",
18
+ "Check",
19
+ "CheckRun",
20
+ "Comment",
21
+ "Context",
22
+ "Outcome",
23
+ "Policy",
24
+ "PolicyError",
25
+ "Report",
26
+ "Requirement",
27
+ "Rule",
28
+ "Severity",
29
+ "Status",
30
+ "Verifier",
31
+ "__version__",
32
+ ]
mergeproof/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mergeproof.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,4 @@
1
+ from mergeproof.checks.base import Check
2
+ from mergeproof.checks.registry import Registry, load_registry
3
+
4
+ __all__ = ["Check", "Registry", "load_registry"]
@@ -0,0 +1,88 @@
1
+ """A structured verdict posted by an automated reviewer.
2
+
3
+ LLM reviewers are welcome as witnesses, not as the gate. They post a fenced
4
+ ``verdict`` block; this check reads it, restricts who may post it, binds it
5
+ to the head commit, and turns it into an ordinary requirement next to the
6
+ deterministic ones::
7
+
8
+ ```verdict
9
+ check: trace-review
10
+ verdict: pass
11
+ head: b3ca7be
12
+ confidence: 0.9
13
+ summary: the after-run shows the corrected output; the before-run does not.
14
+ ```
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+ from mergeproof import evidence
24
+ from mergeproof.checks.base import Check, fail, ok, pending
25
+ from mergeproof.context import Context
26
+ from mergeproof.report import Outcome
27
+
28
+
29
+ class AgentVerdict(Check):
30
+ id = "agent.verdict"
31
+ description = "An allowed automated reviewer posted a verdict block for the named check."
32
+ needs_github = True
33
+
34
+ class Params(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ name: str = Field(description="Value of `check:` inside the verdict block")
38
+ authors: list[str] = Field(
39
+ default_factory=list, description="Logins allowed to post it, e.g. github-actions[bot]"
40
+ )
41
+ bind_to_head: bool = True
42
+ min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
43
+ block: str = "verdict"
44
+
45
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
46
+ if not ctx.online:
47
+ return pending(f"the `{params.name}` verdict is only visible in GitHub mode")
48
+ latest: dict[str, Any] | None = None
49
+ latest_at = ""
50
+ rejected: list[str] = []
51
+ for comment in ctx.comments:
52
+ block = evidence.parse(comment.body, params.block)
53
+ if not block.found or block.get("check") != params.name:
54
+ continue
55
+ if params.authors and comment.author not in params.authors:
56
+ rejected.append(f"{comment.author}: not an allowed verdict author")
57
+ continue
58
+ head = str(block.get("head", ""))[:7]
59
+ if params.bind_to_head and ctx.head_short and head != ctx.head_short:
60
+ rejected.append(
61
+ f"{comment.author}: verdict is for {head or 'an unknown commit'}, head is {ctx.head_short}"
62
+ )
63
+ continue
64
+ if comment.created_at >= latest_at:
65
+ latest, latest_at = block.data, comment.created_at
66
+ if latest is None:
67
+ return pending(f"no `{params.name}` verdict for {ctx.head_short or 'this commit'}", details=rejected)
68
+ verdict = str(latest.get("verdict", "")).lower()
69
+ confidence = float(latest.get("confidence") or 1.0)
70
+ summary = str(latest.get("summary", "")).strip()
71
+ if verdict != "pass":
72
+ return fail(
73
+ f"verdict {verdict or 'missing'}: {summary}", data=latest, fix="Address the findings and push again."
74
+ )
75
+ if confidence < params.min_confidence:
76
+ return pending(
77
+ f"passed with confidence {confidence:.2f}, below {params.min_confidence:.2f}: {summary}", data=latest
78
+ )
79
+ return ok(f"verdict pass ({confidence:.2f}): {summary}", data=latest)
80
+
81
+ def explain(self, params: Params) -> str:
82
+ who = ", ".join(f"`{a}`" for a in params.authors) or "an automated reviewer"
83
+ text = f"{who} posts a ```{params.block} block with `check: {params.name}` and `verdict: pass`"
84
+ if params.bind_to_head:
85
+ text += " for the current head sha"
86
+ if params.min_confidence:
87
+ text += f" with confidence of at least {params.min_confidence}"
88
+ return text + "."
@@ -0,0 +1,71 @@
1
+ """Base class for checks.
2
+
3
+ A check answers one question about a pull request. It declares its
4
+ parameters as a pydantic model, runs against a :class:`Context`, and returns
5
+ an :class:`Outcome`. Third-party checks register under the
6
+ ``mergeproof.checks`` entry-point group.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, ClassVar
13
+
14
+ from pydantic import BaseModel, ConfigDict
15
+
16
+ from mergeproof.context import Context
17
+ from mergeproof.report import Outcome, Status
18
+
19
+
20
+ class Check(ABC):
21
+ id: ClassVar[str]
22
+ description: ClassVar[str] = ""
23
+ needs_github: ClassVar[bool] = False
24
+
25
+ class Params(BaseModel):
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ def parse_params(self, raw: dict[str, Any]) -> BaseModel:
29
+ return self.Params(**raw)
30
+
31
+ @abstractmethod
32
+ def run(self, ctx: Context, params: Any, files: list[str]) -> Outcome:
33
+ """*files* are the changed paths that made the enclosing rule apply."""
34
+
35
+ def explain(self, params: Any) -> str:
36
+ """One sentence a contributor or agent can act on."""
37
+ return self.description
38
+
39
+ def evidence_template(self, params: Any) -> dict[str, Any]:
40
+ """Keys this check expects in the evidence block, with placeholder values."""
41
+ return {}
42
+
43
+
44
+ def ok(summary: str, **kw: Any) -> Outcome:
45
+ return Outcome(status=Status.PASS, summary=summary, **kw)
46
+
47
+
48
+ def fail(summary: str, fix: str | None = None, **kw: Any) -> Outcome:
49
+ return Outcome(status=Status.FAIL, summary=summary, fix=fix, **kw)
50
+
51
+
52
+ def warn(summary: str, fix: str | None = None, **kw: Any) -> Outcome:
53
+ return Outcome(status=Status.WARN, summary=summary, fix=fix, **kw)
54
+
55
+
56
+ def pending(summary: str, fix: str | None = None, **kw: Any) -> Outcome:
57
+ return Outcome(status=Status.PENDING, summary=summary, fix=fix, **kw)
58
+
59
+
60
+ def skip(summary: str, **kw: Any) -> Outcome:
61
+ return Outcome(status=Status.SKIP, summary=summary, **kw)
62
+
63
+
64
+ def error(summary: str, **kw: Any) -> Outcome:
65
+ return Outcome(status=Status.ERROR, summary=summary, **kw)
66
+
67
+
68
+ def plural(count: int, noun: str, plural_form: str | None = None) -> str:
69
+ """``plural(1, "run")`` is ``"1 run"``; ``plural(3, "run")`` is ``"3 runs"``."""
70
+ word = noun if count == 1 else (plural_form or noun + "s")
71
+ return f"{count} {word}"
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from mergeproof.checks.base import Check, fail, ok
8
+ from mergeproof.context import Context
9
+ from mergeproof.report import Outcome
10
+
11
+
12
+ class Body(Check):
13
+ id = "pr.body"
14
+ description = "The PR description has the required sections, matches a regex, or meets a minimum length."
15
+
16
+ class Params(BaseModel):
17
+ model_config = ConfigDict(extra="forbid")
18
+
19
+ sections: list[str] = Field(default_factory=list, description="Markdown headings that must exist")
20
+ matches: str | None = None
21
+ min_length: int = 0
22
+
23
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
24
+ body = ctx.body or ""
25
+ problems = [
26
+ f"missing section `## {section}`"
27
+ for section in params.sections
28
+ if not re.search(rf"^#{{1,6}}\s*{re.escape(section)}\b", body, re.I | re.M)
29
+ ]
30
+ if params.matches and not re.search(params.matches, body, re.S):
31
+ problems.append(f"description does not match `{params.matches}`")
32
+ if len(body.strip()) < params.min_length:
33
+ problems.append(f"description shorter than {params.min_length} characters")
34
+ if problems:
35
+ return fail("; ".join(problems), fix=self.explain(params))
36
+ return ok("description ok")
37
+
38
+ def explain(self, params: Params) -> str:
39
+ parts = []
40
+ if params.sections:
41
+ parts.append("sections " + ", ".join(f"`## {s}`" for s in params.sections))
42
+ if params.matches:
43
+ parts.append(f"text matching `{params.matches}`")
44
+ if params.min_length:
45
+ parts.append(f"at least {params.min_length} characters")
46
+ return "A PR description with " + "; ".join(parts) + "."
@@ -0,0 +1,74 @@
1
+ """A named check run on the head commit succeeded.
2
+
3
+ A commit can carry several check runs with the same name: re-runs, and runs a
4
+ concurrency group cancelled when a newer push arrived. Only the newest run per
5
+ name says anything about the commit, so that is the one that counts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from mergeproof.checks.base import Check, fail, ok, pending, plural
15
+ from mergeproof.context import CheckRun, Context
16
+ from mergeproof.report import Outcome
17
+
18
+
19
+ def newest_per_name(runs: list[CheckRun]) -> list[CheckRun]:
20
+ latest: dict[str, CheckRun] = {}
21
+ for run in runs:
22
+ current = latest.get(run.name)
23
+ if current is None or run.started_at > current.started_at:
24
+ latest[run.name] = run
25
+ return list(latest.values())
26
+
27
+
28
+ class CiJobPassed(Check):
29
+ id = "ci.job_passed"
30
+ description = "A GitHub check run on the head commit completed successfully."
31
+ needs_github = True
32
+
33
+ class Params(BaseModel):
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ name: str = Field(description="Check run name, or a regex when `regex` is true")
37
+ regex: bool = False
38
+ min_matches: int = Field(default=1, description="How many matching runs must succeed")
39
+ missing: str = Field(default="pending", pattern="^(pending|fail)$", description="Status when no run exists yet")
40
+
41
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
42
+ if not ctx.online:
43
+ return pending(f"CI status for `{params.name}` is only visible in GitHub mode")
44
+ if params.regex:
45
+ wanted = re.compile(params.name)
46
+ runs = [r for r in ctx.check_runs if wanted.search(r.name)]
47
+ else:
48
+ runs = [r for r in ctx.check_runs if r.name == params.name]
49
+ runs = newest_per_name(runs)
50
+ if not runs:
51
+ message = (
52
+ f"no check run matching `{params.name}` yet"
53
+ if params.regex
54
+ else f"no check run named `{params.name}` yet"
55
+ )
56
+ return fail(message) if params.missing == "fail" else pending(message)
57
+ failed = [r for r in runs if r.status == "completed" and r.conclusion not in ("success", "skipped", "neutral")]
58
+ running = [r for r in runs if r.status != "completed"]
59
+ succeeded = [r for r in runs if r.status == "completed" and r.conclusion == "success"]
60
+ if failed:
61
+ return fail(
62
+ f"{plural(len(failed), 'run')} failed",
63
+ details=[f"{r.name}: {r.conclusion}" + (f" {r.url}" if r.url else "") for r in failed],
64
+ fix="Make the job green; the gate re-evaluates on the next run.",
65
+ )
66
+ if running:
67
+ return pending(f"{plural(len(running), 'run')} still in progress", details=[r.name for r in running])
68
+ if len(succeeded) < params.min_matches:
69
+ return fail(f"{plural(len(succeeded), 'successful run')}, need {params.min_matches}")
70
+ return ok(f"{plural(len(succeeded), 'run')} succeeded", details=[r.name for r in succeeded][:10])
71
+
72
+ def explain(self, params: Params) -> str:
73
+ how = "matching" if params.regex else "named"
74
+ return f"CI check run {how} `{params.name}` is green on the head commit."
@@ -0,0 +1,81 @@
1
+ """A key in the evidence block is present and has an acceptable value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from mergeproof.checks.base import Check, fail, ok
11
+ from mergeproof.context import Context
12
+ from mergeproof.report import Outcome
13
+
14
+
15
+ class EvidenceField(Check):
16
+ id = "evidence.field"
17
+ description = "A field in the PR's evidence block is present and valid."
18
+
19
+ class Params(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+ key: str = Field(description="Dotted path inside the evidence block, e.g. `environment` or `image.tag`")
23
+ equals: Any | None = None
24
+ one_of: list[Any] | None = None
25
+ matches: str | None = Field(default=None, description="Regex a string value must match")
26
+ min_items: int | None = Field(default=None, description="Minimum length for a list value")
27
+ example: Any | None = Field(default=None, description="Placeholder shown in the evidence template")
28
+ block: str = "evidence"
29
+
30
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
31
+ ev = ctx.evidence(params.block)
32
+ if ev.errors:
33
+ return fail("evidence block could not be parsed", details=ev.errors, fix=self.fix(params))
34
+ if not ev.has(params.key):
35
+ why = f"no `{params.block}` block in the PR description" if not ev.found else f"`{params.key}` is missing"
36
+ return fail(why, fix=self.fix(params))
37
+ value = ev.get(params.key)
38
+ if params.equals is not None and value != params.equals:
39
+ return fail(f"`{params.key}` is {value!r}, expected {params.equals!r}", fix=self.fix(params))
40
+ if params.one_of is not None and value not in params.one_of:
41
+ return fail(f"`{params.key}` is {value!r}, expected one of {params.one_of}", fix=self.fix(params))
42
+ if params.matches is not None and (not isinstance(value, str) or not re.search(params.matches, value)):
43
+ return fail(f"`{params.key}` does not match `{params.matches}`", fix=self.fix(params))
44
+ if params.min_items is not None and (not isinstance(value, list) or len(value) < params.min_items):
45
+ return fail(f"`{params.key}` needs at least {params.min_items} item(s)", fix=self.fix(params))
46
+ shown = value if isinstance(value, str | int | float | bool) else f"{type(value).__name__}[{len(value)}]"
47
+ return ok(f"`{params.key}` = {shown}")
48
+
49
+ def fix(self, params: Params) -> str:
50
+ return f"Add `{params.key}` to the `{params.block}` block in the PR description. {self.explain(params)}"
51
+
52
+ def explain(self, params: Params) -> str:
53
+ wants = [f"`{params.key}` present"]
54
+ if params.equals is not None:
55
+ wants.append(f"equal to `{params.equals}`")
56
+ if params.one_of:
57
+ wants.append("one of " + ", ".join(f"`{v}`" for v in params.one_of))
58
+ if params.matches:
59
+ wants.append(f"matching `{params.matches}`")
60
+ if params.min_items:
61
+ wants.append(f"with at least {params.min_items} item(s)")
62
+ return " and ".join(wants) + "."
63
+
64
+ def evidence_template(self, params: Params) -> dict[str, Any]:
65
+ if params.example is not None:
66
+ leaf: Any = params.example
67
+ elif params.equals is not None:
68
+ leaf = params.equals
69
+ elif params.one_of:
70
+ leaf = params.one_of[0]
71
+ elif params.min_items:
72
+ leaf = ["<item>"]
73
+ else:
74
+ leaf = "<value>"
75
+ template: dict[str, Any] = {}
76
+ node = template
77
+ *parents, last = params.key.split(".")
78
+ for part in parents:
79
+ node = node.setdefault(part, {})
80
+ node[last] = leaf
81
+ return template
@@ -0,0 +1,128 @@
1
+ """Before/after link pairs proving a behaviour change on a live system.
2
+
3
+ The links can point anywhere: a tracing backend, a dashboard, a CI run, a
4
+ screenshot bucket. ``pattern`` decides what counts as a valid link and may
5
+ capture named groups; ``verify`` names a verifier that resolves each link.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from mergeproof.checks.base import Check, error, fail, ok, plural
16
+ from mergeproof.context import Context
17
+ from mergeproof.report import Outcome
18
+ from mergeproof.verifiers import UnknownVerifier, Verifier, load_verifier
19
+
20
+ ANY_URL = r"^https?://\S+$"
21
+
22
+
23
+ class EvidenceLinks(Check):
24
+ id = "evidence.links"
25
+ description = "The evidence block lists before/after link pairs, optionally verified against their source."
26
+
27
+ class Params(BaseModel):
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ key: str = "links"
31
+ min_pairs: int = 1
32
+ pattern: str = Field(
33
+ default=ANY_URL, description="Regex a link must match; named groups are passed to the verifier"
34
+ )
35
+ require_before: bool = True
36
+ distinct: bool = Field(default=True, description="`before` and `after` must differ")
37
+ verify: str | None = Field(default=None, description="Verifier name: `http` or one provided by a plugin")
38
+ verify_options: dict[str, Any] = Field(default_factory=dict)
39
+ example: str = Field(default="https://<host>/<path-to-run>", description="Placeholder in the evidence template")
40
+ block: str = "evidence"
41
+
42
+ verifier: Verifier | None = None
43
+
44
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
45
+ ev = ctx.evidence(params.block)
46
+ if ev.errors:
47
+ return fail("evidence block could not be parsed", details=ev.errors, fix=self.fix(params))
48
+ items = ev.get(params.key)
49
+ if not isinstance(items, list) or not items:
50
+ return fail(f"no `{params.key}` list in the evidence block", fix=self.fix(params))
51
+
52
+ regex = re.compile(params.pattern)
53
+ problems: list[str] = []
54
+ pairs: list[dict[str, str]] = []
55
+ for index, item in enumerate(items, 1):
56
+ if not isinstance(item, dict):
57
+ problems.append(f"item {index}: expected a mapping with `before` and `after`")
58
+ continue
59
+ pair: dict[str, str] = {}
60
+ for side in ("before", "after"):
61
+ url = item.get(side)
62
+ if not url:
63
+ if side == "after" or params.require_before:
64
+ problems.append(f"item {index}: missing `{side}`")
65
+ continue
66
+ if not regex.match(str(url)):
67
+ problems.append(f"item {index}: `{side}` does not look like an accepted link")
68
+ continue
69
+ pair[side] = str(url)
70
+ if params.distinct and pair.get("before") and pair.get("before") == pair.get("after"):
71
+ problems.append(f"item {index}: `before` and `after` are the same link")
72
+ if "after" in pair and ("before" in pair or not params.require_before):
73
+ pairs.append(pair)
74
+ if problems:
75
+ return fail(f"{plural(len(problems), 'problem')} in `{params.key}`", details=problems, fix=self.fix(params))
76
+ if len(pairs) < params.min_pairs:
77
+ return fail(f"{plural(len(pairs), 'valid pair')}, need {params.min_pairs}", fix=self.fix(params))
78
+
79
+ if params.verify:
80
+ unresolved = self.verify_pairs(pairs, regex, params)
81
+ if unresolved is None:
82
+ return error(f"verifier {params.verify!r} is not available or failed")
83
+ if unresolved:
84
+ return fail("link(s) could not be verified", details=unresolved, fix=self.fix(params))
85
+ return ok(f"{plural(len(pairs), 'before/after pair')}, all verified", data={"pairs": pairs})
86
+ return ok(plural(len(pairs), "before/after pair"), data={"pairs": pairs})
87
+
88
+ def verify_pairs(self, pairs: list[dict[str, str]], regex: re.Pattern[str], params: Params) -> list[str] | None:
89
+ """Return the links that did not resolve, or None when the verifier itself is unusable."""
90
+ verifier = self.verifier
91
+ if verifier is None:
92
+ try:
93
+ verifier = load_verifier(params.verify or "", params.verify_options)
94
+ except (UnknownVerifier, TypeError, ValueError):
95
+ return None
96
+ unresolved: list[str] = []
97
+ for pair in pairs:
98
+ for side, url in pair.items():
99
+ match = regex.match(url)
100
+ assert match is not None
101
+ try:
102
+ found = verifier.verify(url, match)
103
+ except Exception as exc:
104
+ unresolved.append(f"{side}: {type(exc).__name__} while verifying {url}")
105
+ continue
106
+ if not found:
107
+ unresolved.append(f"{side}: not found at {url}")
108
+ return unresolved
109
+
110
+ def fix(self, params: Params) -> str:
111
+ return (
112
+ "Capture a link showing the behaviour before the change and one after it; "
113
+ f"list them as `before`/`after` pairs under `{params.key}` in the evidence block."
114
+ )
115
+
116
+ def explain(self, params: Params) -> str:
117
+ text = f"At least {params.min_pairs} `before`/`after` link pair(s) under `{params.key}` in the evidence block"
118
+ if params.pattern != ANY_URL:
119
+ text += f", each matching `{params.pattern}`"
120
+ if params.verify:
121
+ text += f"; links are verified with `{params.verify}`"
122
+ return text + "."
123
+
124
+ def evidence_template(self, params: Params) -> dict[str, Any]:
125
+ pair = {"before": params.example, "after": params.example}
126
+ if not params.require_before:
127
+ pair = {"after": params.example}
128
+ return {params.key: [{"what": "<what was exercised>", **pair}]}
@@ -0,0 +1,47 @@
1
+ """Which files a change must, may, or must not touch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from mergeproof import patterns
8
+ from mergeproof.checks.base import Check, fail, ok
9
+ from mergeproof.context import Context
10
+ from mergeproof.report import Outcome
11
+
12
+
13
+ class FilesChanged(Check):
14
+ id = "files.changed"
15
+ description = "The set of changed files satisfies any_of / all_of / none_of globs."
16
+
17
+ class Params(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ any_of: list[str] = Field(default_factory=list, description="At least one changed file matches")
21
+ all_of: list[str] = Field(default_factory=list, description="Every glob matches at least one changed file")
22
+ none_of: list[str] = Field(default_factory=list, description="No changed file matches")
23
+
24
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
25
+ paths = [f.path for f in ctx.files]
26
+ problems: list[str] = []
27
+ if params.any_of and not any(patterns.matches_any(params.any_of, p) for p in paths):
28
+ problems.append("expected a change to " + " or ".join(params.any_of))
29
+ for glob in params.all_of:
30
+ if not any(patterns.match(glob, p) for p in paths):
31
+ problems.append(f"expected a change to {glob}")
32
+ forbidden = [p for p in paths if patterns.matches_any(params.none_of, p)]
33
+ if forbidden:
34
+ problems.append("must not change " + ", ".join(forbidden[:5]))
35
+ if problems:
36
+ return fail("; ".join(problems), fix=self.explain(params))
37
+ return ok("changed files satisfy the rule")
38
+
39
+ def explain(self, params: Params) -> str:
40
+ parts = []
41
+ if params.any_of:
42
+ parts.append("change at least one of " + ", ".join(f"`{g}`" for g in params.any_of))
43
+ if params.all_of:
44
+ parts.append("change " + " and ".join(f"`{g}`" for g in params.all_of))
45
+ if params.none_of:
46
+ parts.append("leave " + ", ".join(f"`{g}`" for g in params.none_of) + " untouched")
47
+ return "; ".join(parts).capitalize() + "."
@@ -0,0 +1,65 @@
1
+ """A human other than the author attests to having checked the evidence.
2
+
3
+ Agents can write tests and paste links; the one thing they must not do is
4
+ approve their own evidence. By default the attestation is bound to the head
5
+ commit so that a later push invalidates it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from mergeproof.checks.base import Check, ok, pending
13
+ from mergeproof.context import Context
14
+ from mergeproof.report import Outcome
15
+
16
+
17
+ class HumanVerified(Check):
18
+ id = "review.human_verified"
19
+ description = "A reviewer other than the author posted the verification phrase after checking the evidence."
20
+ needs_github = True
21
+
22
+ class Params(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ phrase: str = "/verified"
26
+ allowed_users: list[str] = Field(default_factory=list, description="Empty means any non-author human")
27
+ exclude_author: bool = True
28
+ bind_to_head: bool = Field(default=True, description="The comment must contain the 7-character head sha")
29
+ require_review_state: str | None = Field(default=None, description="For example APPROVED")
30
+
31
+ def run(self, ctx: Context, params: Params, files: list[str]) -> Outcome:
32
+ if not ctx.online:
33
+ return pending("reviewer verification is only visible in GitHub mode")
34
+ expected = f"{params.phrase} {ctx.head_short}".strip() if params.bind_to_head else params.phrase
35
+ rejected: list[str] = []
36
+ for comment in ctx.comments:
37
+ if params.phrase not in comment.body:
38
+ continue
39
+ if comment.is_bot:
40
+ rejected.append(f"{comment.author}: bots cannot verify")
41
+ elif params.exclude_author and comment.author == ctx.author:
42
+ rejected.append(f"{comment.author}: the author cannot self-verify")
43
+ elif params.allowed_users and comment.author not in params.allowed_users:
44
+ rejected.append(f"{comment.author}: not an allowed verifier")
45
+ elif params.bind_to_head and ctx.head_short and ctx.head_short not in comment.body:
46
+ rejected.append(f"{comment.author}: verified an older commit, needs `{expected}`")
47
+ elif params.require_review_state and comment.state != params.require_review_state:
48
+ rejected.append(f"{comment.author}: must be a {params.require_review_state} review")
49
+ else:
50
+ return ok(
51
+ f"verified by @{comment.author}",
52
+ details=[comment.url] if comment.url else [],
53
+ data={"by": comment.author, "at": comment.created_at},
54
+ )
55
+ return pending(f"waiting for a reviewer to post `{expected}`", details=rejected, fix=self.explain(params))
56
+
57
+ def explain(self, params: Params) -> str:
58
+ who = (
59
+ ("one of " + ", ".join(f"@{u}" for u in params.allowed_users))
60
+ if params.allowed_users
61
+ else "a reviewer other than the author"
62
+ )
63
+ tail = " followed by the 7-character head sha" if params.bind_to_head else ""
64
+ state = f" as an {params.require_review_state} review" if params.require_review_state else ""
65
+ return f"After checking the evidence, {who} comments `{params.phrase}`{tail}{state}."