diff --git a/docs/decisions/0005-issues-own-tracker-metadata.md b/docs/decisions/0005-issues-own-tracker-metadata.md index 98acbc0d..43f3e956 100644 --- a/docs/decisions/0005-issues-own-tracker-metadata.md +++ b/docs/decisions/0005-issues-own-tracker-metadata.md @@ -29,7 +29,8 @@ item exists. Every issue has at least one label. An issue opened or left without labels receives `Status/Needs Triage` automatically until it is classified. -Every new pull request is tracked in one of two ways: +Every new pull request is tracked in exactly one of two mutually exclusive +ways: 1. It deliberately references at least one existing issue in its title or description. Tracker metadata stays on that issue and the PR remains @@ -43,9 +44,9 @@ Issue references use one of these forms: - `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it contributes without completing it. -Gitea Actions enforces the either/or PR rule, validates any issue references, -and repairs the empty issue-label state. Branch protection makes the PR policy -check required. +Gitea Actions enforces the exclusive either/or PR rule, validates any issue +references, and repairs the empty issue-label state. Branch protection makes +the PR policy check required. The policy applies from 2026-07-18 onward. Existing issues may be labelled as they are encountered, but closed PRs are grandfathered: no retrospective @@ -70,5 +71,6 @@ issues or PR labels are created solely to make history conform. ## Links - Issue #405. -- `.gitea/workflows/tracker-policy.yml`. +- `.gitea/workflows/tracker-policy-pr.yml`. +- `.gitea/workflows/tracker-policy-issues.yml`. - `scripts/tracker_policy.py`. diff --git a/scripts/tracker_policy.py b/scripts/tracker_policy.py index aec64a24..144aa27a 100644 --- a/scripts/tracker_policy.py +++ b/scripts/tracker_policy.py @@ -55,6 +55,12 @@ def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]: errors: list[str] = [] labels = pull.get("labels") or [] numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", "")) + if labels and numbers: + errors.append( + "PR must use exactly one tracking mode: remove PR labels when " + "linking an issue, or remove the issue reference when labels " + "belong on the PR." + ) if not numbers: if not labels: errors.append( diff --git a/tests/unit/test_tracker_policy.py b/tests/unit/test_tracker_policy.py index 517d33ce..3dba3c3a 100644 --- a/tests/unit/test_tracker_policy.py +++ b/tests/unit/test_tracker_policy.py @@ -47,7 +47,21 @@ class TestCheckPullRequest(unittest.TestCase): self.assertIn("either have a label or reference an issue", errors[0]) api.request.assert_not_called() - def test_validates_issue_reference_even_when_pr_is_labelled(self): + def test_rejects_labelled_pr_linked_to_real_issue(self): + api = Mock() + api.request.return_value = {"number": 12, "pull_request": None} + event = { + "pull_request": { + "title": "Change", + "body": "Closes #12", + "labels": [{"name": "Kind/Documentation"}], + } + } + errors = check_pull_request(event, api) + self.assertEqual(len(errors), 1) + self.assertIn("exactly one tracking mode", errors[0]) + + def test_still_validates_issue_reference_when_both_modes_are_used(self): api = Mock() api.request.return_value = {"number": 12, "pull_request": {}} event = { @@ -58,8 +72,9 @@ class TestCheckPullRequest(unittest.TestCase): } } errors = check_pull_request(event, api) - self.assertEqual(len(errors), 1) - self.assertIn("not an issue", errors[0]) + self.assertEqual(len(errors), 2) + self.assertIn("exactly one tracking mode", errors[0]) + self.assertIn("not an issue", errors[1]) class TestEnsureIssueLabel(unittest.TestCase):