Files
EOS/scripts/check_pr_self_review.py
T
Bobby NoelteandClaude Opus 5.5 dd41e5d57c chore(ai): add agent self-review and a CI check for agent-assisted PRs
Additional changes
Agents write a task plan to .scratch/<branch>/plan.md and self-review with code-review
Agents fix hard violations, report judgement calls and rerun the self-review before every push
Agents declare the review in a Self-review section of the pull request body
Add the ai-self-review workflow and scripts/check_pr_self_review.py for pull requests into main
Detect agents by Assisted-by or Anthropic Co-Authored-By trailers, body markers or the AI box
Fail only on a missing Self-review section or a missing, malformed or stale Reviewed SHA
Note an unticked AI-assisted box and malformed optional lines without failing
Comment on the pull request how to fix a failure and update that one comment on later runs
Show the same report in the job summary and accept Windows line endings in the body
Skip dependabot and release pull requests
Run on pull_request_target with the base branch's script, never pull request code
Add the pull request template: the pr skill's sections plus Checklist and Self-review
Require always valid optimization parameters in the energy-domain skill
Require datetimeutil for date and time handling in new and changed code in AGENTS.md
Require the Assisted-by trailer on agent commits in the commit-messages skill
Add tests for the check in tests/test_ai.py
Add GLOSSARY.md and the agent tracker docs in docs/agents/
Add decision record docs/adr/0001 with an ADR index in the docs toctree
Document the workflow in docs/develop/ai.md and CONTRIBUTING.md
Exclude docs/agents from Sphinx and PyMarkdown and ignore .scratch/
Document cz check instead of make gitlint for commit messages in docs/develop/ai.md

Assisted-by: Claude Code (claude-opus-5-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:37:33 +01:00

432 lines
16 KiB
Python

#!/usr/bin/env python3
"""Check the self-review declaration of agent-assisted pull requests.
A pull request is agent-assisted when any of its commits carries an
``Assisted-by:`` trailer (or Claude Code's ``Co-Authored-By: ... <noreply@anthropic.com>``),
when its body carries an agent marker (an ``Assisted-by:`` line or Claude Code's
"Generated with [Claude Code]" footer), or when the "AI-assisted" box of the pull request
template is ticked. A trailer or body marker without the ticked box is only noted.
An agent-assisted pull request must carry a ``## Self-review`` section in its body with a
``Reviewed:`` line naming the full 40-character lowercase SHA of the current head commit. A
missing section, a missing or malformed SHA, or a SHA other than the head are the only errors.
The other lines of ``SELF_REVIEW_FORMAT`` are expected from agents but optional for the check;
when present but malformed they are only noted:
- ``Base:`` a non-empty ref,
- ``Standards: <n> hard, <m> judgement`` with numbers,
- ``Spec: <k> open (source: ...)`` with a number and a non-empty source.
Open findings do not fail the check. Pull requests by dependabot and titles starting with
``Release`` are skipped.
The check does not verify the review itself; see
``docs/adr/0001-self-declared-sha-bound-review-gate.md`` and ``docs/develop/ai.md``.
Only the Python standard library is used, so CI needs no project environment.
Usage::
python scripts/check_pr_self_review.py --body-file body.md --commits-file commits.jsonl \\
--head-sha <sha> --title "<title>" --author <login>
``commits.jsonl`` holds one JSON-encoded commit message per line, e.g. from
``gh api --paginate repos/<owner>/<repo>/pulls/<n>/commits --jq '.[].commit.message | @json'``.
With ``--report-file`` the check also writes a Markdown report for the pull request comment and
the job summary: how to fix a failing check, or a short note that the check passes. The report
starts with ``COMMENT_MARKER``, by which the workflow finds and updates its one comment.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
SELF_REVIEW_FORMAT = """\
## Self-review
Reviewed: <full 40-character SHA of the reviewed HEAD commit>
Base: <base branch or commit>
Standards: <n> hard, <m> judgement
Spec: <k> open (source: <#issue | .scratch/<branch>/plan.md | none>)
"""
COMMENT_MARKER = "<!-- ai-self-review -->"
DOCS_URL = (
"https://github.com/Akkudoktor-EOS/EOS/blob/main/docs/develop/ai.md"
"#self-review-and-the-pull-request-check"
)
_HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
_ASSISTED_BY = re.compile(r"^Assisted-by:[ \t]*\S", re.IGNORECASE | re.MULTILINE)
_ANTHROPIC_CO_AUTHOR = re.compile(
r"^Co-Authored-By:.*<noreply@anthropic\.com>", re.IGNORECASE | re.MULTILINE
)
# Footers that agents write into pull request bodies on their own. Add a tool's footer here
# once it is known; do not guess.
_AGENT_BODY_FOOTERS = (re.compile(r"Generated with \[Claude Code\]", re.IGNORECASE),)
_AI_CHECKBOX = re.compile(r"^[ \t]*[-*][ \t]*\[([ xX])\][ \t]*AI-assisted\b", re.MULTILINE)
_SELF_REVIEW_HEADING = re.compile(r"^##[ \t]+Self-review[ \t]*$", re.MULTILINE)
_NEXT_HEADING = re.compile(r"^#{1,2}[ \t]+\S", re.MULTILINE)
_REVIEWED = re.compile(r"^Reviewed:[ \t]*(\S+)[ \t]*$", re.MULTILINE)
_BASE = re.compile(r"^Base:[ \t]*(\S.*?)[ \t]*$", re.MULTILINE)
_STANDARDS = re.compile(r"^Standards:[ \t]*\d+[ \t]+hard,[ \t]*\d+[ \t]+judgement[ \t]*$", re.MULTILINE)
_SPEC = re.compile(r"^Spec:[ \t]*\d+[ \t]+open[ \t]+\(source:[ \t]*[^)\s][^)]*\)[ \t]*$", re.MULTILINE)
_OPTIONAL_LINES = (
("Base", _BASE, "Base: <ref>"),
("Standards", _STANDARDS, "Standards: <n> hard, <m> judgement"),
("Spec", _SPEC, "Spec: <k> open (source: ...)"),
)
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
_SKIPPED_AUTHORS = re.compile(r"^dependabot(\[bot\])?$", re.IGNORECASE)
_SKIPPED_TITLES = re.compile(r"^Release")
@dataclass
class CheckResult:
"""Outcome of the self-review check of one pull request.
Attributes:
agent_assisted: Whether the pull request counts as agent-assisted.
skipped_reason: Why the check was skipped, or an empty string.
errors: Problems that fail the check; empty when the check passes.
notes: Problems reported without failing the check.
"""
agent_assisted: bool = False
skipped_reason: str = ""
errors: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
@property
def passed(self) -> bool:
"""Return whether the check passed."""
return not self.errors
def normalize_newlines(text: str) -> str:
"""Convert Windows and old Mac line endings to ``\\n``.
GitHub's web editor often saves pull request bodies with ``\\r\\n``; the line patterns
anchor at ``$`` and would not match a line that ends in ``\\r``.
Args:
text: Text with any line endings.
Returns:
The text with ``\\n`` line endings only.
"""
return text.replace("\r\n", "\n").replace("\r", "\n")
def strip_html_comments(text: str) -> str:
"""Remove HTML comments, so template hints never count as content.
Args:
text: Markdown text, e.g. a pull request body.
Returns:
The text without ``<!-- ... -->`` comments.
"""
return _HTML_COMMENT.sub("", text)
def has_agent_trailer(message: str) -> bool:
"""Return whether a commit message carries an agent trailer.
Args:
message: The full commit message.
Returns:
True for an ``Assisted-by:`` trailer or an Anthropic ``Co-Authored-By:`` trailer.
"""
return bool(_ASSISTED_BY.search(message) or _ANTHROPIC_CO_AUTHOR.search(message))
def has_agent_body_marker(body: str) -> bool:
"""Return whether a pull request body carries an agent marker.
Args:
body: The pull request body without HTML comments.
Returns:
True for an ``Assisted-by:`` line or a known agent footer such as
"Generated with [Claude Code]".
"""
return bool(_ASSISTED_BY.search(body)) or any(
footer.search(body) for footer in _AGENT_BODY_FOOTERS
)
def is_ai_box_ticked(body: str) -> bool:
"""Return whether the "AI-assisted" box of the pull request template is ticked.
Args:
body: The pull request body without HTML comments.
Returns:
True when the box is ticked.
"""
match = _AI_CHECKBOX.search(body)
return bool(match and match.group(1) in "xX")
def extract_self_review(body: str) -> str | None:
"""Return the ``## Self-review`` section of a pull request body.
Args:
body: The pull request body without HTML comments.
Returns:
The section text up to the next level-one or level-two heading, or None if the
body has no such section.
"""
heading = _SELF_REVIEW_HEADING.search(body)
if heading is None:
return None
rest = body[heading.end() :]
next_heading = _NEXT_HEADING.search(rest)
return rest[: next_heading.start()] if next_heading else rest
def check_self_review_section(section: str, head_sha: str) -> tuple[list[str], list[str]]:
"""Check the fields of a self-review section.
Only the ``Reviewed:`` SHA is required. The other lines are optional; a present but
malformed optional line is only noted.
Args:
section: The section text below the ``## Self-review`` heading.
head_sha: The full SHA of the pull request head commit.
Returns:
The errors (missing, malformed or stale SHA) and the notes (malformed optional lines).
"""
errors: list[str] = []
notes: list[str] = []
reviewed = _REVIEWED.search(section)
if reviewed is None:
errors.append("The self-review has no 'Reviewed: <sha>' line.")
elif not _FULL_SHA.match(reviewed.group(1)):
errors.append(
f"'Reviewed: {reviewed.group(1)}' is not a full 40-character lowercase commit SHA."
)
elif reviewed.group(1) != head_sha.lower():
errors.append(
f"The self-review is for {reviewed.group(1)}, but the head of the pull request is"
f" {head_sha}. Rerun the self-review and update the pull request body."
)
for key, pattern, expected in _OPTIONAL_LINES:
present = re.search(rf"(?m)^{key}:", section) is not None
if present and pattern.search(section) is None:
notes.append(f"The '{key}:' line does not have the form '{expected}'.")
return errors, notes
def check_pull_request(
body: str, commit_messages: list[str], head_sha: str, title: str, author: str
) -> CheckResult:
"""Check the self-review declaration of one pull request.
Args:
body: The pull request body (Markdown).
commit_messages: The full messages of all commits of the pull request.
head_sha: The full SHA of the pull request head commit.
title: The pull request title.
author: The login of the pull request author.
Returns:
The check result.
"""
result = CheckResult()
if _SKIPPED_AUTHORS.match(author):
result.skipped_reason = f"pull request by {author}"
return result
if _SKIPPED_TITLES.match(title):
result.skipped_reason = "release pull request"
return result
body = strip_html_comments(normalize_newlines(body))
trailer = any(has_agent_trailer(normalize_newlines(message)) for message in commit_messages)
body_marker = has_agent_body_marker(body)
box_ticked = is_ai_box_ticked(body)
result.agent_assisted = trailer or body_marker or box_ticked
if not result.agent_assisted:
return result
if trailer and not box_ticked:
result.notes.append(
"Commits carry an agent trailer (Assisted-by: or Co-Authored-By: Claude), but the"
" 'AI-assisted' box in the pull request body is not ticked."
)
if body_marker and not box_ticked:
result.notes.append(
"The pull request body carries an agent marker (Assisted-by: or 'Generated with"
" [Claude Code]'), but the 'AI-assisted' box is not ticked."
)
section = extract_self_review(body)
if section is None:
result.errors.append("The pull request body has no '## Self-review' section.")
else:
errors, notes = check_self_review_section(section, head_sha)
result.errors.extend(errors)
result.notes.extend(notes)
return result
def read_commit_messages(path: Path) -> list[str]:
"""Read commit messages from a JSON Lines file.
Args:
path: File with one JSON-encoded commit message per line.
Returns:
The commit messages.
Raises:
ValueError: If a line is not a JSON string.
"""
messages: list[str] = []
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if not line.strip():
continue
message = json.loads(line)
if not isinstance(message, str):
raise ValueError(f"{path}:{number}: expected a JSON string, got {type(message).__name__}")
messages.append(message)
return messages
def _fenced(text: str, language: str = "") -> str:
"""Return text as a fenced code block that the text cannot close early.
Pull request authors control parts of the error messages (e.g. the ``Reviewed:`` value). In a
code block they cannot mention users or inject Markdown, as long as the fence is longer than
any backtick run in the text.
Args:
text: The block content.
language: Optional info string of the block.
Returns:
The fenced block.
"""
longest_run = max((len(run) for run in re.findall(r"`+", text)), default=0)
fence = "`" * max(3, longest_run + 1)
return f"{fence}{language}\n{text.rstrip()}\n{fence}"
def render_report(result: CheckResult, head_sha: str) -> str:
"""Render the Markdown report for the pull request comment and the job summary.
Args:
result: The check result.
head_sha: The full SHA of the pull request head commit.
Returns:
The report, starting with ``COMMENT_MARKER``.
"""
notes = ""
if result.notes:
listed = "\n".join(f"- {note}" for note in result.notes)
notes = f"\nNotes (they do not fail the check):\n\n{_fenced(listed)}\n"
if result.passed:
if result.skipped_reason:
detail = f"The check does not apply: {result.skipped_reason}."
elif result.agent_assisted:
detail = f"The self-review declaration is up to date for `{head_sha}`."
else:
detail = "The pull request is not agent-assisted; there is nothing to check."
return f"{COMMENT_MARKER}\n### ✅ AI self-review check passed\n\n{detail}\n{notes}"
errors = "\n".join(f"- {error}" for error in result.errors)
return (
f"{COMMENT_MARKER}\n"
"### ❌ AI self-review check failed\n\n"
"This pull request counts as agent-assisted (an agent trailer in a commit, an agent marker"
" in the body, or the ticked \"AI-assisted\" box), but its self-review declaration is"
f" missing, incomplete or out of date for the head commit `{head_sha}`:\n\n"
f"{_fenced(errors)}\n"
f"{notes}\n"
"**How to fix**\n\n"
"1. Let the agent rerun the self-review on the head you are going to push and rewrite the"
" section in `.scratch/<branch>/pr.md`. Do not just edit the SHA. If the review leads to"
" fixes, commit them and review the new head.\n"
"2. Tick the \"AI-assisted\" box in `pr.md`, if not done yet.\n"
"3. Push the new commits, if any, and right after the push replace the body, either"
" with `gh pr edit <number> --repo Akkudoktor-EOS/EOS --body-file .scratch/<branch>/pr.md`,"
" or by hand: open the"
" \"...\" menu of the pull request description on GitHub, choose \"Edit\", replace the"
" whole text with the content of `pr.md` and click \"Update comment\". Editing the body"
" reruns the check, and this comment is updated.\n\n"
"Repeat this before every later push.\n\n"
"Expected section at the end of the body; the check requires the `Reviewed:` line, the"
" other lines are expected from agents:\n\n"
f"{_fenced(SELF_REVIEW_FORMAT, 'markdown')}\n\n"
f"See [Self-review and the pull request check]({DOCS_URL}) in `docs/develop/ai.md`.\n"
)
def main(argv: list[str] | None = None) -> int:
"""Run the check from the command line.
Args:
argv: Command line arguments; defaults to ``sys.argv[1:]``.
Returns:
0 when the check passes or does not apply, 1 when it fails.
"""
parser = argparse.ArgumentParser(
description="Check the self-review declaration of agent-assisted pull requests."
)
parser.add_argument("--body-file", type=Path, required=True, help="pull request body")
parser.add_argument(
"--commits-file", type=Path, required=True, help="JSON Lines file of commit messages"
)
parser.add_argument("--head-sha", required=True, help="full SHA of the head commit")
parser.add_argument("--title", default="", help="pull request title")
parser.add_argument("--author", default="", help="login of the pull request author")
parser.add_argument(
"--report-file", type=Path, help="write the Markdown report for the comment and summary"
)
args = parser.parse_args(argv)
result = check_pull_request(
body=args.body_file.read_text(encoding="utf-8"),
commit_messages=read_commit_messages(args.commits_file),
head_sha=args.head_sha,
title=args.title,
author=args.author,
)
if args.report_file is not None:
args.report_file.write_text(render_report(result, args.head_sha), encoding="utf-8")
if result.skipped_reason:
print(f"Self-review check skipped: {result.skipped_reason}.")
return 0
if not result.agent_assisted:
print(
"Not agent-assisted: no agent trailer in the commits, no agent marker in the body,"
" 'AI-assisted' box not ticked."
)
return 0
for note in result.notes:
print(f"::warning::{note}")
if result.passed:
print("Agent-assisted pull request with an up-to-date self-review declaration.")
return 0
print("Agent-assisted pull request: the self-review declaration is incomplete.")
for error in result.errors:
print(f"::error::{error}")
print("\nExpected section in the pull request body (see docs/develop/ai.md):\n")
print(SELF_REVIEW_FORMAT)
return 1
if __name__ == "__main__":
sys.exit(main())