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>
This commit is contained in:
Bobby Noelte
2026-11-11 15:37:33 +01:00
co-authored by Claude Opus 5.5
parent 62cbf4cf2a
commit dd41e5d57c
18 changed files with 1394 additions and 9 deletions
+335
View File
@@ -1,5 +1,7 @@
"""Tests for the AI agent development support in ``ai/``."""
import json
import re
import stat
import sys
import time
@@ -15,6 +17,7 @@ DIR_PROJECT_ROOT = Path(__file__).parent.parent
if str(DIR_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(DIR_PROJECT_ROOT))
from scripts import check_pr_self_review as self_review_check # noqa: E402
from scripts import update_mattpocock_skills as skills_updater # noqa: E402
@@ -302,3 +305,335 @@ class TestMattpocockSkills:
missing_url = (tmp_path / "missing").as_uri()
assert skills_updater.main(["--url", missing_url, "--target", str(skills_dir)]) == 2
class TestPrSelfReviewCheck:
"""CI check of the self-review declaration in ``scripts/check_pr_self_review.py``."""
HEAD_SHA = "0123456789abcdef0123456789abcdef01234567"
OTHER_SHA = "fedcba9876543210fedcba9876543210fedcba98"
AGENT_COMMIT = "feat: add x\n\nAssisted-by: Claude Code (claude-opus-5-5)\n"
CLAUDE_COMMIT = "fix: y\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\n"
HUMAN_COMMIT = "fix: z\n\nCo-Authored-By: Jane Doe <jane@example.com>\n"
@classmethod
def body(cls, ticked: bool = True, review: str | None = None) -> str:
"""Return a pull request body following the template."""
box = "x" if ticked else " "
if review is None:
review = (
f"Reviewed: {cls.HEAD_SHA}\n"
"Base: main\n"
"Standards: 0 hard, 2 judgement\n"
"Spec: 1 open (source: .scratch/chore/ai_qa/plan.md)\n\n"
"- judgement: possible Feature Envy, left as is\n"
)
return (
"## Summary\n\nSomething.\n\n"
f"- [{box}] AI-assisted (an agent wrote or co-wrote part of this PR)\n"
"- [x] I reviewed the complete diff myself (author review)\n\n"
"<!-- The agent fills in the section below. -->\n"
f"## Self-review\n\n{review}"
)
def check(
self, body: str, commits: list[str], title: str = "feat: x", author: str = "someone"
) -> self_review_check.CheckResult:
"""Run the check for the head SHA."""
return self_review_check.check_pull_request(body, commits, self.HEAD_SHA, title, author)
def test_not_agent_assisted_passes(self) -> None:
"""Human-only commits and an unticked box need no self-review."""
result = self.check("## Summary\n\n- [ ] AI-assisted\n", [self.HUMAN_COMMIT])
assert not result.agent_assisted
assert result.passed
@pytest.mark.parametrize("commit", [AGENT_COMMIT, CLAUDE_COMMIT])
def test_complete_self_review_passes(self, commit: str) -> None:
"""A ticked box and a fresh, complete self-review pass, open findings included."""
result = self.check(self.body(), [self.HUMAN_COMMIT, commit])
assert result.agent_assisted
assert result.errors == []
def test_ticked_box_without_trailer_needs_self_review(self) -> None:
"""A ticked box alone makes the pull request agent-assisted."""
result = self.check(self.body(review="nothing\n").replace("## Self-review", "## Notes"), [])
assert result.agent_assisted
assert result.errors == ["The pull request body has no '## Self-review' section."]
def test_trailer_without_ticked_box_is_noted(self) -> None:
"""An agent trailer with an unticked box passes with a note."""
result = self.check(self.body(ticked=False), [self.AGENT_COMMIT])
assert result.passed
assert len(result.notes) == 1
assert "box" in result.notes[0]
@pytest.mark.parametrize(
"marker",
[
"Assisted-by: OpenCode (qwen3-coder)",
"🤖 Generated with [Claude Code](https://claude.com/claude-code)",
],
)
def test_body_marker_makes_agent_assisted(self, marker: str) -> None:
"""An agent marker in the body counts; an unticked box is noted."""
ticked = self.check(self.body() + f"\n{marker}\n", [self.HUMAN_COMMIT])
unticked = self.check(self.body(ticked=False) + f"\n{marker}\n", [self.HUMAN_COMMIT])
assert ticked.agent_assisted
assert ticked.errors == []
assert ticked.notes == []
assert unticked.agent_assisted
assert unticked.passed
assert len(unticked.notes) == 1
assert "agent marker" in unticked.notes[0]
def test_marker_without_section_fails(self) -> None:
"""An agent marker makes the section with the SHA required, ticked box or not."""
body = "- [ ] AI-assisted\n\nAssisted-by: OpenCode (qwen3-coder)\n"
result = self.check(body, [self.HUMAN_COMMIT])
assert "The pull request body has no '## Self-review' section." in result.errors
def test_body_marker_in_html_comment_does_not_count(self) -> None:
"""A marker inside a template comment is not a signal."""
body = "- [ ] AI-assisted\n<!-- e.g. Assisted-by: Claude Code (claude-opus-5-5) -->\n"
result = self.check(body, [self.HUMAN_COMMIT])
assert not result.agent_assisted
def test_stale_self_review_fails(self) -> None:
"""A self-review for another commit than the head fails."""
body = self.body().replace(self.HEAD_SHA, self.OTHER_SHA)
result = self.check(body, [self.AGENT_COMMIT])
assert len(result.errors) == 1
assert self.OTHER_SHA in result.errors[0]
assert self.HEAD_SHA in result.errors[0]
def test_abbreviated_sha_fails(self) -> None:
"""An abbreviated SHA is rejected, even if it is a prefix of the head."""
body = self.body().replace(self.HEAD_SHA, self.HEAD_SHA[:12])
result = self.check(body, [self.AGENT_COMMIT])
assert len(result.errors) == 1
assert "full 40-character" in result.errors[0]
def test_missing_sha_fails(self) -> None:
"""A section without the Reviewed line fails; missing optional lines are not reported."""
result = self.check(self.body(review="- looks fine\n"), [self.AGENT_COMMIT])
assert result.errors == ["The self-review has no 'Reviewed: <sha>' line."]
assert result.notes == []
def test_only_sha_passes(self) -> None:
"""The Reviewed line alone is enough to pass."""
result = self.check(self.body(review=f"Reviewed: {self.HEAD_SHA}\n"), [self.AGENT_COMMIT])
assert result.passed
assert result.notes == []
def test_uppercase_sha_fails(self) -> None:
"""A full SHA in uppercase is malformed."""
body = self.body().replace(self.HEAD_SHA, self.HEAD_SHA.upper())
result = self.check(body, [self.AGENT_COMMIT])
assert len(result.errors) == 1
assert "lowercase" in result.errors[0]
@pytest.mark.parametrize(
("good", "bad", "key"),
[
("Standards: 0 hard, 2 judgement", "Standards: two hard, 2 judgement", "Standards:"),
("Standards: 0 hard, 2 judgement", "Standards: 0 hard", "Standards:"),
(
"Spec: 1 open (source: .scratch/chore/ai_qa/plan.md)",
"Spec: 1 open (source: )",
"Spec:",
),
("Spec: 1 open (source: .scratch/chore/ai_qa/plan.md)", "Spec: 1 open", "Spec:"),
("Base: main", "Base:", "Base:"),
],
ids=["standards-word", "standards-short", "spec-empty-source", "spec-no-source", "base"],
)
def test_malformed_optional_lines_are_noted(self, good: str, bad: str, key: str) -> None:
"""A present but malformed optional line passes with a note."""
body = self.body()
assert good in body
result = self.check(body.replace(good, bad), [self.AGENT_COMMIT])
assert result.passed
assert len(result.notes) == 1
assert f"'{key}" in result.notes[0]
def test_section_inside_html_comment_does_not_count(self) -> None:
"""An example section in a template comment is not a declaration."""
body = self.body().replace("## Self-review", "<!--\n## Self-review") + "-->\n"
result = self.check(body, [self.AGENT_COMMIT])
assert result.errors == ["The pull request body has no '## Self-review' section."]
def test_section_ends_at_next_heading(self) -> None:
"""Fields after the next heading do not belong to the self-review."""
body = self.body(review=f"Reviewed: {self.HEAD_SHA}\n\n## Other\n\nBase: main\n")
result = self.check(body, [self.AGENT_COMMIT])
assert result.passed
assert result.notes == []
def test_windows_line_endings_pass(self) -> None:
"""A body and commits saved with CRLF line endings are checked like LF ones."""
body = self.body().replace("\n", "\r\n")
commit = self.AGENT_COMMIT.replace("\n", "\r\n")
result = self.check(body, [commit])
assert result.agent_assisted
assert result.errors == []
assert result.notes == []
def test_report_lists_notes(self) -> None:
"""Notes appear in the report of a passing and of a failing check."""
passing = self.check(self.body(ticked=False), [self.AGENT_COMMIT])
failing = self.check(
self.body(ticked=False).replace(self.HEAD_SHA, self.OTHER_SHA), [self.AGENT_COMMIT]
)
for result in (passing, failing):
report = self_review_check.render_report(result, self.HEAD_SHA)
assert "do not fail the check" in report
assert result.notes[0] in report
@pytest.mark.parametrize(
("title", "author"),
[("chore: bump x", "dependabot[bot]"), ("Release 1.2.3", "someone")],
)
def test_skipped_pull_requests(self, title: str, author: str) -> None:
"""Dependabot and release pull requests are not checked."""
result = self.check("", [self.AGENT_COMMIT], title=title, author=author)
assert result.skipped_reason
assert result.passed
def test_main_exit_codes(self, tmp_path: Path) -> None:
"""The command line exits 0 for a valid and 1 for a stale declaration."""
body_file = tmp_path / "body.md"
commits_file = tmp_path / "commits.jsonl"
body_file.write_text(self.body(), encoding="utf-8")
commits_file.write_text(
"\n".join(json.dumps(m) for m in [self.HUMAN_COMMIT, self.AGENT_COMMIT]) + "\n",
encoding="utf-8",
)
args = ["--body-file", str(body_file), "--commits-file", str(commits_file)]
assert self_review_check.main([*args, "--head-sha", self.HEAD_SHA]) == 0
assert self_review_check.main([*args, "--head-sha", self.OTHER_SHA]) == 1
def test_report_for_failure_explains_the_fix(self) -> None:
"""A failing check renders the marked comment with the errors and how to fix them."""
body = self.body().replace(self.HEAD_SHA, self.OTHER_SHA)
result = self.check(body, [self.AGENT_COMMIT])
report = self_review_check.render_report(result, self.HEAD_SHA)
assert report.startswith(self_review_check.COMMENT_MARKER + "\n")
assert "check failed" in report
assert f"- {result.errors[0]}" in report
assert "How to fix" in report
assert "gh pr edit" in report
assert "Reviewed: <full 40-character SHA" in report
def test_report_keeps_author_text_inside_the_code_block(self) -> None:
"""Backticks from the body cannot close the code block and inject Markdown."""
body = self.body().replace(self.HEAD_SHA, "````\n@someone")
result = self.check(body, [self.AGENT_COMMIT])
report = self_review_check.render_report(result, self.HEAD_SHA)
fence_match = re.search(r"(?m)^(`{4,})$", report)
assert fence_match is not None
fence = fence_match.group(1)
block = report[fence_match.end() : report.index("\n" + fence + "\n", fence_match.end())]
assert "@someone" not in report.replace(block, "")
assert "````" in block
@pytest.mark.parametrize(
("body", "commits", "title", "expected"),
[
("BODY", ["AGENT"], "feat: x", "up to date for"),
("- [ ] AI-assisted\n", ["HUMAN"], "feat: x", "not agent-assisted"),
("", ["AGENT"], "Release 1.0.0", "does not apply"),
],
)
def test_report_for_passing_check(
self, body: str, commits: list[str], title: str, expected: str
) -> None:
"""A passing check renders a short marked note."""
messages = {"AGENT": self.AGENT_COMMIT, "HUMAN": self.HUMAN_COMMIT}
result = self.check(
self.body() if body == "BODY" else body, [messages[c] for c in commits], title=title
)
report = self_review_check.render_report(result, self.HEAD_SHA)
assert report.startswith(self_review_check.COMMENT_MARKER + "\n")
assert "check passed" in report
assert expected in report
def test_main_writes_report_file(self, tmp_path: Path) -> None:
"""``--report-file`` receives the report for both outcomes."""
body_file = tmp_path / "body.md"
commits_file = tmp_path / "commits.jsonl"
report_file = tmp_path / "report.md"
body_file.write_text(self.body(), encoding="utf-8")
commits_file.write_text(json.dumps(self.AGENT_COMMIT) + "\n", encoding="utf-8")
args = ["--body-file", str(body_file), "--commits-file", str(commits_file)]
assert self_review_check.main(
[*args, "--head-sha", self.HEAD_SHA, "--report-file", str(report_file)]
) == 0
assert "check passed" in report_file.read_text(encoding="utf-8")
assert self_review_check.main(
[*args, "--head-sha", self.OTHER_SHA, "--report-file", str(report_file)]
) == 1
assert "check failed" in report_file.read_text(encoding="utf-8")
def test_workflow_uses_the_script_marker(self) -> None:
"""The workflow finds its comment by the same marker the script writes."""
workflow = (DIR_PROJECT_ROOT / ".github" / "workflows" / "ai-self-review.yml").read_text(
encoding="utf-8"
)
assert f'COMMENT_MARKER: "{self_review_check.COMMENT_MARKER}"' in workflow
def test_report_for_failure_links_the_docs(self) -> None:
"""The failure comment links the docs and covers the push."""
result = self.check(self.body(review="- nothing\n"), [self.AGENT_COMMIT])
report = self_review_check.render_report(result, self.HEAD_SHA)
assert f"]({self_review_check.DOCS_URL})" in report
assert "right after the push" in report
assert "before every later push" in report
assert "gh pr edit" in report
assert "by hand" in report
def test_read_commit_messages_rejects_non_strings(self, tmp_path: Path) -> None:
"""A commits file line that is not a JSON string is an error."""
commits_file = tmp_path / "commits.jsonl"
commits_file.write_text('{"message": "x"}\n', encoding="utf-8")
with pytest.raises(ValueError, match="expected a JSON string"):
self_review_check.read_commit_messages(commits_file)