scikit-learn/build_tools/get_comment.py

357 lines
11 KiB
Python

# This script is used to generate a comment for a PR when linting issues are
# detected. It is used by the `Comment on failed linting` GitHub Action.
# This script fails if there are not comments to be posted.
import os
import requests
def get_versions(versions_file):
"""Get the versions of the packages used in the linter job.
Parameters
----------
versions_file : str
The path to the file that contains the versions of the packages.
Returns
-------
versions : dict
A dictionary with the versions of the packages.
"""
with open("versions.txt", "r") as f:
return dict(line.strip().split("=") for line in f)
def get_step_message(log, start, end, title, message, details):
"""Get the message for a specific test.
Parameters
----------
log : str
The log of the linting job.
start : str
The string that marks the start of the test.
end : str
The string that marks the end of the test.
title : str
The title for this section.
message : str
The message to be added at the beginning of the section.
details : bool
Whether to add the details of each step.
Returns
-------
message : str
The message to be added to the comment.
"""
if end not in log:
return ""
res = (
"-----------------------------------------------\n"
+ f"### {title}\n\n"
+ message
+ "\n\n"
)
if details:
res += (
"<details>\n\n```\n"
+ log[log.find(start) + len(start) + 1 : log.find(end) - 1]
+ "\n```\n\n</details>\n\n"
)
return res
def get_message(log_file, repo, pr_number, sha, run_id, details, versions):
with open(log_file, "r") as f:
log = f.read()
sub_text = (
"\n\n<sub> _Generated for commit:"
f" [{sha[:7]}](https://github.com/{repo}/pull/{pr_number}/commits/{sha}). "
"Link to the linter CI: [here]"
f"(https://github.com/{repo}/actions/runs/{run_id})_ </sub>"
)
if "### Linting completed ###" not in log:
return (
"## ❌ Linting issues\n\n"
"There was an issue running the linter job. Please update with "
"`upstream/main` ([link]("
"https://scikit-learn.org/dev/developers/contributing.html"
"#how-to-contribute)) and push the changes. If you already have done "
"that, please send an empty commit with `git commit --allow-empty` "
"and push the changes to trigger the CI.\n\n" + sub_text
)
message = ""
# black
message += get_step_message(
log,
start="### Running black ###",
end="Problems detected by black",
title="`black`",
message=(
"`black` detected issues. Please run `black .` locally and push "
"the changes. Here you can see the detected issues. Note that "
"running black might also fix some of the issues which might be "
"detected by `ruff`. Note that the installed `black` version is "
f"`black={versions['black']}`."
),
details=details,
)
# ruff
message += get_step_message(
log,
start="### Running ruff ###",
end="Problems detected by ruff",
title="`ruff`",
message=(
"`ruff` detected issues. Please run "
"`ruff check --fix --output-format=full .` locally, fix the remaining "
"issues, and push the changes. Here you can see the detected issues. Note "
f"that the installed `ruff` version is `ruff={versions['ruff']}`."
),
details=details,
)
# mypy
message += get_step_message(
log,
start="### Running mypy ###",
end="Problems detected by mypy",
title="`mypy`",
message=(
"`mypy` detected issues. Please fix them locally and push the changes. "
"Here you can see the detected issues. Note that the installed `mypy` "
f"version is `mypy={versions['mypy']}`."
),
details=details,
)
# cython-lint
message += get_step_message(
log,
start="### Running cython-lint ###",
end="Problems detected by cython-lint",
title="`cython-lint`",
message=(
"`cython-lint` detected issues. Please fix them locally and push "
"the changes. Here you can see the detected issues. Note that the "
"installed `cython-lint` version is "
f"`cython-lint={versions['cython-lint']}`."
),
details=details,
)
# deprecation order
message += get_step_message(
log,
start="### Checking for bad deprecation order ###",
end="Problems detected by deprecation order check",
title="Deprecation Order",
message=(
"Deprecation order check detected issues. Please fix them locally and "
"push the changes. Here you can see the detected issues."
),
details=details,
)
# doctest directives
message += get_step_message(
log,
start="### Checking for default doctest directives ###",
end="Problems detected by doctest directive check",
title="Doctest Directives",
message=(
"doctest directive check detected issues. Please fix them locally and "
"push the changes. Here you can see the detected issues."
),
details=details,
)
# joblib imports
message += get_step_message(
log,
start="### Checking for joblib imports ###",
end="Problems detected by joblib import check",
title="Joblib Imports",
message=(
"`joblib` import check detected issues. Please fix them locally and "
"push the changes. Here you can see the detected issues."
),
details=details,
)
if not message:
# no issues detected, so this script "fails"
return (
"## ✔️ Linting Passed\n"
"All linting checks passed. Your pull request is in excellent shape! ☀️"
+ sub_text
)
if not details:
# This happens if posting the log fails, which happens if the log is too
# long. Typically, this happens if the PR branch hasn't been updated
# since we've introduced import sorting.
branch_not_updated = (
"_Merging with `upstream/main` might fix / improve the issues if you "
"haven't done that since 21.06.2023._\n\n"
)
else:
branch_not_updated = ""
message = (
"## ❌ Linting issues\n\n"
+ branch_not_updated
+ "This PR is introducing linting issues. Here's a summary of the issues. "
+ "Note that you can avoid having linting issues by enabling `pre-commit` "
+ "hooks. Instructions to enable them can be found [here]("
+ "https://scikit-learn.org/dev/developers/contributing.html#how-to-contribute)"
+ ".\n\n"
+ "You can see the details of the linting issues under the `lint` job [here]"
+ f"(https://github.com/{repo}/actions/runs/{run_id})\n\n"
+ message
+ sub_text
)
return message
def get_headers(token):
"""Get the headers for the GitHub API."""
return {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
def find_lint_bot_comments(repo, token, pr_number):
"""Get the comment from the linting bot."""
# repo is in the form of "org/repo"
# API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#list-issue-comments # noqa
response = requests.get(
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
headers=get_headers(token),
)
response.raise_for_status()
all_comments = response.json()
failed_comment = "❌ Linting issues"
success_comment = "✔️ Linting Passed"
# Find all comments that match the linting bot, and return the first one.
# There should always be only one such comment, or none, if the PR is
# just created.
comments = [
comment
for comment in all_comments
if comment["user"]["login"] == "github-actions[bot]"
and (failed_comment in comment["body"] or success_comment in comment["body"])
]
if len(all_comments) > 25 and not comments:
# By default the API returns the first 30 comments. If we can't find the
# comment created by the bot in those, then we raise and we skip creating
# a comment in the first place.
raise RuntimeError("Comment not found in the first 30 comments.")
return comments[0] if comments else None
def create_or_update_comment(comment, message, repo, pr_number, token):
"""Create a new comment or update existing one."""
# repo is in the form of "org/repo"
if comment is not None:
print("updating existing comment")
# API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#update-an-issue-comment # noqa
response = requests.patch(
f"https://api.github.com/repos/{repo}/issues/comments/{comment['id']}",
headers=get_headers(token),
json={"body": message},
)
else:
print("creating new comment")
# API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment # noqa
response = requests.post(
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
headers=get_headers(token),
json={"body": message},
)
response.raise_for_status()
if __name__ == "__main__":
repo = os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITHUB_TOKEN"]
pr_number = os.environ["PR_NUMBER"]
sha = os.environ["BRANCH_SHA"]
log_file = os.environ["LOG_FILE"]
run_id = os.environ["RUN_ID"]
versions_file = os.environ["VERSIONS_FILE"]
versions = get_versions(versions_file)
if not repo or not token or not pr_number or not log_file or not run_id:
raise ValueError(
"One of the following environment variables is not set: "
"GITHUB_REPOSITORY, GITHUB_TOKEN, PR_NUMBER, LOG_FILE, RUN_ID"
)
try:
comment = find_lint_bot_comments(repo, token, pr_number)
except RuntimeError:
print("Comment not found in the first 30 comments. Skipping!")
exit(0)
try:
message = get_message(
log_file,
repo=repo,
pr_number=pr_number,
sha=sha,
run_id=run_id,
details=True,
versions=versions,
)
create_or_update_comment(
comment=comment,
message=message,
repo=repo,
pr_number=pr_number,
token=token,
)
print(message)
except requests.HTTPError:
# The above fails if the message is too long. In that case, we
# try again without the details.
message = get_message(
log_file,
repo=repo,
pr_number=pr_number,
sha=sha,
run_id=run_id,
details=False,
versions=versions,
)
create_or_update_comment(
comment=comment,
message=message,
repo=repo,
pr_number=pr_number,
token=token,
)
print(message)