mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
co-authored by
Claude Opus 5.5
parent
62cbf4cf2a
commit
dd41e5d57c
@@ -0,0 +1,38 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The smallest view that makes the change clear, next to short text: pseudocode, a call or file
|
||||||
|
tree, a diff sketch or a Mermaid diagram. Reference the issue, e.g. "Closes #123". Sections follow
|
||||||
|
the pr skill, see ai/skills/engineering/pr/SKILL.md.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
<!-- Before and after: a failing and passing test run, console output or a screenshot. -->
|
||||||
|
|
||||||
|
## Merge Danger
|
||||||
|
|
||||||
|
**Door:** <!-- one-way or two-way: can the change be walked back cheaply? -->
|
||||||
|
|
||||||
|
**Blast Radius:** <!-- one word: who or what is affected if it goes wrong -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Optional, when they apply:
|
||||||
|
**Breaking:** what users, configurations or integrations must change.
|
||||||
|
**Security:** what the change means for security.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] AI-assisted (an agent wrote or co-wrote part of this PR)
|
||||||
|
- [ ] I reviewed the complete diff myself (author review)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If the pull request is AI-assisted, CI requires the section below with the full SHA of the head
|
||||||
|
commit in its Reviewed: line. The agent writes the section from its self-review of the current head
|
||||||
|
commit. Before every push, rerun the self-review and rewrite the section, do not just edit the
|
||||||
|
SHA; replace the body right after the push. See docs/develop/ai.md.
|
||||||
|
Delete the section if the pull request is not AI-assisted.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Self-review
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: ai-self-review
|
||||||
|
|
||||||
|
# Checks the self-review declaration of agent-assisted pull requests, see
|
||||||
|
# docs/adr/0001-self-declared-sha-bound-review-gate.md and docs/develop/ai.md.
|
||||||
|
#
|
||||||
|
# pull_request_target runs the workflow and the check script of the base branch, so a pull
|
||||||
|
# request cannot weaken its own check. The job never checks out or runs code of the pull
|
||||||
|
# request; it reads the body and the commit messages through the API only.
|
||||||
|
#
|
||||||
|
# It runs only for pull requests into main, never on pushes to main or to other branches, and not
|
||||||
|
# for pull requests into other branches.
|
||||||
|
#
|
||||||
|
# On failure it posts one comment that explains the fix, found again by its marker and updated in
|
||||||
|
# place; once the check passes, the comment is updated to say so. Commenting needs
|
||||||
|
# pull-requests: write, which is safe here because no pull request code runs.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Must equal COMMENT_MARKER in scripts/check_pr_self_review.py (checked by tests/test_ai.py).
|
||||||
|
COMMENT_MARKER: "<!-- ai-self-review -->"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ai-self-review-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
self-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the base branch (not the pull request)
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.base.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
- name: Fetch the pull request body and commit messages
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
|
PR_URL: repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/body.md"
|
||||||
|
gh api --paginate "$PR_URL/commits" --jq '.[].commit.message | @json' \
|
||||||
|
> "$RUNNER_TEMP/commits.jsonl"
|
||||||
|
- name: Check the self-review declaration
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
python scripts/check_pr_self_review.py \
|
||||||
|
--body-file "$RUNNER_TEMP/body.md" \
|
||||||
|
--commits-file "$RUNNER_TEMP/commits.jsonl" \
|
||||||
|
--head-sha "$PR_HEAD_SHA" \
|
||||||
|
--title "$PR_TITLE" \
|
||||||
|
--author "$PR_AUTHOR" \
|
||||||
|
--report-file "$RUNNER_TEMP/report.md"
|
||||||
|
status=$?
|
||||||
|
if [ ! -s "$RUNNER_TEMP/report.md" ]; then
|
||||||
|
# The script failed before it could write its report.
|
||||||
|
status=1
|
||||||
|
{
|
||||||
|
echo "$COMMENT_MARKER"
|
||||||
|
echo "### ❌ AI self-review check could not run"
|
||||||
|
echo
|
||||||
|
echo "The check script failed before it could check the declaration. This is a"
|
||||||
|
echo "problem of the check, not necessarily of the pull request; see the log of the"
|
||||||
|
echo "workflow run."
|
||||||
|
} > "$RUNNER_TEMP/report.md"
|
||||||
|
fi
|
||||||
|
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||||
|
cat "$RUNNER_TEMP/report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
- name: Comment on the pull request
|
||||||
|
# A failing comment must not change the result of the check.
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
STATUS: ${{ steps.check.outputs.status }}
|
||||||
|
ISSUE_URL: repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}
|
||||||
|
COMMENTS_URL: repos/${{ github.repository }}/issues/comments
|
||||||
|
run: |
|
||||||
|
comment_id=$(gh api --paginate "$ISSUE_URL/comments" --jq \
|
||||||
|
'.[] | select(.user.login == "github-actions[bot]")
|
||||||
|
| select(.body | startswith(env.COMMENT_MARKER)) | .id' | head -n 1)
|
||||||
|
if [ -n "$comment_id" ]; then
|
||||||
|
gh api --method PATCH "$COMMENTS_URL/$comment_id" -F "body=@$RUNNER_TEMP/report.md" \
|
||||||
|
> /dev/null
|
||||||
|
elif [ "$STATUS" != "0" ]; then
|
||||||
|
gh api --method POST "$ISSUE_URL/comments" -F "body=@$RUNNER_TEMP/report.md" > /dev/null
|
||||||
|
fi
|
||||||
|
- name: Report the result
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
STATUS: ${{ steps.check.outputs.status }}
|
||||||
|
run: |
|
||||||
|
if [ "${STATUS:-1}" != "0" ]; then
|
||||||
|
echo "::error::The self-review check failed; see the step summary or the pull request comment."
|
||||||
|
fi
|
||||||
|
exit "${STATUS:-1}"
|
||||||
@@ -269,3 +269,6 @@ tests/testdata/docs/
|
|||||||
|
|
||||||
# FastHTML session key
|
# FastHTML session key
|
||||||
.sesskey
|
.sesskey
|
||||||
|
|
||||||
|
# Agent task plans and PR bodies (see docs/develop/ai.md)
|
||||||
|
.scratch/
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: pymarkdown
|
- id: pymarkdown
|
||||||
files: ^docs/
|
files: ^docs/
|
||||||
|
# docs/agents/ is seeded from the vendored engineering skills, not user documentation.
|
||||||
|
exclude: ^docs/agents/
|
||||||
args:
|
args:
|
||||||
- --config=docs/pymarkdown.json
|
- --config=docs/pymarkdown.json
|
||||||
- scan
|
- scan
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ maintainable code, testability, and type safety.
|
|||||||
management.
|
management.
|
||||||
- Preserve compatibility with existing tests and supported integrations unless
|
- Preserve compatibility with existing tests and supported integrations unless
|
||||||
a change explicitly requires otherwise.
|
a change explicitly requires otherwise.
|
||||||
|
- **Create, parse, convert and compare dates, times, durations and time zones
|
||||||
|
with `akkudoktoreos.utils.datetimeutil`** (`to_datetime`, `to_duration`,
|
||||||
|
`to_timezone`, `to_time`, `compare_datetimes`, `DateTime`, `Duration`). Do not call
|
||||||
|
`datetime`, `pendulum` or `dateutil` directly. Exceptions: `datetimeutil.py`
|
||||||
|
itself, and boundaries where a third-party API needs its own type (pandas,
|
||||||
|
NumPy, the standard library), converted right at the boundary. Applies to new
|
||||||
|
and changed code; migrate existing direct calls only where the change touches
|
||||||
|
them anyway.
|
||||||
|
- **Optimization parameters are always valid**: their preparation never fails
|
||||||
|
and never cancels an optimization run. See the `energy-domain` skill.
|
||||||
|
- Commits written or co-written by an agent end with the trailer
|
||||||
|
`Assisted-by: <tool> (<model id>)`, see the `commit-messages` skill.
|
||||||
- Never use the workspace `.venv`: run `uv`, `make` and all Python tools in a
|
- Never use the workspace `.venv`: run `uv`, `make` and all Python tools in a
|
||||||
virtual environment outside the workspace (`UV_PROJECT_ENVIRONMENT`), see
|
virtual environment outside the workspace (`UV_PROJECT_ENVIRONMENT`), see
|
||||||
`ai/skills/python-environment/SKILL.md`.
|
`ai/skills/python-environment/SKILL.md`.
|
||||||
@@ -79,6 +91,62 @@ Load these only when the user asks for them by name:
|
|||||||
| `ai/skills/engineering/triage/SKILL.md` | Triage issues and external PRs |
|
| `ai/skills/engineering/triage/SKILL.md` | Triage issues and external PRs |
|
||||||
| `ai/skills/engineering/wayfinder/SKILL.md` | Plan large work as decision tickets |
|
| `ai/skills/engineering/wayfinder/SKILL.md` | Plan large work as decision tickets |
|
||||||
|
|
||||||
|
## Task plan and self-review
|
||||||
|
|
||||||
|
- Before writing code for a task beyond a small fix, agree on a plan with the
|
||||||
|
user and write it to `.scratch/<branch>/plan.md` (the **task plan**, see
|
||||||
|
`GLOSSARY.md`). `.scratch/` is ignored by git; never commit it. Keep the plan
|
||||||
|
up to date when the user changes the task.
|
||||||
|
- When the task is done, run the **self-review**: the `code-review` skill
|
||||||
|
against the base branch (usually `main`). The spec is the GitHub issue the
|
||||||
|
commits reference, if any, otherwise the task plan.
|
||||||
|
- The coding standards for the review are `CONTRIBUTING.md`, this file and the
|
||||||
|
project skills in `ai/skills/` (not `ai/skills/engineering/`).
|
||||||
|
- Fix every **hard violation** (breach of a documented project rule) yourself,
|
||||||
|
rerun the checks and the self-review. Report **judgement calls** (code
|
||||||
|
smells, possible scope creep) to the user without acting on them.
|
||||||
|
- Write the pull request body to `.scratch/<branch>/pr.md` with the `pr` skill,
|
||||||
|
in the structure of `.github/pull_request_template.md`: the skill's
|
||||||
|
`## Summary`, `## Evidence` and `## Merge Danger` (with optional
|
||||||
|
**Breaking:** and **Security:** lines), then `## Checklist` with the
|
||||||
|
"AI-assisted" box ticked. It ends with a `## Self-review` section in
|
||||||
|
exactly this format. CI requires the `Reviewed:` line and notes problems
|
||||||
|
with the others, see `docs/develop/ai.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 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>)
|
||||||
|
|
||||||
|
- <one line per open finding or judgement call, and why it was left>
|
||||||
|
|
||||||
|
Assisted-by: <tool> (<model id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep footers the tool adds itself below it, e.g. Claude Code's
|
||||||
|
`Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||||
|
|
||||||
|
- The self-review runs once at the end of the task, not before every commit.
|
||||||
|
Before every push (e.g. of fixes after review feedback), rerun the
|
||||||
|
self-review and rewrite the section in `.scratch/<branch>/pr.md`; never
|
||||||
|
just edit the SHA. The `Reviewed:` SHA must be the head of the pull
|
||||||
|
request. The pull request body is replaced right after the push.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
GitHub issues of the upstream repository `Akkudoktor-EOS/EOS`. See
|
||||||
|
`docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Single-context: `GLOSSARY.md` and `docs/adr/` at the repository root. See
|
||||||
|
`docs/agents/domain.md`.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
Before considering a change complete:
|
Before considering a change complete:
|
||||||
@@ -91,3 +159,5 @@ Before considering a change complete:
|
|||||||
- [ ] Mypy checks pass where applicable.
|
- [ ] Mypy checks pass where applicable.
|
||||||
- [ ] Configuration and documentation updated when necessary.
|
- [ ] Configuration and documentation updated when necessary.
|
||||||
- [ ] No unrelated changes or unnecessary exceptions introduced.
|
- [ ] No unrelated changes or unnecessary exceptions introduced.
|
||||||
|
- [ ] Self-review done, hard violations fixed, judgement calls reported.
|
||||||
|
- [ ] `.scratch/<branch>/pr.md` holds an up-to-date `## Self-review` section.
|
||||||
|
|||||||
@@ -133,6 +133,24 @@ enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit
|
|||||||
You may use [`commitizen`](https://commitizen-tools.github.io/commitizen) also to create a
|
You may use [`commitizen`](https://commitizen-tools.github.io/commitizen) also to create a
|
||||||
commit message and commit your change.
|
commit message and commit your change.
|
||||||
|
|
||||||
|
### AI-assisted contributions
|
||||||
|
|
||||||
|
You may use coding agents, but you are the author of what you propose and must review the complete
|
||||||
|
change yourself. Pull requests that an agent wrote or co-wrote are checked by CI:
|
||||||
|
|
||||||
|
- Every commit an agent wrote or co-wrote ends with the trailer
|
||||||
|
`Assisted-by: <tool> (<model id>)`, e.g. `Assisted-by: Claude Code (claude-opus-5-5)`.
|
||||||
|
- Tick the "AI-assisted" box in the pull request template.
|
||||||
|
- The pull request body ends with a `## Self-review` section from the agent's self-review. It names
|
||||||
|
the full SHA of the head commit it reviewed. Before every push, rerun the self-review and rewrite
|
||||||
|
the section, do not just edit the SHA; replace the body right after the push. Agents also add an
|
||||||
|
`Assisted-by:` line to the body, which marks the pull request as agent-assisted.
|
||||||
|
|
||||||
|
CI checks that the section is present and names the current head commit; other problems are
|
||||||
|
only noted. It cannot check the review itself. If the check fails, CI comments on the pull request
|
||||||
|
with what is wrong and how to fix it. See `docs/develop/ai.md` (AI-assisted development) for the
|
||||||
|
workflow and the exact format.
|
||||||
|
|
||||||
## Thank you!
|
## Thank you!
|
||||||
|
|
||||||
And last but not least thanks to all our contributors
|
And last but not least thanks to all our contributors
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
|
Energy forecasts, optimization and energy management plans for local energy devices, and the
|
||||||
|
AI-assisted workflow used to develop them. Written for contributors and coding agents; where
|
||||||
|
user-facing docs use another name, it is listed under _Avoid_.
|
||||||
|
|
||||||
|
## Optimization
|
||||||
|
|
||||||
|
**Optimization parameters**:
|
||||||
|
The complete, valid input set handed to one optimizer for one optimization run.
|
||||||
|
_Avoid_: optimization input, request parameters
|
||||||
|
|
||||||
|
**Fallback value**:
|
||||||
|
A default or heuristic value that replaces an invalid or missing input while the optimization
|
||||||
|
parameters are prepared, always reported as an error.
|
||||||
|
_Avoid_: dummy value, demo value
|
||||||
|
|
||||||
|
## AI-assisted development
|
||||||
|
|
||||||
|
**Host**:
|
||||||
|
The contributor's own machine, where the repository is checked out and where changes are reviewed,
|
||||||
|
run and pushed.
|
||||||
|
|
||||||
|
**Sandbox**:
|
||||||
|
The isolated container in which a coding agent works on the repository.
|
||||||
|
_Avoid_: container, VM
|
||||||
|
|
||||||
|
**Task plan**:
|
||||||
|
The plan the contributor and the agent agreed on for one task; it is the spec the self-review checks
|
||||||
|
the change against, unless the change references an issue.
|
||||||
|
_Avoid_: design note, ticket
|
||||||
|
|
||||||
|
**Self-review**:
|
||||||
|
The agent's advisory review of its own change against the project rules and its spec (the
|
||||||
|
referenced issue, otherwise the task plan), done before it hands the change over and declared in
|
||||||
|
the pull request.
|
||||||
|
_Avoid_: AI review, automated review
|
||||||
|
|
||||||
|
**Hard violation**:
|
||||||
|
A self-review finding that breaches a documented project rule; the agent fixes it before handing
|
||||||
|
the change over.
|
||||||
|
_Avoid_: error, blocker
|
||||||
|
|
||||||
|
**Judgement call**:
|
||||||
|
A self-review finding based on a heuristic, such as a code smell or possible scope creep; it is
|
||||||
|
reported and left to the author.
|
||||||
|
_Avoid_: warning, nit
|
||||||
|
|
||||||
|
**Author review**:
|
||||||
|
The contributor's own review of the complete change on the host; the deciding review before a pull
|
||||||
|
request is opened or updated.
|
||||||
|
_Avoid_: human review, manual review
|
||||||
@@ -88,9 +88,23 @@ Rules:
|
|||||||
|
|
||||||
3. Footer (optional)
|
3. Footer (optional)
|
||||||
|
|
||||||
Use only for breaking changes (BREAKING CHANGE: …) or issue references
|
Use only for breaking changes (BREAKING CHANGE: …), issue references
|
||||||
(Refs: #123). Conventional Commits allows this; it is not a replacement for
|
(Refs: #123) and the agent trailer below. Conventional Commits allows this; it
|
||||||
the body section.
|
is not a replacement for the body section.
|
||||||
|
|
||||||
|
4. Agent trailer (mandatory for agents)
|
||||||
|
|
||||||
|
Every commit that a coding agent wrote or co-wrote ends with
|
||||||
|
|
||||||
|
```text
|
||||||
|
Assisted-by: <tool> (<model id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
e.g. `Assisted-by: Claude Code (claude-opus-5-5)` or
|
||||||
|
`Assisted-by: OpenCode (qwen3-coder)`. Keep trailers the tool adds itself
|
||||||
|
(e.g. `Co-Authored-By:`) and put `Assisted-by:` with them in the last
|
||||||
|
paragraph. The CI check for agent-assisted pull requests reads it, see
|
||||||
|
`docs/develop/ai.md`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -102,6 +116,7 @@ the body section.
|
|||||||
[<another short description>]
|
[<another short description>]
|
||||||
|
|
||||||
[optional footer]
|
[optional footer]
|
||||||
|
[Assisted-by: <tool> (<model id>)]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Exactly one blank line between the subject and the body heading.
|
- Exactly one blank line between the subject and the body heading.
|
||||||
@@ -191,5 +206,6 @@ as shown above.)
|
|||||||
□ Each secondary item is a short single-line description.
|
□ Each secondary item is a short single-line description.
|
||||||
□ No emoji, no asterisk-prefixed headings.
|
□ No emoji, no asterisk-prefixed headings.
|
||||||
□ If more than one body heading would be needed, split the commit instead.
|
□ If more than one body heading would be needed, split the commit instead.
|
||||||
□ Run uv run cz check --rev-range main..HEAD (or rely on the pre-commit hook) to confirm the
|
□ Agent-written commits end with `Assisted-by: <tool> (<model id>)`.
|
||||||
|
□ Run uv run --frozen cz check --rev-range main..HEAD (or rely on the pre-commit hook) to confirm the
|
||||||
message passes the project's check.
|
message passes the project's check.
|
||||||
|
|||||||
@@ -17,3 +17,27 @@ description: Use when working on optimization logic, adapters, controllers, devi
|
|||||||
- **Preserve existing energy sign conventions, units, and conversion
|
- **Preserve existing energy sign conventions, units, and conversion
|
||||||
semantics.** Verify them before changing power, energy, or charging
|
semantics.** Verify them before changing power, energy, or charging
|
||||||
behavior.
|
behavior.
|
||||||
|
|
||||||
|
## Optimization parameters are always valid
|
||||||
|
|
||||||
|
The preparation of the **optimization parameters** (see `GLOSSARY.md`) always
|
||||||
|
creates a valid parameter set. It never fails and never cancels an
|
||||||
|
optimization run. This is a hard rule.
|
||||||
|
|
||||||
|
- It covers the whole path from configuration, predictions and measurements to
|
||||||
|
the parameters handed to an optimizer, for every optimizer (GENETIC,
|
||||||
|
GENETIC0 and future ones), including device and operating-point parameters.
|
||||||
|
- Replace an invalid or missing input with a **fallback value**:
|
||||||
|
1. the configuration field's default,
|
||||||
|
2. a heuristic only when there is no default or the default does not fit
|
||||||
|
the operating state (e.g. a charge limit of 0 when charging is
|
||||||
|
inhibited). For a missing prediction: PV = 0, the last known price
|
||||||
|
carried forward, the load default.
|
||||||
|
- Every fallback:
|
||||||
|
- logs at error level, naming the input and the substitute value,
|
||||||
|
- never changes the user's configuration (no write-back),
|
||||||
|
- never invents devices: a device that cannot be resolved is left out or
|
||||||
|
neutralised, never replaced by a demo device,
|
||||||
|
- never lets an exception escape from the preparation.
|
||||||
|
- A parameter set that is still invalid after preparation is a bug: cover the
|
||||||
|
case with a test and fix the preparation.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# CI enforces a self-declared, SHA-bound self-review for agent-assisted pull requests
|
||||||
|
|
||||||
|
Pull requests that a coding agent wrote or co-wrote must carry a `## Self-review` section in the
|
||||||
|
pull request body that names the full head commit SHA it reviewed. CI does not verify the review
|
||||||
|
itself: the project has no AI in CI, and a signing key in the sandbox would be reachable by a misled
|
||||||
|
agent. Instead CI detects agent assistance (an `Assisted-by:` or Anthropic `Co-Authored-By:` commit
|
||||||
|
trailer, an `Assisted-by:` line or a known agent footer in the pull request body, or the
|
||||||
|
"AI-assisted" box in the pull request template). It fails only when the declaration or its
|
||||||
|
`Reviewed:` SHA is missing or malformed, or names a SHA other than the current head; everything
|
||||||
|
else (an unticked box next to a trailer or marker, malformed optional lines) is only noted. Like a
|
||||||
|
DCO sign-off, this makes forgetting impossible and a false claim deliberate; the author review and
|
||||||
|
the maintainers' review remain the actual controls.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **AI review in CI**: rejected, the project runs no AI in CI.
|
||||||
|
- **Signed review attestation from the sandbox**: rejected, the agent can reach the key and sign
|
||||||
|
anything.
|
||||||
|
- **Honour system (template checkbox only)**: rejected, a declaration that is not bound to a commit
|
||||||
|
silently covers code pushed after the review.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every push to an agent-assisted pull request needs a fresh self-review before the push and an
|
||||||
|
updated pull request body right after it; rebases and squashes before merge do too.
|
||||||
|
- Open findings do not fail the check, so agents have no incentive to hide them.
|
||||||
|
- The check only blocks merging once maintainers make it a required status check.
|
||||||
|
- The workflow runs on `pull_request_target` with the base branch's script and never runs pull
|
||||||
|
request code, so a pull request cannot weaken its own check; changes to the check take effect
|
||||||
|
only after they are merged.
|
||||||
|
- The workflow needs `pull-requests: write` to comment how to fix a failing check. That is safe
|
||||||
|
only as long as it runs no pull request code.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
Decisions that are hard to reverse, surprising without context and the result of a real trade-off.
|
||||||
|
Each record states the context, the decision and why it was made.
|
||||||
|
|
||||||
|
```{toctree}
|
||||||
|
:maxdepth: 1
|
||||||
|
:glob:
|
||||||
|
|
||||||
|
0*
|
||||||
|
```
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Domain Docs
|
||||||
|
|
||||||
|
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||||
|
|
||||||
|
## Before exploring, read these
|
||||||
|
|
||||||
|
- **`GLOSSARY.md`** at the repo root, or
|
||||||
|
- **`GLOSSARY-MAP.md`** at the repo root if it exists: it points at one `GLOSSARY.md` per context. Read each one relevant to the topic.
|
||||||
|
- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||||
|
|
||||||
|
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
Single-context repo (most repos):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── GLOSSARY.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-context repo (presence of `GLOSSARY-MAP.md` at the root):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── GLOSSARY-MAP.md
|
||||||
|
├── docs/adr/ ← system-wide decisions
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── GLOSSARY.md
|
||||||
|
│ └── docs/adr/ ← context-specific decisions
|
||||||
|
└── billing/
|
||||||
|
├── GLOSSARY.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the glossary's vocabulary
|
||||||
|
|
||||||
|
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||||
|
|
||||||
|
If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Issue tracker: GitHub
|
||||||
|
|
||||||
|
Issues and specs for this repo live as GitHub issues of `Akkudoktor-EOS/EOS`. Use the `gh` CLI for all operations.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||||
|
- **Read an issue**: `gh issue view <number> --json number,title,body,labels,comments`.
|
||||||
|
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||||
|
- **Make an issue a sub-issue of a parent**: `gh issue create --parent <parent> ...`, or `gh issue edit <parent> --add-sub-issue <child>` afterwards (`gh` 2.94+). Older `gh`: `gh api --method POST repos/<owner>/<repo>/issues/<parent>/sub_issues -F sub_issue_id=<child-db-id>` (database id, as in **Blocking** below). Without sub-issues, put `Part of #<parent>` at the top of the child body.
|
||||||
|
- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh issue create --blocked-by <n>,<n> ...` or `gh issue edit <child> --add-blocked-by <blocker>` (`gh` 2.94+). Older `gh`: `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
|
||||||
|
- **Count open blockers**: `gh api repos/<owner>/<repo>/issues/<n> --jq .issue_dependencies_summary.blocked_by` (open blockers only; 0 means startable). `gh issue view <n> --json blockedBy` also lists closed blockers, so filter it: `--jq '[.blockedBy.nodes[] | select(.state == "OPEN")] | length'`.
|
||||||
|
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||||
|
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||||
|
- **Close**: post the explanation with `gh issue comment <number> --body "..."`, then `gh issue close <number>`. On an already-closed issue, `gh issue close --comment` drops the comment and still exits 0.
|
||||||
|
|
||||||
|
The tracker is the upstream repository **`Akkudoktor-EOS/EOS`**, not a contributor's fork. Pass
|
||||||
|
`--repo Akkudoktor-EOS/EOS` to every `gh issue` command, because a clone of a fork may resolve to
|
||||||
|
the fork.
|
||||||
|
|
||||||
|
Reading issues needs no confirmation. Before creating, editing, labelling, commenting on or closing
|
||||||
|
an issue, show the content to the user and wait for their go: these are public actions in the
|
||||||
|
upstream project. Never file into `mattpocock/skills` (the repo these skills come from).
|
||||||
|
|
||||||
|
Labels are created by the EOS maintainers only; do not run `gh label create`.
|
||||||
|
|
||||||
|
## Pull requests as a triage surface
|
||||||
|
|
||||||
|
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
|
||||||
|
|
||||||
|
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
|
||||||
|
|
||||||
|
- **Read a PR**: `gh pr view <number>`, then `gh pr view <number> --comments` (off a TTY, `--comments` prints only the comments, not the body), and `gh pr diff <number>` for the diff.
|
||||||
|
- **List external PRs for triage**: `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, author_association, labels: [.labels[].name]}'`.
|
||||||
|
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
|
||||||
|
|
||||||
|
GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a GitHub issue.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Read it as in **Read an issue** above.
|
||||||
|
|
||||||
|
## Wayfinding operations
|
||||||
|
|
||||||
|
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
|
||||||
|
|
||||||
|
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
|
||||||
|
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (see **Make an issue a sub-issue of a parent**). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
|
||||||
|
- **Blocking**: see **Blocking** under Conventions.
|
||||||
|
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (see **Count open blockers**, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
|
||||||
|
- **Claim**: `gh issue edit <n> --add-assignee @me`, the session's first write.
|
||||||
|
- **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
|
||||||
+2
-1
@@ -41,7 +41,8 @@ extensions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
exclude_patterns = []
|
# docs/agents/ holds instructions for coding agents, not user documentation.
|
||||||
|
exclude_patterns = ["agents/**"]
|
||||||
|
|
||||||
source_suffix = {
|
source_suffix = {
|
||||||
".rst": "restructuredtext",
|
".rst": "restructuredtext",
|
||||||
|
|||||||
+137
-4
@@ -77,17 +77,24 @@ the skills, start the sandbox with `AI_SANDBOX_SKILLS_RW=1`.
|
|||||||
3. **Plan before larger changes.** For anything beyond a small fix, ask the agent for a short
|
3. **Plan before larger changes.** For anything beyond a small fix, ask the agent for a short
|
||||||
design note or plan first and agree on it before code is written. Claude Code has a plan mode
|
design note or plan first and agree on it before code is written. Claude Code has a plan mode
|
||||||
(`Shift+Tab`) for this.
|
(`Shift+Tab`) for this.
|
||||||
|
The agent writes the agreed plan to `.scratch/<branch>/plan.md` (the task plan, see
|
||||||
|
[](#ai-self-review)).
|
||||||
4. **Let the agent verify its work.** The skills require the agent to add tests and to run
|
4. **Let the agent verify its work.** The skills require the agent to add tests and to run
|
||||||
pytest, ruff, mypy and the filename linter. Ask for the results if they are not reported.
|
pytest, ruff, mypy and the filename linter. Ask for the results if they are not reported.
|
||||||
|
At the end of the task the agent runs its self-review and writes the pull request body, see
|
||||||
|
[](#ai-self-review).
|
||||||
5. **Watch and review.** Open the repository in your IDE on the host. You can follow the edits
|
5. **Watch and review.** Open the repository in your IDE on the host. You can follow the edits
|
||||||
live, answer the agent's questions with full knowledge of the code, run the result yourself and
|
live, answer the agent's questions with full knowledge of the code, run the result yourself and
|
||||||
change things by hand. Review the complete diff before you open or update a pull request, as
|
change things by hand. Review the complete diff before you open or update a pull request, as
|
||||||
described in [](#ai-review-checklist). You are the author of what goes upstream.
|
described in [](#ai-review-checklist). You are the author of what goes upstream.
|
||||||
6. **Commit in small steps.** Let the agent commit each logical step with a Conventional Commits
|
6. **Commit in small steps.** Let the agent commit each logical step with a Conventional Commits
|
||||||
message (see the `commit-messages` skill), or commit yourself. Check the messages with
|
message (see the `commit-messages` skill), or commit yourself. Agent-written commits end
|
||||||
`make gitlint`.
|
with an `Assisted-by:` trailer. Check the messages with commitizen, which works on every
|
||||||
|
platform: `uv run --frozen cz check --rev-range main..HEAD`.
|
||||||
7. **Push and open the pull request from the host.** Then the sandbox needs no GitHub
|
7. **Push and open the pull request from the host.** Then the sandbox needs no GitHub
|
||||||
credentials at all. See [](#ai-sandbox-github) if you want the agent to push.
|
credentials at all. See [](#ai-sandbox-github) if you want the agent to push. Use the body the
|
||||||
|
agent wrote. Before every further push, let the agent rerun the self-review and rewrite the
|
||||||
|
body, and replace the body right after the push, see [](#ai-self-review).
|
||||||
|
|
||||||
(ai-engineering-skills)=
|
(ai-engineering-skills)=
|
||||||
|
|
||||||
@@ -145,6 +152,132 @@ the update needs `AI_SANDBOX_SKILLS_RW=1`, because `ai/skills` is read-only ther
|
|||||||
If you also installed the skills as an agent plugin, e.g. `mattpocock-skills` in Claude Code, the
|
If you also installed the skills as an agent plugin, e.g. `mattpocock-skills` in Claude Code, the
|
||||||
agent sees every skill twice. Use either the plugin or the copy in the repository.
|
agent sees every skill twice. Use either the plugin or the copy in the repository.
|
||||||
|
|
||||||
|
(ai-self-review)=
|
||||||
|
|
||||||
|
## Self-review and the pull request check
|
||||||
|
|
||||||
|
### Task plan
|
||||||
|
|
||||||
|
For a task beyond a small fix, the agent writes the plan you agreed on to
|
||||||
|
`.scratch/<branch>/plan.md`, e.g. `.scratch/feat/profile/plan.md`. `.scratch/` is ignored by git
|
||||||
|
and outlives the session, so a later session can pick up the plan. The task plan is the spec the
|
||||||
|
self-review checks the change against, unless the commits reference a GitHub issue: then the issue
|
||||||
|
is the spec.
|
||||||
|
|
||||||
|
### Self-review
|
||||||
|
|
||||||
|
When the task is done, the agent reviews its own change with the `code-review` engineering skill
|
||||||
|
against the base branch, usually `main`. The review has two separate parts:
|
||||||
|
|
||||||
|
- **Standards**: does the change follow `CONTRIBUTING.md`, `AGENTS.md` and the project skills?
|
||||||
|
- **Spec**: does it do what the task plan or the issue asked for, and nothing else?
|
||||||
|
|
||||||
|
The agent fixes every **hard violation** (a breach of a documented project rule) itself, then
|
||||||
|
reruns the checks and the self-review. It reports **judgement calls** (code smells, possible scope
|
||||||
|
creep) to you and leaves the decision to you. The self-review is advisory: it catches cheap
|
||||||
|
mistakes before you spend time on them. It does not replace your author review, and it is no
|
||||||
|
security control, because a misled agent reviews its own change (see [](#ai-review-checklist)).
|
||||||
|
|
||||||
|
### Pull request body
|
||||||
|
|
||||||
|
The agent writes the pull request body to `.scratch/<branch>/pr.md` with the `pr` engineering
|
||||||
|
skill. It follows `.github/pull_request_template.md`, which uses the skill's sections, so agents
|
||||||
|
and people write the same structure:
|
||||||
|
|
||||||
|
- `## Summary`: the smallest view that makes the change clear (pseudocode, a call or file tree, a
|
||||||
|
diff sketch or a diagram) with short text,
|
||||||
|
- `## Evidence`: before and after, e.g. a failing and a passing test run,
|
||||||
|
- `## Merge Danger`: whether the change is a one-way or two-way door, its blast radius, and
|
||||||
|
optional **Breaking:** and **Security:** lines,
|
||||||
|
- `## Checklist`: the "AI-assisted" and author-review boxes,
|
||||||
|
- `## Self-review`: the self-review declaration:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
Reviewed: 3f2a9c1e5b7d4a6f8e0c2b4d6f8a0c2e4b6d8f0a
|
||||||
|
Base: main
|
||||||
|
Standards: 0 hard, 2 judgement
|
||||||
|
Spec: 1 open (source: .scratch/feat/profile/plan.md)
|
||||||
|
|
||||||
|
- judgement: possible Feature Envy in `geneticparams.py`, left as is because ...
|
||||||
|
- spec: ...
|
||||||
|
|
||||||
|
Assisted-by: Claude Code (claude-opus-5-5)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Reviewed:` is the full SHA of the commit the review ran on. `Spec:` names the source of the spec:
|
||||||
|
an issue (`#123`), the task plan, or `none`.
|
||||||
|
|
||||||
|
Open the pull request from the host with that body. Before every further push, let the agent
|
||||||
|
rerun the self-review and rewrite `pr.md`, not just edit the SHA; replace the body right after the
|
||||||
|
push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --repo Akkudoktor-EOS/EOS --body-file .scratch/<branch>/pr.md ...
|
||||||
|
gh pr edit <number> --repo Akkudoktor-EOS/EOS --body-file .scratch/<branch>/pr.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `gh`, do it by hand on GitHub: paste the content of `pr.md` as the description when you
|
||||||
|
open the pull request. To replace it later, open the "..." menu of the pull request description,
|
||||||
|
choose "Edit", replace the whole text with the content of `pr.md` and click "Update comment".
|
||||||
|
|
||||||
|
### The pull request check
|
||||||
|
|
||||||
|
The CI workflow `ai-self-review` (`scripts/check_pr_self_review.py`) treats a pull request as
|
||||||
|
agent-assisted when
|
||||||
|
|
||||||
|
- any commit carries an `Assisted-by:` trailer (or Claude Code's
|
||||||
|
`Co-Authored-By: ... <noreply@anthropic.com>`),
|
||||||
|
- the body carries an agent marker: an `Assisted-by:` line, or a footer an agent writes on its own,
|
||||||
|
such as Claude Code's `Generated with [Claude Code](https://claude.com/claude-code)` (the check
|
||||||
|
matches the Markdown text `Generated with [Claude Code]`), or
|
||||||
|
- the "AI-assisted" box in the body is ticked.
|
||||||
|
|
||||||
|
So a pull request that an agent opened is detected even when its commits were written by hand.
|
||||||
|
Template comments (`<!-- ... -->`) never count. The check then fails only when
|
||||||
|
|
||||||
|
- the body has no `## Self-review` section, or the section has no `Reviewed:` line,
|
||||||
|
- `Reviewed:` is not the full 40-character lowercase SHA of the current head of the pull request.
|
||||||
|
|
||||||
|
Everything else is only noted, without failing the check: as a warning in the log, in the job
|
||||||
|
summary, and in the comment if the pull request already has one. A passing check never adds a new
|
||||||
|
comment because of notes. Notes are given for:
|
||||||
|
|
||||||
|
- a commit or the body carries an agent marker but the box is not ticked,
|
||||||
|
- `Base:`, `Standards:` or `Spec:` is present but malformed: the counts must be numbers and
|
||||||
|
`Spec:` needs a source, as in the format above.
|
||||||
|
|
||||||
|
Agents still write all four lines; the check requires only `Reviewed:`, because the SHA is what
|
||||||
|
binds the declaration to the code. A missing optional line is not reported.
|
||||||
|
|
||||||
|
Open findings do not fail the check. Dependabot and release pull requests are skipped.
|
||||||
|
|
||||||
|
When the check fails, the workflow posts a comment on the pull request: what is wrong, how to fix
|
||||||
|
it, and the expected format of the section. It keeps one such comment per pull request and updates
|
||||||
|
it on every run, so pushes and body edits do not add new comments; once the check passes, the
|
||||||
|
comment says so. The same report appears in the job summary of the run. Values taken from the
|
||||||
|
pull request body or the commit messages appear in the comment only inside a code block, so they
|
||||||
|
cannot mention anyone or inject Markdown. If the check script itself fails, the comment says that
|
||||||
|
the check could not run and points to the log of the workflow run; the check then fails.
|
||||||
|
|
||||||
|
Every push changes the head, so the check fails until the body names the new head. Therefore the
|
||||||
|
self-review is rerun and `pr.md` rewritten before the push, and the body is replaced right after
|
||||||
|
it. The check run started by the push sees the old body; the run started by the body edit cancels
|
||||||
|
it if it is still running, and decides the result. This is deliberate: a declaration must not
|
||||||
|
silently cover code pushed after the review.
|
||||||
|
Rebases and squashes before the merge need a fresh declaration too.
|
||||||
|
|
||||||
|
The check cannot tell a real review from a typed-in SHA, as no AI runs in CI. Like a sign-off, it
|
||||||
|
makes forgetting impossible and a false claim deliberate. Your author review and the maintainers'
|
||||||
|
review remain the controls. See the decision record
|
||||||
|
[0001](../adr/0001-self-declared-sha-bound-review-gate.md).
|
||||||
|
|
||||||
|
The workflow runs on `pull_request_target`: it uses the workflow and the script of the base
|
||||||
|
branch, never the code of the pull request, so a pull request cannot weaken its own check. It runs
|
||||||
|
only for pull requests into `main`, never on pushes to `main` or to other branches, and not for
|
||||||
|
pull requests into other branches.
|
||||||
|
|
||||||
## The AI development sandbox
|
## The AI development sandbox
|
||||||
|
|
||||||
An AI agent runs commands on your behalf, and it can be misled, for example by instructions hidden
|
An AI agent runs commands on your behalf, and it can be misled, for example by instructions hidden
|
||||||
@@ -292,7 +425,7 @@ The usual commands work:
|
|||||||
| Pre-commit hooks on committed changes | `uv run --frozen pre-commit run --from-ref main --to-ref HEAD` |
|
| Pre-commit hooks on committed changes | `uv run --frozen pre-commit run --from-ref main --to-ref HEAD` |
|
||||||
| Pre-commit hooks on changed files | `uv run --frozen pre-commit run --files <file> ...` |
|
| Pre-commit hooks on changed files | `uv run --frozen pre-commit run --files <file> ...` |
|
||||||
| Check file names | `uv run --frozen python scripts/lint_filenames.py` |
|
| Check file names | `uv run --frozen python scripts/lint_filenames.py` |
|
||||||
| Check commit messages since `main` | `make gitlint` |
|
| Check commit messages since `main` | `uv run --frozen cz check --rev-range main..HEAD` |
|
||||||
| Generate the documentation | `make gen-docs` |
|
| Generate the documentation | `make gen-docs` |
|
||||||
| Start the EOS server for development | `make run-dev` (see [](#ai-sandbox-servers)) |
|
| Start the EOS server for development | `make run-dev` (see [](#ai-sandbox-servers)) |
|
||||||
| Start EOSdash for development | `make run-dash-dev` (see [](#ai-sandbox-servers)) |
|
| Start EOSdash for development | `make run-dash-dev` (see [](#ai-sandbox-servers)) |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ develop/develop.md
|
|||||||
develop/ai.md
|
develop/ai.md
|
||||||
develop/release.md
|
develop/release.md
|
||||||
develop/async.md
|
develop/async.md
|
||||||
|
adr/index.md
|
||||||
develop/CHANGELOG.md
|
develop/CHANGELOG.md
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
#!/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())
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for the AI agent development support in ``ai/``."""
|
"""Tests for the AI agent development support in ``ai/``."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -15,6 +17,7 @@ DIR_PROJECT_ROOT = Path(__file__).parent.parent
|
|||||||
if str(DIR_PROJECT_ROOT) not in sys.path:
|
if str(DIR_PROJECT_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(DIR_PROJECT_ROOT))
|
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
|
from scripts import update_mattpocock_skills as skills_updater # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -302,3 +305,335 @@ class TestMattpocockSkills:
|
|||||||
missing_url = (tmp_path / "missing").as_uri()
|
missing_url = (tmp_path / "missing").as_uri()
|
||||||
|
|
||||||
assert skills_updater.main(["--url", missing_url, "--target", str(skills_dir)]) == 2
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user