ci: improve template comments so contributors don't delete the marker.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
<!-- noctalia-pr-template:v1 -->
|
||||
<!-- ^ Keep the marker line above this comment.
|
||||
|
||||
<!-- If this PR is not ready for review yet, please mark it as Draft. -->
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user