ci(workflow): enforce-pr-template
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
name: Enforce Pull Request Template
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
|
||||
concurrency:
|
||||
group: enforce-pr-template-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
enforce:
|
||||
if: ${{ github.repository == 'noctalia-dev/community-plugins' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# pull_request_target has write access. Only execute this trusted script from
|
||||
# the default branch; never check out or run code from the pull request head.
|
||||
- name: Check out trusted enforcement code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate pull request description
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 .github/workflows/scripts/enforce-pr-template.py "$GITHUB_EVENT_PATH"
|
||||
+182
@@ -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()
|
||||
Reference in New Issue
Block a user