#!/usr/bin/env python3 from __future__ import annotations import json import os import sys import urllib.error import urllib.request from pathlib import Path from typing import Any TEMPLATE_MARKER = "" COMMENT_MARKER = "" REQUIRED_HEADINGS = ( "## Plugin", "## What it does", "## External dependencies", "## Testing", "## Screenshots / Videos", "## Checklist", "## Code review attestation", ) REQUIRED_FIELD_PREFIXES = ( "- **Id:**", "- **Noctalia version tested against:**", "- **Plugin API level:**", ) REQUIRED_CHECKLIST_ITEMS = ( "New plugin", "Update to an existing plugin (version bumped in `plugin.toml`)", "Tested on Niri", "Tested on Hyprland", "Tested on Sway", "Tested on another compositor:", "The directory name matches the part of `id` after the `/` in `plugin.toml` exactly.", "It ships `plugin.toml`, `README.md`, `thumbnail.webp`, and `translations/en.json`.", "`README.md` follows the [README template](https://github.com/noctalia-dev/community-plugins/blob/main/README_TEMPLATE.md), documents every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.", "I created `thumbnail.webp` with the [thumbnail generator](https://assets.noctalia.dev/plugins/thumbnail-generator.html).", "`version` follows semver and is bumped in this PR; `plugin_api` is the oldest API level this plugin requires.", "Every non-English translation in this PR uses a locale supported by Noctalia core, and I can read, write, and understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).", "I did not edit `catalog.toml`; CI generates it.", "This PR touches exactly one plugin directory.", "The code is readable and not obfuscated, minified, or generated.", "It does not download and execute remote code.", "Every network call, filesystem write, and spawned process is something the description above accounts for.", "I have the right to publish this code under the `license` declared in `plugin.toml`.", ) CLOSURE_INTRO = f"""{COMMENT_MARKER} This pull request was automatically closed because its description no longer contains every part of [the pull request template](https://github.com/noctalia-dev/community-plugins/blob/main/.github/PULL_REQUEST_TEMPLATE.md) that this repository requires. Missing: """ CLOSURE_OUTRO = """ Please add the items listed above back to the description, keeping their exact wording, then reopen the pull request. Reopening re-runs this check. Only the marker line, the `##` headings, the `- **Field:**` lines, and the `- [ ]` checklist entries are required; the guidance comments in the template are yours to delete, and checklist boxes may stay unchecked while the pull request is a draft. """ def build_closure_comment(missing: list[str]) -> str: bullets = "".join(f"- {item}\n" for item in missing) return f"{CLOSURE_INTRO}{bullets}{CLOSURE_OUTRO}" def missing_requirements(body: object) -> list[str]: if not isinstance(body, str): body = "" lines = {line.strip() for line in body.splitlines()} normalized_body = " ".join(body.split()) missing: list[str] = [] if TEMPLATE_MARKER not in normalized_body: missing.append(f"the template marker line `{TEMPLATE_MARKER}`") for heading in REQUIRED_HEADINGS: if heading not in lines: missing.append(f"the `{heading}` heading") for prefix in REQUIRED_FIELD_PREFIXES: if prefix not in normalized_body: missing.append(f"the `{prefix}` field") for item in REQUIRED_CHECKLIST_ITEMS: if not any( f"- [{state}] {item}" in normalized_body for state in (" ", "x", "X") ): missing.append(f"the checklist entry: {item}") return missing def github_request( url: str, token: str, *, method: str = "GET", payload: dict[str, object] | None = None, ) -> Any: data = None if payload is None else json.dumps(payload).encode() request = urllib.request.Request( url, data=data, method=method, headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "noctalia-pr-template-enforcement", "X-GitHub-Api-Version": "2022-11-28", }, ) with urllib.request.urlopen(request, timeout=30) as response: response_body = response.read() return json.loads(response_body) if response_body else None def latest_enforcement_comment(issue_url: str, token: str) -> str | None: page = 1 latest: str | None = None while True: comments = github_request( f"{issue_url}/comments?per_page=100&page={page}", token, ) if not isinstance(comments, list): raise RuntimeError("GitHub returned an invalid pull request comment list") for comment in comments: if not isinstance(comment, dict): continue body = str(comment.get("body", "")) if COMMENT_MARKER in body: latest = body if len(comments) < 100: return latest page += 1 def enforce(event: dict[str, object], token: str) -> list[str]: pull_request = event.get("pull_request") if not isinstance(pull_request, dict): raise ValueError("event does not contain a pull_request object") missing = missing_requirements(pull_request.get("body")) if not missing: return [] issue_url = pull_request.get("issue_url") pull_request_url = pull_request.get("url") if not isinstance(issue_url, str) or not isinstance(pull_request_url, str): raise ValueError("pull request event is missing GitHub API URLs") if not token: raise ValueError("GITHUB_TOKEN is required to close an invalid pull request") comment = build_closure_comment(missing) if latest_enforcement_comment(issue_url, token) != comment: github_request( f"{issue_url}/comments", token, method="POST", payload={"body": comment}, ) github_request( pull_request_url, token, method="PATCH", payload={"state": "closed"}, ) return missing def main(argv: list[str]) -> int: event_path = Path(argv[1] if len(argv) > 1 else os.environ["GITHUB_EVENT_PATH"]) try: event = json.loads(event_path.read_text()) if not isinstance(event, dict): raise ValueError("GitHub event payload must be a JSON object") missing = enforce(event, os.environ.get("GITHUB_TOKEN", "")) except (OSError, ValueError, RuntimeError, urllib.error.URLError) as error: print(f"::error title=PR template enforcement failed::{error}") return 1 if missing: print( "::error title=Pull request description is missing required template content::" + "; ".join(missing) ) return 1 print("Pull request description retains the required template structure.") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))