#!/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())