diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1e03953..6b5ba78 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,12 @@ + + A bot closes pull requests whose description loses the marker line, a "##" heading, + a "- **Field:**" line, or a "- [ ]" checklist entry. Fill those in, do not delete them. + Everything else, including every one of these guidance comments, is yours to delete. + + Not ready for review yet? Mark the pull request as Draft; checklist boxes may stay + unchecked while it is a draft. --> ## Plugin diff --git a/.github/workflows/scripts/enforce-pr-template.py b/.github/workflows/scripts/enforce-pr-template.py index 0ec6e09..1c482c7 100755 --- a/.github/workflows/scripts/enforce-pr-template.py +++ b/.github/workflows/scripts/enforce-pr-template.py @@ -46,11 +46,25 @@ REQUIRED_CHECKLIST_ITEMS = ( "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. +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. -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. +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]: @@ -61,23 +75,23 @@ def missing_requirements(body: object) -> list[str]: normalized_body = " ".join(body.split()) missing: list[str] = [] - if TEMPLATE_MARKER not in lines: - missing.append("template version marker") + 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"{heading} section") + missing.append(f"the `{heading}` heading") for prefix in REQUIRED_FIELD_PREFIXES: if prefix not in normalized_body: - missing.append(f"field: {prefix.removeprefix('- ')}") + 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"checklist item: {item}") + missing.append(f"the checklist entry: {item}") return missing @@ -107,8 +121,9 @@ def github_request( return json.loads(response_body) if response_body else None -def has_enforcement_comment(issue_url: str, token: str) -> bool: +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}", @@ -116,14 +131,14 @@ def has_enforcement_comment(issue_url: str, token: str) -> bool: ) 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 + 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 False + return latest page += 1 @@ -143,12 +158,13 @@ def enforce(event: dict[str, object], token: str) -> list[str]: if not token: raise ValueError("GITHUB_TOKEN is required to close an invalid pull request") - if not has_enforcement_comment(issue_url, token): + comment = build_closure_comment(missing) + if latest_enforcement_comment(issue_url, token) != comment: github_request( f"{issue_url}/comments", token, method="POST", - payload={"body": CLOSURE_COMMENT}, + payload={"body": comment}, ) github_request( pull_request_url, @@ -171,7 +187,10 @@ def main(argv: list[str]) -> int: return 1 if missing: - print("::error title=Required PR template content is missing::" + "; ".join(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.") diff --git a/.github/workflows/scripts/test_enforce_pr_template.py b/.github/workflows/scripts/test_enforce_pr_template.py index 773b816..8f1bd30 100644 --- a/.github/workflows/scripts/test_enforce_pr_template.py +++ b/.github/workflows/scripts/test_enforce_pr_template.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import re import unittest from pathlib import Path from unittest import mock @@ -33,25 +34,36 @@ class TemplateValidationTests(unittest.TestCase): ) self.assertEqual(enforce_pr_template.missing_requirements(wrapped), []) + def test_accepts_body_stripped_of_guidance_comments(self) -> None: + stripped = re.sub( + r").*?-->", + "", + self.template, + flags=re.DOTALL, + ) + self.assertNotIn("guidance", stripped) + self.assertIn(enforce_pr_template.TEMPLATE_MARKER, stripped) + self.assertEqual(enforce_pr_template.missing_requirements(stripped), []) + 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"], + ["the template marker line ``"], ) def test_rejects_removed_section(self) -> None: body = self.template.replace("## Testing", "## Verification") self.assertEqual( enforce_pr_template.missing_requirements(body), - ["## Testing section"], + ["the `## Testing` heading"], ) 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:**"], + ["the `- **Plugin API level:**` field"], ) def test_rejects_altered_multiline_checklist_item(self) -> None: @@ -62,7 +74,7 @@ class TemplateValidationTests(unittest.TestCase): self.assertEqual( enforce_pr_template.missing_requirements(body), [ - "checklist item: `README.md` follows the " + "the checklist entry: `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." ], @@ -102,7 +114,13 @@ class TemplateEnforcementTests(unittest.TestCase): "token", ) - self.assertIn("template version marker", missing) + self.assertIn( + "the template marker line ``", + missing, + ) + comment = enforce_pr_template.build_closure_comment(missing) + for item in missing: + self.assertIn(f"- {item}\n", comment) self.assertEqual( request.call_args_list, [ @@ -114,7 +132,7 @@ class TemplateEnforcementTests(unittest.TestCase): f"{self.ISSUE_URL}/comments", "token", method="POST", - payload={"body": enforce_pr_template.CLOSURE_COMMENT}, + payload={"body": comment}, ), mock.call( self.PULL_REQUEST_URL, @@ -125,17 +143,16 @@ class TemplateEnforcementTests(unittest.TestCase): ], ) - def test_existing_enforcement_comment_is_not_duplicated(self) -> None: - existing_comment = {"body": enforce_pr_template.CLOSURE_COMMENT} + def test_identical_enforcement_comment_is_not_duplicated(self) -> None: + body = "AI-generated replacement body" + missing = enforce_pr_template.missing_requirements(body) + existing_comment = {"body": enforce_pr_template.build_closure_comment(missing)} 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", - ) + enforce_pr_template.enforce(self.event(body), "token") self.assertEqual( request.call_args_list, @@ -153,6 +170,31 @@ class TemplateEnforcementTests(unittest.TestCase): ], ) + def test_stale_enforcement_comment_is_replaced_with_current_findings(self) -> None: + body = TEMPLATE_PATH.read_text().replace("## Testing", "## Verification") + stale = { + "body": enforce_pr_template.build_closure_comment( + ["the template marker line ``"] + ) + } + with mock.patch.object( + enforce_pr_template, + "github_request", + side_effect=[[stale], {}, {}], + ) as request: + missing = enforce_pr_template.enforce(self.event(body), "token") + + self.assertEqual(missing, ["the `## Testing` heading"]) + self.assertEqual( + request.call_args_list[1], + mock.call( + f"{self.ISSUE_URL}/comments", + "token", + method="POST", + payload={"body": enforce_pr_template.build_closure_comment(missing)}, + ), + ) + if __name__ == "__main__": unittest.main()