ci: improve template comments so contributors don't delete the marker.

This commit is contained in:
Lemmy
2026-08-01 09:10:36 -04:00
parent f302b4d432
commit f8767575e4
3 changed files with 99 additions and 32 deletions
@@ -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.")
@@ -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"<!--(?!\s*noctalia-pr-template:v1\s*-->).*?-->",
"",
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 `<!-- noctalia-pr-template:v1 -->`"],
)
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 `<!-- noctalia-pr-template:v1 -->`",
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 `<!-- noctalia-pr-template:v1 -->`"]
)
}
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()