From 5828f5e900c8e7e9bea166bbee07de228bc6876b Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 06:46:00 +0000 Subject: [PATCH] ci(tracker): allow labelled standalone pull requests --- .../0005-issues-own-tracker-metadata.md | 35 ++++++++++++++----- scripts/tracker_policy.py | 16 ++++----- tests/unit/test_tracker_policy.py | 27 +++++++++++--- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/docs/decisions/0005-issues-own-tracker-metadata.md b/docs/decisions/0005-issues-own-tracker-metadata.md index 14220cab..98acbc0d 100644 --- a/docs/decisions/0005-issues-own-tracker-metadata.md +++ b/docs/decisions/0005-issues-own-tracker-metadata.md @@ -4,6 +4,10 @@ - **Date:** 2026-07-18 - **Deciders:** didericis +> **Amended 2026-07-26.** A pull request may carry labels directly instead of +> linking a tracking issue. When a PR does link an issue, the issue remains the +> canonical owner of planning metadata and the reference is validated. + ## Context Gitea exposes labels on both issues and pull requests. Applying the same labels @@ -20,19 +24,28 @@ would make the issue history less truthful. ## Decision -Issues are the canonical tracker records and own labels. Every issue has at -least one label. An issue opened or left without labels receives -`Status/Needs Triage` automatically until it is classified. +Issues are the canonical tracker records and own labels when a separate work +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. -Pull requests carry no labels. Every new PR deliberately references at least -one existing issue in its title or description with one of these forms: +Every new pull request is tracked in one of two 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 + unlabelled. +2. It carries at least one label directly when a separate issue would add no + useful planning context. + +Issue references use one of these forms: - `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it. - `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it contributes without completing it. -Gitea Actions enforces both PR rules as a status check and repairs the empty -issue-label state. Branch protection makes the PR policy check required. +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. The policy applies from 2026-07-18 onward. Existing issues may be labelled as they are encountered, but closed PRs are grandfathered: no retrospective @@ -40,9 +53,13 @@ issues or PR labels are created solely to make history conform. ## Consequences -- Classification, priority, and workflow metadata have one source of truth. -- A PR's issue link is the navigation path to its planning metadata. +- Classification, priority, and workflow metadata have one source of truth for + each change: the linked issue when one exists, otherwise the PR. +- For issue-backed changes, the PR's issue link is the navigation path to its + planning metadata. - Multi-PR issues do not require copied or synchronized labels. +- Small standalone changes do not require a tracking issue created solely to + satisfy automation. - `Status/Needs Triage` is an intentional fallback, not a final classification. - Direct issue creation remains convenient; automation repairs a missing label diff --git a/scripts/tracker_policy.py b/scripts/tracker_policy.py index 4494875f..aec64a24 100644 --- a/scripts/tracker_policy.py +++ b/scripts/tracker_policy.py @@ -54,18 +54,14 @@ def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]: pull = event["pull_request"] errors: list[str] = [] labels = pull.get("labels") or [] - if labels: - errors.append( - "PRs must be unlabeled; put tracker metadata on the linked issue " - f"(found: {', '.join(label['name'] for label in labels)})." - ) - numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", "")) if not numbers: - errors.append( - "PR must reference an issue with Closes/Fixes/Resolves #N, " - "Part of #N, Related to #N, Refs #N, or References #N." - ) + if not labels: + errors.append( + "PR must either have a label or reference an issue with " + "Closes/Fixes/Resolves #N, Part of #N, Related to #N, " + "Refs #N, or References #N." + ) return errors real_issues = 0 diff --git a/tests/unit/test_tracker_policy.py b/tests/unit/test_tracker_policy.py index be79a174..517d33ce 100644 --- a/tests/unit/test_tracker_policy.py +++ b/tests/unit/test_tracker_policy.py @@ -27,7 +27,27 @@ class TestCheckPullRequest(unittest.TestCase): event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}} self.assertEqual(check_pull_request(event, api), []) - def test_rejects_labels_and_pr_reference(self): + def test_accepts_labelled_pr_without_issue(self): + api = Mock() + event = { + "pull_request": { + "title": "Change", + "body": "", + "labels": [{"name": "Kind/Documentation"}], + } + } + self.assertEqual(check_pull_request(event, api), []) + api.request.assert_not_called() + + def test_rejects_unlabelled_pr_without_issue(self): + api = Mock() + event = {"pull_request": {"title": "Change", "body": "", "labels": []}} + errors = check_pull_request(event, api) + self.assertEqual(len(errors), 1) + 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): api = Mock() api.request.return_value = {"number": 12, "pull_request": {}} event = { @@ -38,9 +58,8 @@ class TestCheckPullRequest(unittest.TestCase): } } errors = check_pull_request(event, api) - self.assertEqual(len(errors), 2) - self.assertIn("unlabeled", errors[0]) - self.assertIn("not an issue", errors[1]) + self.assertEqual(len(errors), 1) + self.assertIn("not an issue", errors[0]) class TestEnsureIssueLabel(unittest.TestCase):