ci(tracker): allow labelled standalone pull requests

This commit is contained in:
2026-07-26 06:46:00 +00:00
committed by didericis
parent be025ff8fb
commit 5828f5e900
3 changed files with 55 additions and 23 deletions
@@ -4,6 +4,10 @@
- **Date:** 2026-07-18 - **Date:** 2026-07-18
- **Deciders:** didericis - **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 ## Context
Gitea exposes labels on both issues and pull requests. Applying the same labels 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 ## Decision
Issues are the canonical tracker records and own labels. Every issue has at Issues are the canonical tracker records and own labels when a separate work
least one label. An issue opened or left without labels receives item exists. Every issue has at least one label. An issue opened or left
`Status/Needs Triage` automatically until it is classified. without labels receives `Status/Needs Triage` automatically until it is
classified.
Pull requests carry no labels. Every new PR deliberately references at least Every new pull request is tracked in one of two ways:
one existing issue in its title or description with one of these forms:
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. - `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it - `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
contributes without completing it. contributes without completing it.
Gitea Actions enforces both PR rules as a status check and repairs the empty Gitea Actions enforces the either/or PR rule, validates any issue references,
issue-label state. Branch protection makes the PR policy check required. 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 The policy applies from 2026-07-18 onward. Existing issues may be labelled as
they are encountered, but closed PRs are grandfathered: no retrospective 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 ## Consequences
- Classification, priority, and workflow metadata have one source of truth. - Classification, priority, and workflow metadata have one source of truth for
- A PR's issue link is the navigation path to its planning metadata. 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. - 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 - `Status/Needs Triage` is an intentional fallback, not a final
classification. classification.
- Direct issue creation remains convenient; automation repairs a missing label - Direct issue creation remains convenient; automation repairs a missing label
+6 -10
View File
@@ -54,18 +54,14 @@ def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
pull = event["pull_request"] pull = event["pull_request"]
errors: list[str] = [] errors: list[str] = []
labels = pull.get("labels") or [] 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", "")) numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
if not numbers: if not numbers:
errors.append( if not labels:
"PR must reference an issue with Closes/Fixes/Resolves #N, " errors.append(
"Part of #N, Related to #N, Refs #N, or References #N." "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 return errors
real_issues = 0 real_issues = 0
+23 -4
View File
@@ -27,7 +27,27 @@ class TestCheckPullRequest(unittest.TestCase):
event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}} event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}}
self.assertEqual(check_pull_request(event, api), []) 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 = Mock()
api.request.return_value = {"number": 12, "pull_request": {}} api.request.return_value = {"number": 12, "pull_request": {}}
event = { event = {
@@ -38,9 +58,8 @@ class TestCheckPullRequest(unittest.TestCase):
} }
} }
errors = check_pull_request(event, api) errors = check_pull_request(event, api)
self.assertEqual(len(errors), 2) self.assertEqual(len(errors), 1)
self.assertIn("unlabeled", errors[0]) self.assertIn("not an issue", errors[0])
self.assertIn("not an issue", errors[1])
class TestEnsureIssueLabel(unittest.TestCase): class TestEnsureIssueLabel(unittest.TestCase):