diff options
Diffstat (limited to 'scripts/pr_quality/tests/test_check_pr.py')
| -rw-r--r-- | scripts/pr_quality/tests/test_check_pr.py | 968 |
1 files changed, 968 insertions, 0 deletions
diff --git a/scripts/pr_quality/tests/test_check_pr.py b/scripts/pr_quality/tests/test_check_pr.py new file mode 100644 index 0000000000..f238ebb0c6 --- /dev/null +++ b/scripts/pr_quality/tests/test_check_pr.py @@ -0,0 +1,968 @@ +"""Tests for the PR quality checks in check_pr.py.""" + +import json +import logging +import tempfile +import unittest +import urllib.error +from pathlib import Path +from unittest import mock + +from pr_quality import check_pr +from pr_quality.errors import LEVEL_ERROR, LEVEL_WARNING, MISSING_TRAC_TICKET, Message + +logger = logging.getLogger("pr_quality.check_pr") + +UNSET = object() +ERROR_ICON, ERROR_LABEL = LEVEL_ERROR +WARNING_ICON, WARNING_LABEL = LEVEL_WARNING + + +def make_pr_body( + ticket="ticket-36991", + description=( + "Fix regression in template rendering where nested blocks were" + " incorrectly evaluated." + ), + no_ai_checked=True, + ai_used_checked=False, + ai_description="", + checked_items=5, +): + no_ai_box = "[x]" if no_ai_checked else "[ ]" + ai_used_box = "[x]" if ai_used_checked else "[ ]" + ai_extra = f"\n{ai_description}" if ai_description else "" + + checklist_items = [ + "This PR follows the [contribution guidelines]" + "(https://docs.djangoproject.com/en/stable/internals/contributing/" + "writing-code/submitting-patches/).", + "This PR **does not** disclose a security vulnerability" + " (see [vulnerability reporting]" + "(https://docs.djangoproject.com/en/stable/internals/security/)).", + "This PR targets the `main` branch." + " <!-- Backports will be evaluated and done by mergers, when necessary. -->", + "The commit message is written in past tense, mentions the ticket" + " number, and ends with a period (see [guidelines]" + "(https://docs.djangoproject.com/en/dev/internals/contributing/" + "committing-code/#committing-guidelines)).", + "I have not requested, and will not request, an automated AI review" + " for this PR." + " <!-- You are welcome to do so in your own fork. -->", + 'I have checked the "Has patch" ticket flag in the Trac system.', + "I have added or updated relevant tests.", + "I have added or updated relevant docs, including release notes if" + " applicable.", + "I have attached screenshots in both light and dark modes for any UI" + " changes.", + ] + checklist_lines = "\n".join( + f"- [x] {item}" if i < checked_items else f"- [ ] {item}" + for i, item in enumerate(checklist_items) + ) + + # GitHub PR bodies are plain markdown with no leading spaces. + return ( + f"#### Trac ticket number\n" + f"<!-- Replace XXXXX with the corresponding Trac ticket number. -->\n" + f'<!-- Or delete the line and write "N/A - typo" for typo fixes. -->\n' + f"\n" + f"{ticket}\n" + f"\n" + f"#### Branch description\n" + f"{description}\n" + f"\n" + f"#### AI Assistance Disclosure (REQUIRED)\n" + f"<!-- Please select exactly ONE of the following: -->\n" + f"- {no_ai_box} **No AI tools were used** in preparing this PR.\n" + f"- {ai_used_box} **If AI tools were used**, I have disclosed which" + f" ones, and fully reviewed and verified their output.{ai_extra}\n" + f"\n" + f"#### Checklist\n" + f"{checklist_lines}\n" + ) + + +VALID_PR_BODY = make_pr_body() +VALID_PR_TITLE = "Fixed #36991 -- Fix template rendering regression." + + +def make_trac_json( + ticket_id="36991", + stage="Accepted", + status="assigned", + resolution="", + has_patch="0", + needs_docs="0", + needs_tests="0", + needs_better_patch="0", +): + return { + "id": int(ticket_id), + "summary": "Some summary", + "status": status, + "resolution": resolution, + "custom": { + "stage": stage, + "has_patch": has_patch, + "needs_docs": needs_docs, + "needs_tests": needs_tests, + "needs_better_patch": needs_better_patch, + }, + } + + +def patch_urlopen(json_responses=(), status_code=200): + side_effects = [] + if status_code == 200: + for data in json_responses: + mock_response = mock.MagicMock() + mock_response.read.return_value = json.dumps(data).encode() + mock_cm = mock.MagicMock() + mock_cm.__enter__.return_value = mock_response + side_effects.append(mock_cm) + else: + error = urllib.error.HTTPError( + url="https://example.com", + code=status_code, + msg="Error", + hdrs=None, + fp=None, + ) + side_effects.append(error) + return mock.patch("urllib.request.urlopen", side_effect=side_effects) + + +class BaseTestCase(unittest.TestCase): + def setUp(self): + null_handler = logging.NullHandler() + logger.addHandler(null_handler) + self.addCleanup(logger.removeHandler, null_handler) + + def call_main( + self, + repo="test/repo", + token="test-token", + pr_author="trusted", + pr_body="", + pr_title="", + pr_created_at=None, + pr_number="10", + total_changes=check_pr.LARGE_PR_THRESHOLD, + commit_count=0, + autoclose=True, + trac_data=UNSET, + ): + if trac_data is UNSET: + trac_data = make_trac_json(stage="Accepted", has_patch="1") + with ( + mock.patch.object( + check_pr, "get_pr_total_changes", return_value=total_changes + ), + mock.patch.object( + check_pr, "get_recent_commit_count", return_value=commit_count + ), + mock.patch.object(check_pr, "write_job_summary") as mock_summary, + mock.patch.object(check_pr, "github_request", mock.MagicMock()) as mock_gh, + mock.patch.object(check_pr, "fetch_trac_ticket", return_value=trac_data), + ): + result = check_pr.main( + repo=repo, + token=token, + pr_author=pr_author, + pr_body=pr_body, + pr_title=pr_title, + pr_number=pr_number, + pr_created_at=pr_created_at, + autoclose=autoclose, + gha_formatter=False, + ) + return result, mock_summary, mock_gh + + +class TestExtractTicketId(BaseTestCase): + def test_valid_ticket_returns_id(self): + self.assertEqual(check_pr.extract_ticket_id("ticket-36991"), "36991") + + def test_ticket_in_sentence_returns_id(self): + self.assertEqual( + check_pr.extract_ticket_id("See ticket-12345 for details."), "12345" + ) + + def test_case_insensitive(self): + self.assertEqual(check_pr.extract_ticket_id("TICKET-99"), "99") + + def test_no_ticket_returns_none(self): + self.assertIsNone(check_pr.extract_ticket_id("No ticket here.")) + + def test_na_returns_none(self): + self.assertIsNone(check_pr.extract_ticket_id("N/A - typo fix")) + + def test_ticket_placeholder_returns_none(self): + self.assertIsNone(check_pr.extract_ticket_id("ticket-XXXXX")) + + def test_returns_first_ticket_id(self): + self.assertEqual(check_pr.extract_ticket_id("ticket-111 and ticket-222"), "111") + + +class TestFetchTracTicket(BaseTestCase): + def test_success_returns_dict(self): + data = make_trac_json(stage="Accepted", has_patch="1") + with patch_urlopen([data]): + result = check_pr.fetch_trac_ticket("36991") + self.assertEqual(result["custom"]["stage"], "Accepted") + self.assertEqual(result["custom"]["has_patch"], "1") + + def test_http_404_returns_ticket_not_found(self): + with patch_urlopen(status_code=404): + result = check_pr.fetch_trac_ticket("99999") + self.assertIs(result, check_pr.TICKET_NOT_FOUND) + + def test_http_500_returns_none(self): + with patch_urlopen(status_code=500): + result = check_pr.fetch_trac_ticket("36991") + self.assertIsNone(result) + + def test_network_error_returns_none(self): + with mock.patch( + "urllib.request.urlopen", side_effect=OSError("Connection refused") + ): + result = check_pr.fetch_trac_ticket("36991") + self.assertIsNone(result) + + def test_uses_trac_api_url(self): + data = make_trac_json() + with patch_urlopen([data]) as mock_urlopen: + check_pr.fetch_trac_ticket("36991") + url_called = mock_urlopen.call_args.args[0] + self.assertIn("djangoproject.com/trac/api/tickets/36991", url_called) + + +class TestCheckTracTicket(BaseTestCase): + def test_valid_ticket_small_pr_passes(self): + self.assertIsNone( + check_pr.check_trac_ticket(VALID_PR_BODY, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_valid_ticket_large_pr_passes(self): + self.assertIsNone( + check_pr.check_trac_ticket(VALID_PR_BODY, check_pr.LARGE_PR_THRESHOLD) + ) + + def test_placeholder_fails(self): + body = make_pr_body(ticket="ticket-XXXXX") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_missing_small_pr_fails(self): + body = make_pr_body(ticket="") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_missing_large_pr_fails(self): + body = make_pr_body(ticket="") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD) + ) + + def test_na_small_pr_passes(self): + body = make_pr_body(ticket="N/A - typo") + self.assertIsNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_na_bare_small_pr_passes(self): + body = make_pr_body(ticket="N/A") + self.assertIsNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_na_large_pr_fails(self): + body = make_pr_body(ticket="N/A - typo") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD) + ) + + def test_na_at_threshold_fails(self): + body = make_pr_body(ticket="N/A") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD) + ) + + def test_na_just_below_threshold_passes(self): + body = make_pr_body(ticket="N/A") + self.assertIsNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_ticket_na_format_fails(self): + body = make_pr_body(ticket="ticket-N/A") + self.assertIsNotNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD - 1) + ) + + def test_various_ticket_lengths_pass(self): + for ticket in ["ticket-1", "ticket-123", "ticket-999999"]: + with self.subTest(ticket=ticket): + body = make_pr_body(ticket=ticket) + self.assertIsNone( + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD) + ) + + +class TestCheckTracStatus(BaseTestCase): + def test_accepted_assigned_unresolved_passes(self): + # The Trac API returns null (None) for open tickets. + data = make_trac_json(stage="Accepted", status="assigned", resolution=None) + self.assertIsNone(check_pr.check_trac_status("36991", data)) + + def test_non_accepted_stage_fails(self): + for stage in ["Unreviewed", "Ready for Checkin", "Someday/Maybe"]: + with self.subTest(stage=stage): + data = make_trac_json(stage=stage) + self.assertIsNotNone(check_pr.check_trac_status("36991", data)) + + def test_resolved_ticket_fails(self): + for resolution in [ + "fixed", + "wontfix", + "duplicate", + "invalid", + "worksforme", + "needsinfo", + "needsnewfeatureprocess", + ]: + with self.subTest(resolution=resolution): + data = make_trac_json( + stage="Accepted", status="assigned", resolution=resolution + ) + self.assertIsNotNone(check_pr.check_trac_status("36991", data)) + + def test_unassigned_ticket_fails(self): + for status in ["new", "closed", ""]: + with self.subTest(status=status): + data = make_trac_json(stage="Accepted", status=status, resolution="") + self.assertIsNotNone(check_pr.check_trac_status("36991", data)) + + def test_failure_message_contains_ticket_id(self): + data = make_trac_json(stage="Unreviewed") + result = check_pr.check_trac_status("12345", data) + self.assertIsInstance(result, Message) + self.assertEqual(result.kwargs["ticket_id"], "12345") + + def test_failure_message_contains_current_state(self): + data = make_trac_json(stage="Unreviewed", status="new", resolution="duplicate") + result = check_pr.check_trac_status("36991", data) + self.assertIsInstance(result, Message) + self.assertIn("stage='Unreviewed'", result.kwargs["current_state"]) + self.assertIn("resolution='duplicate'", result.kwargs["current_state"]) + self.assertIn("status='new'", result.kwargs["current_state"]) + + def test_ticket_not_found_current_state(self): + result = check_pr.check_trac_status("99999", check_pr.TICKET_NOT_FOUND) + self.assertIsInstance(result, Message) + self.assertIn("not found", result.kwargs["current_state"]) + + def test_none_data_skips_check(self): + self.assertIsNone(check_pr.check_trac_status("36991", None)) + + +class TestCheckTracHasPatch(BaseTestCase): + def test_already_set_passes(self): + data = make_trac_json(has_patch="1") + self.assertIsNone(check_pr.check_trac_has_patch("36991", data)) + + def test_not_set_times_out_fails(self): + data = make_trac_json(has_patch="0") + result = check_pr.check_trac_has_patch( + "36991", data, poll_timeout=0, poll_interval=0 + ) + self.assertIsNotNone(result) + + def test_failure_message_contains_ticket_id(self): + data = make_trac_json(ticket_id="36991", has_patch="0") + result = check_pr.check_trac_has_patch( + "36991", data, poll_timeout=0, poll_interval=0 + ) + self.assertIsInstance(result, Message) + self.assertEqual(result.kwargs["ticket_id"], "36991") + + def test_set_on_second_poll_passes(self): + initial = make_trac_json(has_patch="0") + second = make_trac_json(has_patch="1") + with ( + mock.patch.object(check_pr, "fetch_trac_ticket", return_value=second), + mock.patch("time.sleep"), + ): + self.assertIsNone( + check_pr.check_trac_has_patch( + "36991", initial, poll_timeout=60, poll_interval=0 + ) + ) + + def test_none_data_skips_check(self): + self.assertIsNone(check_pr.check_trac_has_patch("36991", None)) + + def test_ticket_not_found_skips_check(self): + # Ticket not found -- already reported by check_trac_status. + self.assertIsNone( + check_pr.check_trac_has_patch("36991", check_pr.TICKET_NOT_FOUND) + ) + + def test_fetch_error_during_poll_skips_check(self): + initial = make_trac_json(has_patch="0") + with ( + mock.patch.object(check_pr, "fetch_trac_ticket", return_value=None), + mock.patch("time.sleep"), + ): + self.assertIsNone( + check_pr.check_trac_has_patch( + "36991", initial, poll_timeout=60, poll_interval=0 + ) + ) + + +class TestCheckPrTitleHasTicket(BaseTestCase): + def test_ticket_in_title_passes(self): + self.assertIsNone( + check_pr.check_pr_title_has_ticket("Fixed #36991 -- Fix bug.", "36991") + ) + + def test_refs_format_passes(self): + self.assertIsNone( + check_pr.check_pr_title_has_ticket("Refs #36991 -- Add tests.", "36991") + ) + + def test_ticket_missing_from_title_fails(self): + self.assertIsNotNone( + check_pr.check_pr_title_has_ticket("Fix template rendering bug.", "36991") + ) + + def test_empty_title_fails(self): + self.assertIsNotNone(check_pr.check_pr_title_has_ticket("", "36991")) + + def test_wrong_ticket_number_fails(self): + self.assertIsNotNone( + check_pr.check_pr_title_has_ticket("Fixed #11111 -- Fix bug.", "36991") + ) + + def test_failure_message_contains_ticket_id(self): + result = check_pr.check_pr_title_has_ticket("Fix something.", "36991") + self.assertIsInstance(result, Message) + self.assertEqual(result.kwargs["ticket_id"], "36991") + + +class TestCheckBranchDescription(BaseTestCase): + def test_valid_passes(self): + self.assertIsNone(check_pr.check_branch_description(VALID_PR_BODY)) + + def test_placeholder_fails(self): + body = make_pr_body( + description=( + "Provide a concise overview of the issue or rationale behind" + " the proposed changes." + ) + ) + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_placeholder_with_appended_text_fails(self): + body = make_pr_body( + description=( + "Provide a concise overview of the issue or rationale behind" + " the proposed changes. Yes." + ) + ) + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_empty_fails(self): + body = make_pr_body(description="") + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_too_short_fails(self): + body = make_pr_body(description="Fix bug.") + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_exactly_five_words_passes(self): + body = make_pr_body(description="Fix the template rendering bug.") + self.assertIsNone(check_pr.check_branch_description(body)) + + def test_html_comment_only_fails(self): + body = make_pr_body( + description="<!-- Provide a concise overview of the issue -->" + ) + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_html_comment_words_not_counted(self): + body = make_pr_body(description="<!-- this has five words --> fix") + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_missing_section_header_fails(self): + body = VALID_PR_BODY.replace("#### Branch description\n", "") + self.assertIsNotNone(check_pr.check_branch_description(body)) + + def test_multiline_passes(self): + body = make_pr_body( + description=( + "This PR fixes a bug in the ORM.\n" + "The issue affects queries with multiple joins." + ) + ) + self.assertIsNone(check_pr.check_branch_description(body)) + + def test_crlf_line_endings_pass(self): + body = make_pr_body().replace("\n", "\r\n") + self.assertIsNone(check_pr.check_branch_description(body)) + + +class TestCheckAIDisclosure(BaseTestCase): + def test_no_ai_checked_passes(self): + self.assertIsNone(check_pr.check_ai_disclosure(VALID_PR_BODY)) + + def test_ai_used_with_description_passes(self): + body = make_pr_body( + no_ai_checked=False, + ai_used_checked=True, + ai_description=( + "Used GitHub Copilot for autocomplete, all output manually reviewed." + ), + ) + self.assertIsNone(check_pr.check_ai_disclosure(body)) + + def test_neither_option_checked_fails(self): + body = make_pr_body(no_ai_checked=False, ai_used_checked=False) + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + def test_both_options_checked_fails(self): + body = make_pr_body(no_ai_checked=True, ai_used_checked=True) + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + def test_ai_used_no_description_fails(self): + body = make_pr_body( + no_ai_checked=False, ai_used_checked=True, ai_description="" + ) + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + def test_ai_used_short_description_fails(self): + body = make_pr_body( + no_ai_checked=False, ai_used_checked=True, ai_description="Used Copilot." + ) + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + def test_ai_used_exactly_five_word_description_passes(self): + body = make_pr_body( + no_ai_checked=False, + ai_used_checked=True, + ai_description="Used Claude for code review.", + ) + self.assertIsNone(check_pr.check_ai_disclosure(body)) + + def test_missing_section_fails(self): + body = VALID_PR_BODY.replace("#### AI Assistance Disclosure (REQUIRED)\n", "") + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + def test_uppercase_x_in_checkbox_passes(self): + body = VALID_PR_BODY.replace( + "- [x] **No AI tools were used**", "- [X] **No AI tools were used**" + ) + self.assertIsNone(check_pr.check_ai_disclosure(body)) + + def test_commented_out_checkbox_not_counted(self): + body = make_pr_body(no_ai_checked=False, ai_used_checked=False) + body = body.replace( + "- [ ] **No AI tools were used**", + "<!-- - [x] No AI tools were used -->\n- [ ] **No AI tools were used**", + ) + self.assertIsNotNone(check_pr.check_ai_disclosure(body)) + + +class TestStripHtmlComments(BaseTestCase): + def test_removes_single_line_comment(self): + self.assertEqual( + check_pr.strip_html_comments("hello <!-- comment --> world"), "hello world" + ) + + def test_removes_multiline_comment(self): + self.assertEqual( + check_pr.strip_html_comments("before\n<!-- line one\nline two\n-->\nafter"), + "before\n\nafter", + ) + + def test_no_comment_unchanged(self): + self.assertEqual( + check_pr.strip_html_comments("no comments here"), "no comments here" + ) + + def test_empty_string(self): + self.assertEqual(check_pr.strip_html_comments(""), "") + + def test_removes_multiple_comments(self): + self.assertEqual( + check_pr.strip_html_comments("a <!-- x --> b <!-- y --> c"), + "a b c", + ) + + +class TestCheckChecklist(BaseTestCase): + def test_first_five_checked_passes(self): + self.assertIsNone(check_pr.check_checklist(VALID_PR_BODY)) + + def test_all_nine_checked_passes(self): + body = make_pr_body(checked_items=9) + self.assertIsNone(check_pr.check_checklist(body)) + + def test_none_checked_fails(self): + body = make_pr_body(checked_items=0) + self.assertIsNotNone(check_pr.check_checklist(body)) + + def test_four_of_five_checked_fails(self): + body = make_pr_body(checked_items=4) + self.assertIsNotNone(check_pr.check_checklist(body)) + + def test_three_of_five_checked_fails(self): + body = make_pr_body(checked_items=3) + self.assertIsNotNone(check_pr.check_checklist(body)) + + def test_missing_section_fails(self): + body = VALID_PR_BODY.replace("#### Checklist\n", "") + self.assertIsNotNone(check_pr.check_checklist(body)) + + def test_uppercase_x_passes(self): + body = VALID_PR_BODY.replace("- [x]", "- [X]") + self.assertIsNone(check_pr.check_checklist(body)) + + def test_crlf_line_endings_pass(self): + body = make_pr_body().replace("\n", "\r\n") + self.assertIsNone(check_pr.check_checklist(body)) + + +class TestIntegration(BaseTestCase): + def test_fully_valid_pr_passes_all_checks(self): + data = make_trac_json(stage="Accepted", has_patch="1") + for label, body in [ + ("LF line endings", VALID_PR_BODY), + ("CRLF line endings", VALID_PR_BODY.replace("\n", "\r\n")), + ]: + with self.subTest(label=label): + ticket_id = check_pr.extract_ticket_id(body) + results = [ + check_pr.check_trac_ticket(body, check_pr.LARGE_PR_THRESHOLD), + check_pr.check_trac_status(ticket_id, data), + check_pr.check_trac_has_patch(ticket_id, data), + check_pr.check_pr_title_has_ticket(VALID_PR_TITLE, ticket_id), + check_pr.check_branch_description(body), + check_pr.check_ai_disclosure(body), + check_pr.check_checklist(body), + ] + failures = [r for r in results if r is not None] + self.assertEqual( + failures, + [], + f"Expected no failures ({label}), got: {failures!r}", + ) + + def test_blank_body_fails_non_status_checks(self): + results = [ + check_pr.check_trac_ticket("", check_pr.LARGE_PR_THRESHOLD), + check_pr.check_branch_description(""), + check_pr.check_ai_disclosure(""), + check_pr.check_checklist(""), + ] + for i, result in enumerate(results, 1): + self.assertIsNotNone(result, f"Check {i} should have failed on empty body") + + def test_make_pr_body_matches_template(self): + template_path = ( + Path(__file__).parents[3] / ".github" / "pull_request_template.md" + ) + with open(template_path) as f: + raw_template = f.read() + blank_body = make_pr_body( + ticket="ticket-XXXXX", + description=( + "Provide a concise overview of the issue or rationale behind" + " the proposed changes." + ), + no_ai_checked=False, + ai_used_checked=False, + checked_items=0, + ) + self.assertEqual(blank_body, raw_template) + + def test_unedited_template_fails_all_checks(self): + template_path = ( + Path(__file__).parents[3] / ".github" / "pull_request_template.md" + ) + with open(template_path) as f: + raw_template = f.read() + results = [ + check_pr.check_trac_ticket(raw_template, check_pr.LARGE_PR_THRESHOLD), + check_pr.check_branch_description(raw_template), + check_pr.check_ai_disclosure(raw_template), + check_pr.check_checklist(raw_template), + ] + for i, result in enumerate(results, 1): + self.assertIsNotNone( + result, f"Check {i} should have failed on raw template" + ) + + def test_pr_before_template_date_skips_all_checks(self): + with self.assertLogs(logger, level="INFO") as logs: + result, _, mock_gh = self.call_main( + pr_body=make_pr_body(ticket="", checked_items=0), + pr_created_at="2024-01-01T00:00:00Z", + ) + self.assertIsNone(result) + mock_gh.assert_not_called() + self.assertIn("older than PR template", "\n".join(logs.output)) + + def test_pr_before_ai_disclosure_date_skips_ai_check(self): + body = make_pr_body(no_ai_checked=False, ai_used_checked=False) + with self.assertLogs(logger, level="INFO") as logs: + _, mock_summary, _ = self.call_main( + pr_body=body, + pr_title=VALID_PR_TITLE, + pr_created_at="2025-01-01T00:00:00Z", + ) + _, results, _ = mock_summary.call_args.args + result_map = {name: result for name, result, _ in results} + self.assertIs(result_map["AI disclosure completed"], check_pr.SKIPPED) + self.assertIn("older than AI Disclosure", "\n".join(logs.output)) + + def test_no_ticket_skips_trac_status_and_has_patch(self): + with self.assertLogs(logger, level="INFO") as logs: + self.call_main(pr_body="") + self.assertIn( + "No Trac ticket -- skipping status and has_patch checks.", + "\n".join(logs.output), + ) + + def test_no_ticket_results_include_skipped_sentinels(self): + body = make_pr_body(ticket="") + _, mock_summary, _ = self.call_main(pr_body=body) + _, results, _ = mock_summary.call_args.args + result_map = {name: result for name, result, _ in results} + self.assertIs(result_map["Trac ticket status is Accepted"], check_pr.SKIPPED) + self.assertIs(result_map["Trac ticket has_patch flag set"], check_pr.SKIPPED) + self.assertIs(result_map["PR title includes ticket number"], check_pr.SKIPPED) + + def test_non_accepted_ticket_skips_has_patch(self): + data = make_trac_json(stage="Unreviewed") + _, mock_summary, _ = self.call_main( + pr_body=VALID_PR_BODY, pr_title=VALID_PR_TITLE, trac_data=data + ) + _, results, _ = mock_summary.call_args.args + result_map = {name: result for name, result, _ in results} + self.assertIsNotNone(result_map["Trac ticket status is Accepted"]) + self.assertIs(result_map["Trac ticket has_patch flag set"], check_pr.SKIPPED) + + def test_established_author_skips_all_checks(self): + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body, commit_count=5) + self.assertIsNone(result) + mock_gh.assert_not_called() + + def test_fully_valid_pr_no_comment_posted(self): + result, _, mock_gh = self.call_main( + pr_body=VALID_PR_BODY, pr_title=VALID_PR_TITLE + ) + self.assertIsNone(result) + mock_gh.assert_not_called() + + def test_trusted_author_failures_no_close(self): + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body, commit_count=1) + self.assertEqual(result, 1) + mock_gh.assert_called_once_with( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ) + + def test_untrusted_author_failures_posts_comment_and_closes(self): + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body) + self.assertEqual(result, 1) + self.assertEqual( + mock_gh.mock_calls, + [ + mock.call( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ), + mock.call( + "PATCH", "/pulls/10", "test-token", "test/repo", {"state": "closed"} + ), + ], + ) + + def test_missing_pr_author_treated_as_untrusted(self): + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body, pr_author="") + self.assertEqual(result, 1) + self.assertEqual( + mock_gh.mock_calls, + [ + mock.call( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ), + mock.call( + "PATCH", "/pulls/10", "test-token", "test/repo", {"state": "closed"} + ), + ], + ) + + def test_autoclose_false_posts_comment_does_not_close(self): + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body, autoclose=False) + self.assertEqual(result, 1) + mock_gh.assert_called_once_with( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ) + + def test_warnings_only_posts_comment_does_not_close(self): + trac_data = make_trac_json(stage="Accepted", has_patch="0") + # pr_title="" triggers title warning. + # has_patch="0" triggers has_patch warning. + result, _, mock_gh = self.call_main( + pr_body=VALID_PR_BODY, pr_title="", trac_data=trac_data + ) + self.assertIsNone(result) + mock_gh.assert_called_once_with( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ) + + def test_warnings_alongside_failures_still_closes(self): + trac_data = make_trac_json(stage="Accepted", has_patch="0") + # Empty body: fatal ticket failure + incorrect title warning. + body = make_pr_body(ticket="", checked_items=0) + result, _, mock_gh = self.call_main(pr_body=body, trac_data=trac_data) + self.assertEqual(result, 1) + self.assertEqual( + mock_gh.mock_calls, + [ + mock.call( + "POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY + ), + mock.call( + "PATCH", "/pulls/10", "test-token", "test/repo", {"state": "closed"} + ), + ], + ) + + +class TestWriteJobSummary(BaseTestCase): + def test_no_summary_file_does_nothing(self): + check_pr.write_job_summary( + "99", + [ + ("Some check", None, LEVEL_ERROR), + ("Other check", "failure", LEVEL_ERROR), + ], + ) + + def test_all_passed(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary = Path(tmpdir) / "summary.md" + results = [ + ("Trac ticket referenced", None, LEVEL_ERROR), + ("Branch description provided", None, LEVEL_ERROR), + ] + check_pr.write_job_summary("7", results, str(summary)) + content = summary.read_text() + self.assertIn("## PR #7 Quality Check Results", content) + self.assertIn("✅", content) + self.assertNotIn(ERROR_ICON, content) + self.assertIn("Trac ticket referenced", content) + self.assertIn("Branch description provided", content) + + def test_with_failures(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary = Path(tmpdir) / "summary.md" + results = [ + ("Trac ticket referenced", None, LEVEL_ERROR), + ("Branch description provided", "Missing description", LEVEL_ERROR), + ("Checklist completed", "Incomplete checklist", LEVEL_ERROR), + ] + check_pr.write_job_summary("12", results, str(summary)) + content = summary.read_text() + self.assertIn("## PR #12 Quality Check Results", content) + self.assertEqual(content.count("✅"), 1) + self.assertEqual(content.count(ERROR_ICON), 2) + self.assertIn("Passed", content) + self.assertIn(ERROR_LABEL, content) + + def test_with_warning(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary = Path(tmpdir) / "summary.md" + results = [ + ("Trac ticket referenced", None, LEVEL_ERROR), + ("Trac ticket has_patch flag set", "No patch", LEVEL_WARNING), + ] + check_pr.write_job_summary("8", results, str(summary)) + content = summary.read_text() + self.assertIn("✅", content) + self.assertIn(WARNING_ICON, content) + self.assertIn(WARNING_LABEL, content) + self.assertNotIn(ERROR_ICON, content) + + def test_appends_to_existing_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary = Path(tmpdir) / "summary.md" + summary.write_text("## Previous step\n\nSome content.\n") + check_pr.write_job_summary( + "5", [("Checklist completed", None, LEVEL_ERROR)], str(summary) + ) + content = summary.read_text() + self.assertIn("## Previous step", content) + self.assertIn("## PR #5 Quality Check Results", content) + + def test_skipped_checks(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary = Path(tmpdir) / "summary.md" + results = [ + ( + "Trac ticket referenced", + Message(*MISSING_TRAC_TICKET, threshold=80), + LEVEL_ERROR, + ), + ("Trac ticket status is Accepted", check_pr.SKIPPED, LEVEL_ERROR), + ("Trac ticket has_patch flag set", check_pr.SKIPPED, LEVEL_WARNING), + ] + check_pr.write_job_summary("3", results, str(summary)) + content = summary.read_text() + self.assertEqual(content.count("⏭️"), 2) + self.assertEqual(content.count("Skipped"), 2) + self.assertIn(ERROR_ICON, content) + + +class TestGetRecentCommitCount(BaseTestCase): + def _make_github_request_mock(self, commits): + return mock.patch.object(check_pr, "github_request", return_value=commits) + + def test_returns_number_of_commits(self): + commits = [{"sha": "abc"}, {"sha": "def"}, {"sha": "ghi"}] + with self._make_github_request_mock(commits): + self.assertEqual( + check_pr.get_recent_commit_count( + "someuser", "django/django", "token", since_days=365, max_count=5 + ), + 3, + ) + + def test_no_commits_returns_zero(self): + with self._make_github_request_mock([]): + self.assertEqual( + check_pr.get_recent_commit_count( + "newuser", "django/django", "token", since_days=365, max_count=5 + ), + 0, + ) + + def test_api_path_includes_author_since_and_max_count(self): + with mock.patch.object(check_pr, "github_request", return_value=[]) as mock_gh: + check_pr.get_recent_commit_count( + "someuser", "django/django", "token", since_days=365, max_count=5 + ) + params = mock_gh.call_args.kwargs["params"] + self.assertEqual(params["author"], "someuser") + self.assertIn("since", params) + self.assertEqual(params["per_page"], 5) |
