ci(workflow): enforce-pr-template

This commit is contained in:
Lemmy
2026-07-31 08:51:02 -04:00
parent 767fb29af0
commit dffd46ab4d
4 changed files with 377 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/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_COMMENT = f"""{COMMENT_MARKER}
This pull request was automatically closed because its description removed or altered required sections of the repository's pull request template.
Restore the current contents of `.github/PULL_REQUEST_TEMPLATE.md`, complete it without deleting its sections, fields, or checklist entries, and then reopen the pull request. Checklist boxes may remain unchecked while the pull request is a draft.
"""
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 lines:
missing.append("template version marker")
for heading in REQUIRED_HEADINGS:
if heading not in lines:
missing.append(f"{heading} section")
for prefix in REQUIRED_FIELD_PREFIXES:
if prefix not in normalized_body:
missing.append(f"field: {prefix.removeprefix('- ')}")
for item in REQUIRED_CHECKLIST_ITEMS:
if not any(
f"- [{state}] {item}" in normalized_body
for state in (" ", "x", "X")
):
missing.append(f"checklist item: {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 has_enforcement_comment(issue_url: str, token: str) -> bool:
page = 1
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")
if any(
isinstance(comment, dict)
and COMMENT_MARKER in str(comment.get("body", ""))
for comment in comments
):
return True
if len(comments) < 100:
return False
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")
if not has_enforcement_comment(issue_url, token):
github_request(
f"{issue_url}/comments",
token,
method="POST",
payload={"body": CLOSURE_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=Required PR template content is missing::" + "; ".join(missing))
return 1
print("Pull request description retains the required template structure.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
@@ -0,0 +1,158 @@
from __future__ import annotations
import importlib.util
import unittest
from pathlib import Path
from unittest import mock
VALIDATOR_PATH = Path(__file__).with_name("enforce-pr-template.py")
TEMPLATE_PATH = Path(__file__).parents[2] / "PULL_REQUEST_TEMPLATE.md"
SPEC = importlib.util.spec_from_file_location("enforce_pr_template", VALIDATOR_PATH)
assert SPEC is not None and SPEC.loader is not None
enforce_pr_template = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(enforce_pr_template)
class TemplateValidationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.template = TEMPLATE_PATH.read_text()
def test_accepts_canonical_template(self) -> None:
self.assertEqual(enforce_pr_template.missing_requirements(self.template), [])
def test_accepts_checked_checklist_items(self) -> None:
checked = self.template.replace("- [ ]", "- [x]")
self.assertEqual(enforce_pr_template.missing_requirements(checked), [])
def test_accepts_template_line_wrapping(self) -> None:
wrapped = self.template.replace(
"and includes exact panel IPC commands",
"and includes exact panel IPC\n commands",
)
self.assertEqual(enforce_pr_template.missing_requirements(wrapped), [])
def test_rejects_missing_version_marker(self) -> None:
body = self.template.replace(enforce_pr_template.TEMPLATE_MARKER, "")
self.assertEqual(
enforce_pr_template.missing_requirements(body),
["template version marker"],
)
def test_rejects_removed_section(self) -> None:
body = self.template.replace("## Testing", "## Verification")
self.assertEqual(
enforce_pr_template.missing_requirements(body),
["## Testing section"],
)
def test_rejects_removed_required_field(self) -> None:
body = self.template.replace("- **Plugin API level:**", "- **API:**")
self.assertEqual(
enforce_pr_template.missing_requirements(body),
["field: **Plugin API level:**"],
)
def test_rejects_altered_multiline_checklist_item(self) -> None:
body = self.template.replace(
"`README.md` follows the",
"`README.md` resembles the",
)
self.assertEqual(
enforce_pr_template.missing_requirements(body),
[
"checklist item: `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."
],
)
class TemplateEnforcementTests(unittest.TestCase):
ISSUE_URL = "https://api.github.test/repos/noctalia-dev/community-plugins/issues/123"
PULL_REQUEST_URL = "https://api.github.test/repos/noctalia-dev/community-plugins/pulls/123"
def event(self, body: str) -> dict[str, object]:
return {
"pull_request": {
"body": body,
"issue_url": self.ISSUE_URL,
"url": self.PULL_REQUEST_URL,
}
}
def test_valid_template_does_not_call_github(self) -> None:
template = TEMPLATE_PATH.read_text()
with mock.patch.object(enforce_pr_template, "github_request") as request:
self.assertEqual(enforce_pr_template.enforce(self.event(template), "token"), [])
request.assert_not_called()
def test_invalid_template_comments_once_and_closes_pull_request(self) -> None:
def response(url: str, token: str, **kwargs: object) -> object:
return [] if kwargs.get("method", "GET") == "GET" else {}
with mock.patch.object(
enforce_pr_template,
"github_request",
side_effect=response,
) as request:
missing = enforce_pr_template.enforce(
self.event("AI-generated replacement body"),
"token",
)
self.assertIn("template version marker", missing)
self.assertEqual(
request.call_args_list,
[
mock.call(
f"{self.ISSUE_URL}/comments?per_page=100&page=1",
"token",
),
mock.call(
f"{self.ISSUE_URL}/comments",
"token",
method="POST",
payload={"body": enforce_pr_template.CLOSURE_COMMENT},
),
mock.call(
self.PULL_REQUEST_URL,
"token",
method="PATCH",
payload={"state": "closed"},
),
],
)
def test_existing_enforcement_comment_is_not_duplicated(self) -> None:
existing_comment = {"body": enforce_pr_template.CLOSURE_COMMENT}
with mock.patch.object(
enforce_pr_template,
"github_request",
side_effect=[[existing_comment], {}],
) as request:
enforce_pr_template.enforce(
self.event("AI-generated replacement body"),
"token",
)
self.assertEqual(
request.call_args_list,
[
mock.call(
f"{self.ISSUE_URL}/comments?per_page=100&page=1",
"token",
),
mock.call(
self.PULL_REQUEST_URL,
"token",
method="PATCH",
payload={"state": "closed"},
),
],
)
if __name__ == "__main__":
unittest.main()