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.
- mergeproof/__init__.py +32 -0
- mergeproof/__main__.py +4 -0
- mergeproof/checks/__init__.py +4 -0
- mergeproof/checks/agent_verdict.py +88 -0
- mergeproof/checks/base.py +71 -0
- mergeproof/checks/body.py +46 -0
- mergeproof/checks/ci_job.py +74 -0
- mergeproof/checks/evidence_field.py +81 -0
- mergeproof/checks/evidence_links.py +128 -0
- mergeproof/checks/files.py +47 -0
- mergeproof/checks/human_verified.py +65 -0
- mergeproof/checks/labels.py +44 -0
- mergeproof/checks/registry.py +66 -0
- mergeproof/checks/shell.py +61 -0
- mergeproof/checks/tests_changed.py +72 -0
- mergeproof/cli.py +384 -0
- mergeproof/context.py +79 -0
- mergeproof/engine.py +86 -0
- mergeproof/evidence.py +90 -0
- mergeproof/mcp_server.py +105 -0
- mergeproof/patterns.py +67 -0
- mergeproof/policy.py +135 -0
- mergeproof/providers/__init__.py +0 -0
- mergeproof/providers/git.py +98 -0
- mergeproof/providers/github.py +238 -0
- mergeproof/render/__init__.py +20 -0
- mergeproof/render/agent.py +71 -0
- mergeproof/render/junit.py +52 -0
- mergeproof/render/markdown.py +174 -0
- mergeproof/render/rdjson.py +33 -0
- mergeproof/render/text.py +38 -0
- mergeproof/report.py +131 -0
- mergeproof/telemetry.py +48 -0
- mergeproof/verifiers/__init__.py +3 -0
- mergeproof/verifiers/base.py +39 -0
- mergeproof/verifiers/http.py +32 -0
- mergeproof-0.2.0.dist-info/METADATA +536 -0
- mergeproof-0.2.0.dist-info/RECORD +41 -0
- mergeproof-0.2.0.dist-info/WHEEL +4 -0
- mergeproof-0.2.0.dist-info/entry_points.txt +17 -0
- 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,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}."
|