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- [ ] who decides?\n"), [4]) def test_todo_item_is_deferred(self): found = open_questions("## Offene Fragen\n\n- [ ] TODO.tool: raise the forks\n once the server has more cores.\n" "- TODO.spec-x: later\n- [ ] who decides?\n- [ ] TODO: no topic\n", DOC, HEADINGS) self.assertEqual([(q.line, q.deferred) for q in found], [(3, True), (5, True), (6, False), (7, False)]) def test_plain_bullet_is_open(self): self.assertEqual(lines("## Open Questions\n\n- Who decides?\n1. And when?\n"), [3, 4]) def test_a_none_paragraph_is_no_open_question(self): self.assertEqual(lines("## Open Questions\n\nNone.\n\n## Offene Fragen\n\nkeine\n"), []) def test_a_none_item_is_no_open_question(self): self.assertEqual(lines("## Open Questions\n\n- None.\n"), []) def test_a_sentence_starting_with_none_stays_open(self): self.assertEqual(lines("## Open Questions\n\nNone so far, but who decides the limit?\n"), [3]) 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\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- [ ] who decides?\n", DOC, HEADINGS) self.assertEqual((found[0].path, found[0].text), (DOC, "- [ ] 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_deferred_todos_are_a_hint(self): raw = b"## Open Questions\n\n- [ ] TODO.tool: raise the forks later\n" reasons = self.reasons([gitea_file(DOC)], {DOC: raw}) self.assertEqual([(r.code, r.text, r.verdict) for r in reasons], [("pr_doc_deferred_todos", f"deferred TODOs in the PR document: " f"{DOC}:3: `- [ ] TODO.tool: raise the forks later`", Verdict.GREEN)]) 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_open_question_gives_a_comment_review(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), ("COMMENT", 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()