diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 19215cc..1e03953 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,5 @@ + + ## Plugin diff --git a/.github/workflows/enforce-pr-template.yml b/.github/workflows/enforce-pr-template.yml new file mode 100644 index 0000000..78786bc --- /dev/null +++ b/.github/workflows/enforce-pr-template.yml @@ -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" diff --git a/.github/workflows/scripts/enforce-pr-template.py b/.github/workflows/scripts/enforce-pr-template.py new file mode 100755 index 0000000..0ec6e09 --- /dev/null +++ b/.github/workflows/scripts/enforce-pr-template.py @@ -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 = "" +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_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)) diff --git a/.github/workflows/scripts/test_enforce_pr_template.py b/.github/workflows/scripts/test_enforce_pr_template.py new file mode 100644 index 0000000..773b816 --- /dev/null +++ b/.github/workflows/scripts/test_enforce_pr_template.py @@ -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()