Files
werkrevisor/tests/test_open_questions.py
miandmhoennig cf7a31272a
werkator build succeeded after 00:08
werkrevisor 0.6.0: ORANGE for model findings, RED only for deterministic rules (#2)
werkrevisor 0.6.0: four verdict colours, and only deterministic rules block hard.

- 🟠 ORANGE: findings of the LLM and of the classifier Jev. No bot review; a developer approval after checking the findings unlocks the merge (assessment status `warning`, exit 15 where codes are distinguished, 0 in approval mode).
- 🔴 RED: only deterministic rules. First rule: open questions in a changed PR document (`[ ]`, bullet or paragraph without checkbox under "Open Questions"/"Offene Fragen").
- Fixes from the comparison on hsadmin-ng: no `FIXME.review` markers in findings, secret scan ignores `src/test/**` except real key formats, documentation files trigger no hard-YELLOW path rules.

Reason: the repository admin on dev.hostsharing.net is an operator, so an admin merge override is no way out of a false RED; model and classifier findings are often wrong (3 of 8 Sonnet findings in the comparison).

222 unit tests green in the pinned PR-Agent image.

Open: whether Gitea ignores a stale bot REQUEST_CHANGES after a later ORANGE/YELLOW head; tested on werkprobe after the rollout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #2
2026-10-05 14:46:57 +02:00

139 lines
6.7 KiB
Python

import unittest
from tests.fakes import HEAD, FakeGitea, FakeModel, config, diff_for, gitea_file
from tests.test_flow import run
from werkrevisor.rules import files_from_gitea, open_question_reasons
from werkrevisor.specdocs import open_questions
from werkrevisor.verdict import Verdict
HEADINGS = ("Open Questions", "Offene Fragen")
DOC = "doc/PR/2026-10-05-PR#42-x.md"
def lines(text):
return [q.line for q in open_questions(text, DOC, HEADINGS)]
class OpenQuestionParserTest(unittest.TestCase):
def test_done_items_pass(self):
self.assertEqual(lines("# PR\n\n## Open Questions\n\n- [x] TODO.spec: decided\n* [X] also done\n"), [])
def test_unchecked_item_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- [x] done\n- [ ] TODO.spec: who decides?\n"), [4])
def test_plain_bullet_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- Who decides?\n1. And when?\n"), [3, 4])
def test_paragraph_is_open_once(self):
self.assertEqual(lines("## Open Questions\n\nWe do not know yet\nwhat the team wants.\n\nNext one.\n"), [3, 6])
def test_empty_section_blank_lines_and_comments_pass(self):
text = "## Open Questions\n\n<!-- none yet -->\n<!--\n- [ ] in a comment\n-->\n\n## Solution\n\nText.\n"
self.assertEqual(lines(text), [])
def test_continuation_lines_of_a_done_item_pass(self):
text = ("## Offene Fragen\n\n- [x] TODO.spec: decided on 2026-10-05,\n see the discussion in the PR.\n"
"lazy continuation without indentation\n\n indented continuation after a blank line\n")
self.assertEqual(lines(text), [])
def test_unindented_paragraph_after_a_blank_line_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- [x] done\n\nA new paragraph.\n"), [5])
def test_nested_subheading_stays_in_the_section(self):
text = ("## Open Questions\n\n### Backend\n\n- [ ] open\n\n#### Detail\n\nAlso open.\n"
"## Solution\n\n- not a question\n")
self.assertEqual(lines(text), [5, 9])
def test_same_or_higher_level_ends_the_section(self):
self.assertEqual(lines("### Open Questions\n\n- [x] done\n\n### Next\n\n- free text\n"), [])
self.assertEqual(lines("### Open Questions\n\n- [x] done\n\n## Higher\n\n- free text\n"), [])
def test_any_heading_level_and_both_names(self):
for heading in ("# Open Questions", "#### Offene Fragen", "## open questions:"):
with self.subTest(heading=heading):
self.assertEqual(lines(f"{heading}\n\n- [ ] open\n"), [3])
def test_other_sections_do_not_count(self):
self.assertEqual(lines("## Solution\n\n- [ ] not a question section\n"), [])
def test_heading_in_a_code_block_does_not_start_the_section(self):
self.assertEqual(lines("## Example\n\n```\n## Open Questions\n- [ ] sample\n```\n"), [])
def test_text(self):
found = open_questions("## Open Questions\n\n- [ ] TODO.spec: who decides?\n", DOC, HEADINGS)
self.assertEqual((found[0].path, found[0].text), (DOC, "- [ ] TODO.spec: who decides?"))
def doc_bytes(items):
return ("# PR#42\n\n## Open Questions\n\n" + "".join(f"- [ ] question {i}\n" for i in range(items))).encode()
class OpenQuestionRuleTest(unittest.TestCase):
def reasons(self, files, raw):
return open_question_reasons(files_from_gitea(files), lambda path: raw[path], config().pr_doc)
def test_lists_three_items_and_counts_the_rest(self):
reasons = self.reasons([gitea_file(DOC)], {DOC: doc_bytes(5)})
self.assertEqual([(r.code, r.verdict) for r in reasons], [("pr_doc_open_questions", Verdict.RED)])
self.assertEqual(reasons[0].text, f"open questions in the PR document: {DOC}:5: `- [ ] question 0`; "
f"{DOC}:6: `- [ ] question 1`; {DOC}:7: `- [ ] question 2`; and 2 more")
def test_only_changed_pr_documents_count(self):
raw = {"doc/PR/TEMPLATE.md": doc_bytes(1), "doc/other.md": doc_bytes(1), DOC: doc_bytes(1)}
files = [gitea_file("doc/PR/TEMPLATE.md"), gitea_file("doc/other.md"), gitea_file(DOC, status="deleted")]
self.assertEqual(self.reasons(files, raw), [])
def test_binary_document_is_yellow(self):
reasons = self.reasons([gitea_file(DOC)], {DOC: b"\0\1"})
self.assertEqual([(r.code, r.verdict) for r in reasons], [("pr_doc_unreadable", Verdict.YELLOW)])
class OpenQuestionFlowTest(unittest.TestCase):
def gitea(self, raw, **kw):
gitea = FakeGitea(files=[gitea_file(DOC)], diff=diff_for(DOC, added=["x"]), **kw)
gitea.raw_files[DOC] = raw
return gitea
def test_open_question_is_red_without_model_call(self):
model = FakeModel(prompt_files=[DOC])
gitea = self.gitea(doc_bytes(1))
outcome, _ = run(gitea, model)
self.assertIs(outcome.result.verdict, Verdict.RED)
self.assertEqual(outcome.result.exit_code, 20)
self.assertEqual(model.calls, [])
self.assertEqual(gitea.statuses[0][1], "failure")
self.assertIn("## 🔴 werkrevisor: RED / CHANGES_REQUIRED", gitea.created_comments[0])
self.assertIn(f"{DOC}:5: `- [ ] question 0`", gitea.created_comments[0])
def test_red_is_not_unlocked_by_a_human_approval(self):
approval = {"state": "APPROVED", "commit_id": HEAD, "user": {"login": "alice"}}
gitea = self.gitea(doc_bytes(1), reviews=[approval])
outcome, _ = run(gitea, cfg=config(gate={"yellow_gate": "block", "human_reviewers": ["alice"]}))
self.assertEqual(outcome.result.exit_code, 20)
def test_red_requests_changes_in_approval_mode(self):
gitea = self.gitea(doc_bytes(1))
outcome, _ = run(gitea, cfg=config(gate={"yellow_gate": "approval", "human_reviewers": ["alice"]}))
self.assertEqual(outcome.result.exit_code, 20)
event, commit_id, body = gitea.created_reviews[0]
self.assertEqual((event, commit_id), ("REQUEST_CHANGES", HEAD))
self.assertIn(f"{DOC}:5: `- [ ] question 0`", body)
self.assertEqual(gitea.requested, [])
def test_done_questions_get_the_normal_review(self):
model = FakeModel(prompt_files=[DOC])
outcome, _ = run(self.gitea(b"# PR#42\n\n## Open Questions\n\n- [x] decided\n"), model)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertEqual(len(model.calls), 1)
def test_pr_document_not_touched_by_the_pr_is_not_checked(self):
gitea = FakeGitea(files=[gitea_file("README.md")], diff=diff_for("README.md", added=["x"]))
gitea.raw_files[DOC] = doc_bytes(1)
outcome, _ = run(gitea, FakeModel(prompt_files=["README.md"]))
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertNotIn(DOC, [path for path, _ in gitea.raw_requests])
if __name__ == "__main__":
unittest.main()