Add the reporting step and the reusable english-comments workflow
tools/english_comments_report.py keeps one comment on the pull request that mentions the author, updates it on a re-run and marks it resolved when the check passes. english-comments.yml runs the check on the full history, tells the author even when the check fails, and then fails the run. A script that no release holds yet no longer counts as missing from the default tools-ref. Refs #201 Refs #202 Task: MIL-013#5 Task: MIL-013#6
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""Tell the author of a pull request which comments are not in English (MIL-013).
|
||||
|
||||
Run by .gitea/workflows/english-comments.yml after the check. It reads the
|
||||
findings file of tools/english_comments.py and keeps ONE comment on the pull
|
||||
request:
|
||||
|
||||
- findings: the comment mentions the author and lists each finding; an
|
||||
existing comment of this check is updated, never a second one added;
|
||||
- no findings: an existing comment of this check is marked resolved (and no
|
||||
longer mentions the author); with none nothing is written.
|
||||
|
||||
Settings come from the environment:
|
||||
|
||||
GITEA_API_URL Gitea API base, for example https://host/api/v1
|
||||
SOURCE_REPOSITORY owner/repo of the pull request
|
||||
PR_NUMBER the number of the pull request
|
||||
PR_AUTHOR the login of the author, mentioned in the comment
|
||||
REPORT_TOKEN a token that may comment on pull requests (a CI secret);
|
||||
without it nothing is written and the step ends with a
|
||||
warning (the check run itself still fails)
|
||||
FINDINGS_FILE the JSON file written by english_comments.py --output
|
||||
RUN_URL optional, linked in the comment
|
||||
|
||||
Every failure is printed as ``::error::[category] message`` with the categories
|
||||
configuration, credentials, permissions and api; the token is never printed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
MARKER = "<!-- english-comments-check -->"
|
||||
REQUEST_TIMEOUT_SECONDS = 30
|
||||
PAGE_SIZE = 50
|
||||
MAX_ROWS = 30
|
||||
|
||||
|
||||
class ReportError(RuntimeError):
|
||||
"""A failure with a category: configuration, credentials, permissions or api."""
|
||||
|
||||
def __init__(self, category: str, message: str) -> None:
|
||||
super().__init__(f"[{category}] {message}")
|
||||
|
||||
|
||||
def request_json(url, method="GET", headers=None, body=None):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8") if body is not None else None,
|
||||
headers=headers or {},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
|
||||
content = response.read()
|
||||
return json.loads(content) if content else None
|
||||
except urllib.error.HTTPError as error:
|
||||
target = url.split("?")[0]
|
||||
detail = f"Gitea API request failed with HTTP {error.code} ({error.reason}) for {method} {target}"
|
||||
if error.code == 401:
|
||||
raise ReportError("credentials", f"{detail}. The token was rejected.") from None
|
||||
if error.code == 403:
|
||||
raise ReportError(
|
||||
"permissions", f"{detail}. The token may not comment on pull requests."
|
||||
) from None
|
||||
raise ReportError("api", detail) from None
|
||||
except (urllib.error.URLError, TimeoutError) as error:
|
||||
raise ReportError("api", f"Gitea API is unreachable ({error}).") from None
|
||||
|
||||
|
||||
def _cell(text: str, limit: int = 100) -> str:
|
||||
"""Text safe for a table cell: no pipes, line breaks, mentions or backticks."""
|
||||
text = " ".join(text.split()).replace("|", "\\|").replace("`", "'").replace("@", "@")
|
||||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def failing_body(author: str, findings: list[dict], run_url: str = "") -> str:
|
||||
rows = [
|
||||
f"| `{_cell(f['path'], 80)}` | {f['line']} | {_cell(str(f['language']))} {f['probability']} | {_cell(f['text'])} |"
|
||||
for f in findings[:MAX_ROWS]
|
||||
]
|
||||
more = len(findings) - MAX_ROWS
|
||||
lines = [
|
||||
MARKER,
|
||||
f"@{author}, the **English comments** check found {len(findings)} comment(s) that do not look "
|
||||
"like English. Source code comments are written in English only.",
|
||||
"",
|
||||
"| File | Line | Looks like | Comment |",
|
||||
"| --- | --- | --- | --- |",
|
||||
*rows,
|
||||
]
|
||||
if more > 0:
|
||||
lines.append(f"\n...and {more} more; see the run.")
|
||||
lines += [
|
||||
"",
|
||||
"Rewrite the comment in English. For a quotation or a name, add `english-comments: ignore` "
|
||||
"to the comment, or add the word to the allowed-words file.",
|
||||
]
|
||||
if run_url:
|
||||
lines.append(f"\n[The run]({run_url})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def resolved_body() -> str:
|
||||
return f"{MARKER}\nThe **English comments** check passes: every checked comment is in English."
|
||||
|
||||
|
||||
def find_comment(api, headers, request, repository, number):
|
||||
"""Return the newest comment of this check on the pull request, or None."""
|
||||
found = None
|
||||
page = 1
|
||||
while True:
|
||||
url = f"{api}/repos/{repository}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}"
|
||||
comments = request(url, "GET", headers, None) or []
|
||||
for comment in comments:
|
||||
if MARKER in (comment.get("body") or ""):
|
||||
found = comment
|
||||
if len(comments) < PAGE_SIZE:
|
||||
return found
|
||||
page += 1
|
||||
|
||||
|
||||
def main(request=request_json) -> int:
|
||||
env = os.environ
|
||||
token = env.get("REPORT_TOKEN", "")
|
||||
try:
|
||||
missing = [
|
||||
name
|
||||
for name in ("GITEA_API_URL", "SOURCE_REPOSITORY", "PR_NUMBER", "PR_AUTHOR", "FINDINGS_FILE")
|
||||
if not env.get(name)
|
||||
]
|
||||
if missing:
|
||||
raise ReportError("configuration", f"{', '.join(missing)} not set.")
|
||||
if not os.path.exists(env["FINDINGS_FILE"]):
|
||||
print("::warning::The check wrote no findings file (it did not run); nothing to report.")
|
||||
return 0
|
||||
try:
|
||||
with open(env["FINDINGS_FILE"], encoding="utf-8") as handle:
|
||||
findings = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
raise ReportError("configuration", f"findings file '{env['FINDINGS_FILE']}' cannot be read.") from None
|
||||
if not token:
|
||||
print("::warning::No REPORT_TOKEN: the pull request is not commented. The check itself still decides.")
|
||||
return 0
|
||||
api = env["GITEA_API_URL"].rstrip("/")
|
||||
repository = env["SOURCE_REPOSITORY"]
|
||||
number = env["PR_NUMBER"]
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
existing = find_comment(api, headers, request, repository, number)
|
||||
if findings:
|
||||
body = failing_body(env["PR_AUTHOR"], findings, env.get("RUN_URL", ""))
|
||||
elif existing:
|
||||
body = resolved_body()
|
||||
else:
|
||||
print("No findings and no earlier comment: nothing to write.")
|
||||
return 0
|
||||
if existing:
|
||||
if existing.get("body") != body:
|
||||
request(f"{api}/repos/{repository}/issues/comments/{existing['id']}", "PATCH", headers, {"body": body})
|
||||
print("Updated the comment of the check.")
|
||||
else:
|
||||
request(f"{api}/repos/{repository}/issues/{number}/comments", "POST", headers, {"body": body})
|
||||
print("Wrote a comment on the pull request.")
|
||||
return 0
|
||||
except ReportError as error:
|
||||
print(f"::error::{error}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user