From 9d66d1bc49d5b2024fd3c2a8d0d3380248d8566c Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 18 Jul 2026 02:03:22 +0000 Subject: [PATCH] ci: enforce canonical issue metadata policy --- .gitea/workflows/tracker-policy.yml | 33 +++++ README.md | 9 ++ .../0005-issues-own-tracker-metadata.md | 57 ++++++++ scripts/tracker_policy.py | 135 ++++++++++++++++++ tests/unit/test_tracker_policy.py | 62 ++++++++ 5 files changed, 296 insertions(+) create mode 100644 .gitea/workflows/tracker-policy.yml create mode 100644 docs/decisions/0005-issues-own-tracker-metadata.md create mode 100644 scripts/tracker_policy.py create mode 100644 tests/unit/test_tracker_policy.py diff --git a/.gitea/workflows/tracker-policy.yml b/.gitea/workflows/tracker-policy.yml new file mode 100644 index 0000000..d28fc19 --- /dev/null +++ b/.gitea/workflows/tracker-policy.yml @@ -0,0 +1,33 @@ +name: tracker-policy + +on: + issues: + types: [opened, unlabeled] + pull_request: + types: [opened, edited, reopened, synchronized, labeled, unlabeled] + +jobs: + label-issue: + if: ${{ github.event_name == 'issues' }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v4 + - name: Ensure the issue has a label + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 scripts/tracker_policy.py label-issue + + check-pr: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + issues: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Require an unlabeled PR linked to an issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 scripts/tracker_policy.py check-pr diff --git a/README.md b/README.md index 75711dd..c741615 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,15 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. +## Tracker policy + +Issues are the canonical work items and own all tracker labels; every issue +must have at least one. Pull requests stay unlabeled and deliberately reference +an issue with `Closes #…`, `Part of #…`, or another form defined in +[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions +enforces the convention for new work from 2026-07-18 onward. Earlier closed +PRs are grandfathered rather than given artificial retrospective issues. + ## Trademarks bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox. diff --git a/docs/decisions/0005-issues-own-tracker-metadata.md b/docs/decisions/0005-issues-own-tracker-metadata.md new file mode 100644 index 0000000..14220ca --- /dev/null +++ b/docs/decisions/0005-issues-own-tracker-metadata.md @@ -0,0 +1,57 @@ +# ADR 0005: Keep tracker metadata on issues + +- **Status:** Accepted +- **Date:** 2026-07-18 +- **Deciders:** didericis + +## Context + +Gitea exposes labels on both issues and pull requests. Applying the same labels +to both copies planning metadata, creates a synchronization obligation, and +makes disagreements between the two records possible. At the same time, +unlabelled objects look accidental unless the repository states which object +owns the metadata. + +The repository already uses issues as work items and PRs as implementations of +those work items. At this decision's cutoff, all open PRs reference issues, but +121 of 219 historically merged PRs do not. Manufacturing retrospective issues +for that history would create records that never participated in planning and +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. + +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: + +- `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. + +The policy applies from 2026-07-18 onward. Existing issues may be labelled as +they are encountered, but closed PRs are grandfathered: no retrospective +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. +- Multi-PR issues do not require copied or synchronized labels. +- `Status/Needs Triage` is an intentional fallback, not a final + classification. +- Direct issue creation remains convenient; automation repairs a missing label + immediately after creation because Gitea has no native required-label rule. +- The required check must be configured in branch protection after this + workflow lands. + +## Links + +- Issue #405. +- `.gitea/workflows/tracker-policy.yml`. +- `scripts/tracker_policy.py`. diff --git a/scripts/tracker_policy.py b/scripts/tracker_policy.py new file mode 100644 index 0000000..4494875 --- /dev/null +++ b/scripts/tracker_policy.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Enforce the repository's issue/PR metadata policy in Gitea Actions.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +ISSUE_REFERENCE = re.compile( + r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|" + r"related\s+to|refs?|references)\s+#(\d+)\b" +) +TRIAGE_LABEL = "Status/Needs Triage" + + +def deliberate_issue_numbers(title: str, body: str) -> set[int]: + """Return same-repository issue numbers referenced intentionally.""" + return {int(match) for match in ISSUE_REFERENCE.findall(f"{title}\n{body}")} + + +class GiteaApi: + """Small API client using the Actions-provided repository token.""" + + def __init__(self, api_url: str, repository: str, token: str) -> None: + self.base = f"{api_url.rstrip('/')}/repos/{repository}" + self.token = token + + def request(self, method: str, path: str, payload: object | None = None) -> Any: + data = None if payload is None else json.dumps(payload).encode() + request = urllib.request.Request( + f"{self.base}{path}", + data=data, + method=method, + headers={ + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=15) as response: + if response.status == 204: + return None + return json.load(response) + + +def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]: + """Return policy violations for a pull_request event.""" + 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." + ) + return errors + + real_issues = 0 + for number in sorted(numbers): + try: + item = api.request("GET", f"/issues/{number}") + except urllib.error.HTTPError as error: + if error.code == 404: + errors.append(f"Referenced issue #{number} does not exist.") + continue + raise + if item.get("pull_request") is not None: + errors.append(f"#{number} is a pull request, not an issue.") + else: + real_issues += 1 + + if not real_issues and not errors: + errors.append("PR must reference at least one real issue.") + return errors + + +def ensure_issue_label(event: dict[str, Any], api: GiteaApi) -> bool: + """Apply the triage label if an issue event leaves the issue unlabeled.""" + issue = event["issue"] + if issue.get("pull_request") is not None or issue.get("labels"): + return False + labels = api.request("GET", "/labels?limit=100") + triage = next((label for label in labels if label["name"] == TRIAGE_LABEL), None) + if triage is None: + raise RuntimeError(f"repository label {TRIAGE_LABEL!r} does not exist") + api.request("POST", f"/issues/{issue['number']}/labels", {"labels": [triage["id"]]}) + return True + + +def _load_event(path: str) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("check-pr", "label-issue")) + parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH")) + args = parser.parse_args() + if not args.event: + parser.error("--event or GITHUB_EVENT_PATH is required") + + api = GiteaApi( + os.environ["GITHUB_API_URL"], + os.environ["GITHUB_REPOSITORY"], + os.environ["GITHUB_TOKEN"], + ) + event = _load_event(args.event) + if args.command == "check-pr": + errors = check_pull_request(event, api) + if errors: + print("\n".join(f"::error::{error}" for error in errors)) + return 1 + print("PR tracker policy passed.") + return 0 + + changed = ensure_issue_label(event, api) + print(f"Applied {TRIAGE_LABEL}." if changed else "Issue already has a label.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_tracker_policy.py b/tests/unit/test_tracker_policy.py new file mode 100644 index 0000000..be79a17 --- /dev/null +++ b/tests/unit/test_tracker_policy.py @@ -0,0 +1,62 @@ +import unittest +from unittest.mock import Mock + +from scripts.tracker_policy import ( + TRIAGE_LABEL, + check_pull_request, + deliberate_issue_numbers, + ensure_issue_label, +) + + +class TestDeliberateIssueNumbers(unittest.TestCase): + def test_accepts_completing_and_noncompleting_forms(self): + self.assertEqual( + deliberate_issue_numbers("Fixes #12", "Part of #14; refs #15"), + {12, 14, 15}, + ) + + def test_does_not_treat_incidental_number_as_link(self): + self.assertEqual(deliberate_issue_numbers("Audit #12", "See PR #14"), set()) + + +class TestCheckPullRequest(unittest.TestCase): + def test_accepts_unlabelled_pr_linked_to_real_issue(self): + api = Mock() + api.request.return_value = {"number": 12, "pull_request": None} + 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): + api = Mock() + api.request.return_value = {"number": 12, "pull_request": {}} + event = { + "pull_request": { + "title": "Change", + "body": "Closes #12", + "labels": [{"name": "Kind/Bug"}], + } + } + errors = check_pull_request(event, api) + self.assertEqual(len(errors), 2) + self.assertIn("unlabeled", errors[0]) + self.assertIn("not an issue", errors[1]) + + +class TestEnsureIssueLabel(unittest.TestCase): + def test_adds_triage_label_to_unlabelled_issue(self): + api = Mock() + api.request.side_effect = [[{"id": 55, "name": TRIAGE_LABEL}], None] + event = {"issue": {"number": 405, "labels": [], "pull_request": None}} + self.assertTrue(ensure_issue_label(event, api)) + api.request.assert_any_call("POST", "/issues/405/labels", {"labels": [55]}) + + def test_leaves_labelled_issue_unchanged(self): + api = Mock() + event = {"issue": {"number": 405, "labels": [{"name": "Kind/Documentation"}]}} + self.assertFalse(ensure_issue_label(event, api)) + api.request.assert_not_called() + + +if __name__ == "__main__": + unittest.main()