Files

202 lines
7.2 KiB
Python
Executable File

#!/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 = "<!-- noctalia-pr-template:v1 -->"
COMMENT_MARKER = "<!-- noctalia-pr-template-enforcement -->"
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))