Skip to content
163 changes: 89 additions & 74 deletions templates/_templating_scripting.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
"""
import argparse
import contextlib
from enum import StrEnum
from hashlib import sha256
import json
from pathlib import Path
import re
import shlex
from subprocess import CalledProcessError, check_output, run
from tempfile import NamedTemporaryFile
from typing import NamedTuple
from typing import NamedTuple, Optional
from urllib.parse import urlparse

# A mechanism for disabling the issues and comments if the dev team is
Expand All @@ -21,21 +23,36 @@
TEMPLATES_DIR = Path(__file__).parent.resolve()
TEMPLATE_REPO_ROOT = TEMPLATES_DIR.parent
# ensure any new bots have both a "app/" prefix and a "[bot]" postfix version
BOTS = ["dependabot[bot]", "app/dependabot", "pre-commit-ci[bot]", "app/pre-commit-ci"]
BOTS = [
"dependabot[bot]",
"app/dependabot",
"pre-commit-ci[bot]",
"app/pre-commit-ci",
"app/scitools-ci",
]
TEMPLATING_HEADING = f"## [Templating]({SCITOOLS_URL}/.github/blob/main/templates)"

_MAGIC_PREFIX = "@scitools-templating: please"
MAGIC_NO_PROMPT = re.compile(rf"{_MAGIC_PREFIX} no share prompt")
MAGIC_NO_NOTIFY = re.compile(rf"{_MAGIC_PREFIX} no update notification on: ([\w-]+)")
MAGIC_NO_PROMPT = re.compile(rf"{_MAGIC_PREFIX} no share prompt", re.IGNORECASE)
MAGIC_NO_NOTIFY = re.compile(rf"{_MAGIC_PREFIX} no update notification on: ([\w-]+)", re.IGNORECASE)


class ReviewType(StrEnum):
APPROVE = "approve"
COMMENT = "comment"
REQUEST_CHANGES = "request-changes"


def git_command(command: str) -> str:
command = shlex.split(f"git {command}")
return check_output(command).decode("utf-8").strip()


def gh_json(sub_command: str, field: str) -> dict:
command = shlex.split(f"gh {sub_command} --json {field}")
return json.loads(check_output(command))
def gh_json(sub_command: str, field: Optional[str] = None) -> dict:
command = f"gh {sub_command}"
if field:
command += f" --json {field}"
return json.loads(check_output(shlex.split(command)))


class Config:
Expand Down Expand Up @@ -145,6 +162,8 @@ def git_diff(*args: str) -> str:
file_url = f"{SCITOOLS_URL}/{repo}/blob/main/{path_in_repo}"
file_link = f"[`{path_in_repo}`]({file_url})"
issue_body = (
f"{TEMPLATING_HEADING}\n\n"

f"The template for `{path_in_repo}` has been updated; see the "
"diff below. Please either:\n\n"

Expand Down Expand Up @@ -206,7 +225,9 @@ def prompt_share(args: argparse.Namespace) -> None:

pr_number = args.pr_number
# Can use a URL here for local debugging:
# pr_number = "https://github.com/SciTools/iris/pull/6496"
# pr_number = "https://github.com/SciTools/iris/pull/6901"

current_user = gh_json("api user")["login"]

body = gh_json(f"pr view {pr_number}", "body")["body"]
if MAGIC_NO_PROMPT.search(body):
Expand All @@ -220,16 +241,9 @@ def split_github_url(url: str) -> tuple[str, str, str]:
_, org, repo, _, ref = urlparse(url).path.split("/")
return org, repo, ref

def url_to_short_ref(url: str) -> str:
org, repo, ref = split_github_url(url)
return f"{org}/{repo}#{ref}"

pr_url = gh_json(f"pr view {pr_number}", "url")["url"]
pr_short_ref = url_to_short_ref(pr_url)
pr_repo = split_github_url(pr_url)[1]

author = gh_json(f"pr view {pr_number}", "author")["author"]["login"]

changed_files = gh_json(f"pr view {pr_number}", "files")["files"]
changed_paths = [Path(file["path"]) for file in changed_files]

Expand All @@ -249,72 +263,72 @@ def get_all_authors() -> set[str]:
for commit_author in get_commit_authors(commit)
)

def post_review(review_body: str, review_type: ReviewType) -> None:
pr_int = pr_number
if pr_int == pr_url:
# Sometimes happens during local debugging.
pr_int = gh_json(f"pr view {pr_number}", "number")["number"]

# Find any existing templating reviews. Edit the last one if found.
gh_command = f"gh api repos/SciTools/{pr_repo}/pulls/{pr_int}/reviews"
existing_reviews = json.loads(check_output(shlex.split(gh_command)))
reviews_to_edit = [
review for review in existing_reviews
if review["user"]["login"] == current_user
and review["body"].startswith(TEMPLATING_HEADING)
]
if reviews_to_edit:
# Edit the last existing review.
review = reviews_to_edit[-1]
payload = json.dumps({"body": review_body})
gh_command = (
f"gh api --method PUT "
f"repos/SciTools/{pr_repo}/pulls/{pr_int}/reviews/{review['id']} "
f"--input -"
)
run(shlex.split(gh_command), input=payload.encode(), check=True)

else:
# Create a new review.
with NamedTemporaryFile("w") as file_write:
file_write.write(review_body)
file_write.flush()
gh_command = (
f"gh pr review {pr_number} --{review_type.value} "
f"--body-file {file_write.name}"
)
run(shlex.split(gh_command), check=True)

human_authors = get_all_authors() - set(BOTS)
if human_authors == set():
review_body = (
f"### [Templating]({SCITOOLS_URL}/.github/blob/main/templates)\n\n"
review_text = (
f"{TEMPLATING_HEADING}\n\n"
"Version numbers are not typically covered by templating. It is "
"expected that this PR is 100% about advancing version numbers, "
"which would not require any templating follow-up. **Please double-"
"check for any other changes that might be suitable for "
"templating**."
)
with NamedTemporaryFile("w") as file_write:
file_write.write(review_body)
file_write.flush()
gh_command = shlex.split(
f"gh pr review {pr_number} --comment --body-file {file_write.name}"
)
run(gh_command, check=True)
post_review(review_text, ReviewType.COMMENT)
return

def create_issue(title: str, body: str) -> None:
assignee = author

# Check that an issue with this title isn't already on the .github repo.
existing_issues = gh_json(
"issue list --state all --repo SciTools/.github", "title"
)
if any(issue["title"] == title for issue in existing_issues):
return

if assignee in BOTS:
# if the author is a bot, we don't want to assign the issue to the bot
# so instead choose a human author from the latest commit
assignee = list(human_authors)[0]

with NamedTemporaryFile("w") as file_write:
file_write.write(body)
file_write.flush()
gh_command = shlex.split(
"gh issue create "
f'--title "{title}" '
f"--body-file {file_write.name} "
"--repo SciTools/.github "
f"--assignee {assignee}"
)
issue_url = check_output(gh_command).decode("utf-8").strip()
short_ref = url_to_short_ref(issue_url)
# GitHub renders the full text of a cross-ref when it is in a list.
review_body = f"- [ ] Please see: {short_ref}"
gh_command = shlex.split(
f'gh pr review {pr_number} --request-changes --body "{review_body}"'
)
run(gh_command, check=True)

issue_title = f"Share {pr_short_ref} changes via templating?"

templates_relative = TEMPLATES_DIR.relative_to(TEMPLATE_REPO_ROOT)
templates_url = f"{SCITOOLS_URL}/.github/tree/main/{templates_relative}"
body_intro = (
f"## [Templating]({SCITOOLS_URL}/.github/blob/main/templates/README.md)\n\n"
f"{pr_short_ref} (by @{author}) includes changes that may be worth "
f"{TEMPLATING_HEADING}\n\n"
f"This PR includes changes that may be worth "
"sharing via templating. For each file listed below, please "
"either:\n\n"
"- Action the suggestion via a pull request editing/adding the "
f"relevant file in the [templates directory]({templates_url}). [^1]\n"
f"relevant file in the [SciTools/.github `templates/` directory]({templates_url}). [^1]\n"
f"- Raise an issue against the [SciTools/.github repo]({SCITOOLS_URL}/.github) "
"for the above action if you _really_ don't have 10mins spare right now. "
"**Include an assignee**, to avoid it being forgotten.\n"
"- Dismiss the suggestion if the changes are not suitable for "
"templating."
"templating.\n\n"
"You will need to dismiss this review before this PR can be merged. "
"**Recommend the reviewer does this as their final action before "
"merging**, as this text will continually update as commits come in."
)

templated_list = []
Expand All @@ -337,15 +351,20 @@ def create_issue(title: str, body: str) -> None:
ignored = str(changed_path) in ignore_dict[pr_repo]
if ignored:
continue

changed_hash = sha256(str(changed_path).encode()).hexdigest()
changed_url = f"{pr_url}/files#diff-{changed_hash}"
changed_link = f"[`{changed_path}`]({changed_url})"

if is_templated:
template_relative = template.relative_to(TEMPLATE_REPO_ROOT)
template_url = (
f"{SCITOOLS_URL}/.github/blob/main/{template_relative}"
)
template_link = f"[`{template_relative}`]({template_url})"
template_link = f"[`SciTools/.github/{template_relative}`]({template_url})"

templated_list.append(
f"- [ ] `{changed_path}`, templated by {template_link}"
f"- [ ] {changed_link}, templated by {template_link}"
)

else:
Expand All @@ -359,13 +378,9 @@ def create_issue(title: str, body: str) -> None:
if changed_parent in (
git_root,
git_root / "benchmarks",
git_root / "docs" / "src",
):
candidates_list.append(f"- [ ] `{changed_path}`")
if changed_path in (
git_root / "docs" / "src" / "conf.py",
git_root / "docs" / "src" / "Makefile",
):
candidates_list.append(f"- [ ] `{changed_path}`")
candidates_list.append(f"- [ ] {changed_link}")

if templated_list or candidates_list:
body_args = [body_intro]
Expand All @@ -385,8 +400,8 @@ def create_issue(title: str, body: str) -> None:
f"``{pattern_repo}``"
)

issue_body = "\n".join(body_args)
create_issue(issue_title, issue_body)
review_text= "\n".join(body_args)
post_review(review_text, ReviewType.REQUEST_CHANGES)


def check_dir(args: argparse.Namespace) -> None:
Expand Down
Loading