diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..2c5af329 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper scripts for MergeWork maintenance checks.""" diff --git a/scripts/bounty_refs.py b/scripts/bounty_refs.py new file mode 100644 index 00000000..204d3440 --- /dev/null +++ b/scripts/bounty_refs.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import re + +LINKED_BOUNTY_VERBS = r"bounty|claims?|close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?" +BOUNTY_REF_RE = re.compile( + rf"\b(?:{LINKED_BOUNTY_VERBS})\s+#(\d+)(?![A-Za-z0-9_-])", + re.IGNORECASE, +) diff --git a/scripts/pr_queue_health.py b/scripts/pr_queue_health.py index e96ed86d..1ba58e06 100644 --- a/scripts/pr_queue_health.py +++ b/scripts/pr_queue_health.py @@ -6,9 +6,14 @@ import subprocess import sys from collections import defaultdict +from pathlib import Path from typing import Any -BOUNTY_REF_RE = re.compile(r"\b(?:bounty|refs?|fixes|closes|claims?)\s+#(\d+)", re.IGNORECASE) +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.bounty_refs import BOUNTY_REF_RE + NOISY_TITLE_PREFIX_RE = re.compile(r"^\s*(?:\[[^\]]+\]\s*)+") UNSTABLE_MERGE_STATES = {"blocked", "conflicting", "dirty", "unknown", "unstable"} GH_TIMEOUT_SECONDS = 30 @@ -116,7 +121,8 @@ def analyze_queue(data: dict[str, Any]) -> dict[str, Any]: _issue( pr, "missing_bounty_reference", - "No Bounty #, Refs #, or /claim # found", + "No bounty reference such as Bounty #, Refs #, " + "Fixes #, or /claim # found", ) ) for ref in pr["refs"]: diff --git a/scripts/submission_quality_gate.py b/scripts/submission_quality_gate.py index 6abca939..285c83f5 100644 --- a/scripts/submission_quality_gate.py +++ b/scripts/submission_quality_gate.py @@ -7,11 +7,16 @@ import sys from datetime import UTC, datetime, timedelta from difflib import SequenceMatcher +from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.request import urlopen -BOUNTY_REF_RE = re.compile(r"\b(?:bounty|refs?|fixes|closes|claims?)\s+#(\d+)", re.IGNORECASE) +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.bounty_refs import BOUNTY_REF_RE + EVIDENCE_RE = re.compile( r"\b(pytest|ruff|mypy|validation|verified|test evidence|checks? passed)\b", re.IGNORECASE, @@ -220,7 +225,8 @@ def evaluate_submission(data: dict[str, Any]) -> dict[str, Any]: _check( "bounty_reference", "fail", - "submission text must include Bounty #, Refs #, or /claim #", + "submission text must include a bounty reference such as " + "Bounty #, Refs #, Fixes #, or /claim #", ) ) else: diff --git a/tests/test_pr_queue_health.py b/tests/test_pr_queue_health.py index 58ff8fbe..274f066f 100644 --- a/tests/test_pr_queue_health.py +++ b/tests/test_pr_queue_health.py @@ -2,12 +2,16 @@ import json import subprocess +import sys +from pathlib import Path import pytest from scripts import pr_queue_health from scripts.pr_queue_health import analyze_queue, format_markdown_report, format_text_report, main +ROOT = Path(__file__).resolve().parents[1] + def test_pr_queue_health_flags_required_queue_cases(tmp_path, capsys) -> None: fixture = { @@ -84,6 +88,19 @@ def test_pr_queue_health_flags_required_queue_cases(tmp_path, capsys) -> None: assert output["summary"]["pull_requests"] == 4 +def test_pr_queue_health_script_entrypoint_loads_shared_parser() -> None: + result = subprocess.run( + [sys.executable, "scripts/pr_queue_health.py", "--help"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "usage:" in result.stdout + + def test_pr_queue_health_text_report_is_pasteable() -> None: report = analyze_queue( { @@ -127,6 +144,66 @@ def test_pr_queue_health_accepts_claim_command_reference() -> None: assert report["missing_bounty_references"] == [] +def test_pr_queue_health_accepts_github_linking_keywords() -> None: + references = ( + "Bounty #310", + "Claim #310", + "Claims #310", + "Ref #310", + "Refs #310", + "Reference #310", + "References #310", + "Fix #310", + "Fixes #310", + "Fixed #310", + "Close #310", + "Closes #310", + "Closed #310", + "Resolve #310", + "Resolves #310", + "Resolved #310", + ) + report = analyze_queue( + { + "bounties": [{"number": 310, "state": "OPEN", "awards_remaining": 1}], + "pull_requests": [ + { + "number": index, + "title": f"Harden bounty queue checks {index}", + "body": reference, + "merge_state": "clean", + "labels": [], + } + for index, reference in enumerate(references, start=1) + ], + } + ) + + assert report["summary"]["missing_bounty_references"] == 0 + assert report["missing_bounty_references"] == [] + + +@pytest.mark.parametrize("reference", ("Fixes #310abc", "Fixes #310_abc", "Fixes #310-abc")) +def test_pr_queue_health_rejects_linking_keyword_issue_suffix(reference: str) -> None: + report = analyze_queue( + { + "bounties": [{"number": 310, "state": "OPEN", "awards_remaining": 1}], + "pull_requests": [ + { + "number": 8, + "title": "Harden bounty queue checks", + "body": reference, + "merge_state": "clean", + "labels": [], + } + ], + } + ) + + assert report["summary"]["missing_bounty_references"] == 1 + assert report["missing_bounty_references"][0]["pull_request"] == 8 + + def test_pr_queue_health_markdown_report_includes_required_sections() -> None: report = analyze_queue( { @@ -183,7 +260,8 @@ def test_pr_queue_health_markdown_report_includes_required_sections() -> None: assert "### Missing bounty references" in markdown assert ( "- [PR #2](https://github.com/ramimbo/mergework/pull/2): " - "Improve bounty filters (No Bounty #, Refs #, or /claim # found)" + "Improve bounty filters (No bounty reference such as Bounty #, " + "Refs #, Fixes #, or /claim # found)" ) in markdown assert "### Dirty or unstable merge state" in markdown assert "Merge state is dirty" in markdown diff --git a/tests/test_submission_quality_gate.py b/tests/test_submission_quality_gate.py index 29eee86f..5578fce3 100644 --- a/tests/test_submission_quality_gate.py +++ b/tests/test_submission_quality_gate.py @@ -2,10 +2,16 @@ import json import subprocess +import sys +from pathlib import Path + +import pytest from scripts import submission_quality_gate from scripts.submission_quality_gate import evaluate_submission, main +ROOT = Path(__file__).resolve().parents[1] + def test_submission_quality_gate_passes_open_bounty_with_evidence(capsys, tmp_path) -> None: fixture = { @@ -40,6 +46,19 @@ def test_submission_quality_gate_passes_open_bounty_with_evidence(capsys, tmp_pa assert json.loads(capsys.readouterr().out)["status"] == "pass" +def test_submission_quality_gate_script_entrypoint_loads_shared_parser() -> None: + result = subprocess.run( + [sys.executable, "scripts/submission_quality_gate.py", "--help"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "usage:" in result.stdout + + def test_submission_quality_gate_accepts_claim_command_reference() -> None: result = evaluate_submission( { @@ -66,6 +85,62 @@ def test_submission_quality_gate_accepts_claim_command_reference() -> None: } in result["checks"] +def test_submission_quality_gate_accepts_github_linking_keywords() -> None: + references = ( + "Bounty #319", + "Claim #319", + "Claims #319", + "Ref #319", + "Refs #319", + "Reference #319", + "References #319", + "Fix #319", + "Fixes #319", + "Fixed #319", + "Close #319", + "Closes #319", + "Closed #319", + "Resolve #319", + "Resolves #319", + "Resolved #319", + ) + for reference in references: + result = evaluate_submission( + { + "submission_text": f""" + Summary: + Harden the bounty reference parser. + + {reference} + + Validation: + - pytest passed. + """, + "bounties": [{"number": 319, "state": "OPEN", "awards_remaining": 1}], + "pull_requests": [], + } + ) + + assert result["status"] == "pass", reference + assert result["bounty_reference"] == 319 + + +@pytest.mark.parametrize("reference", ("Fixes #319abc", "Fixes #319_abc", "Fixes #319-abc")) +def test_submission_quality_gate_rejects_linking_keyword_issue_suffix(reference: str) -> None: + result = evaluate_submission( + { + "submission_text": ( + f"Summary: add validation\n\n{reference}\n\nValidation: pytest passed" + ), + "bounties": [{"number": 319, "state": "OPEN", "awards_remaining": 1}], + "pull_requests": [], + } + ) + + assert result["status"] == "fail" + assert result["bounty_reference"] is None + + def test_submission_quality_gate_fails_missing_reference() -> None: result = evaluate_submission( { @@ -80,7 +155,8 @@ def test_submission_quality_gate_fails_missing_reference() -> None: "name": "bounty_reference", "status": "fail", "message": ( - "submission text must include Bounty #, Refs #, or /claim #" + "submission text must include a bounty reference such as " + "Bounty #, Refs #, Fixes #, or /claim #" ), }