# Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. """GitHub Label Utilities.""" import json from functools import lru_cache from typing import Any, List, Tuple, TYPE_CHECKING, Union from github_utils import gh_fetch_url_and_headers, GitHubComment # TODO: this is a temp workaround to avoid circular dependencies, # and should be removed once GitHubPR is refactored out of trymerge script. if TYPE_CHECKING: from trymerge import GitHubPR BOT_AUTHORS = ["github-actions", "pytorchmergebot", "pytorch-bot"] LABEL_ERR_MSG_TITLE = "This PR needs a `release notes:` label" LABEL_ERR_MSG = f"""# {LABEL_ERR_MSG_TITLE} If your changes are user facing and intended to be a part of release notes, please use a label starting with `release notes:`. If not, please add the `topic: not user facing` label. To add a label, you can comment to pytorchbot, for example `@pytorchbot label "topic: not user facing"` For more information, see https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work. """ def request_for_labels(url: str) -> Tuple[Any, Any]: headers = {"Accept": "application/vnd.github.v3+json"} return gh_fetch_url_and_headers( url, headers=headers, reader=lambda x: x.read().decode("utf-8") ) def update_labels(labels: List[str], info: str) -> None: labels_json = json.loads(info) labels.extend([x["name"] for x in labels_json]) def get_last_page_num_from_header(header: Any) -> int: # Link info looks like: ; # rel="next", ; rel="last" link_info = header["link"] # Docs does not specify that it should be present for projects with just few labels # And https://github.com/malfet/deleteme/actions/runs/7334565243/job/19971396887 it's not the case if link_info is None: return 1 prefix = "&page=" suffix = ">;" return int( link_info[link_info.rindex(prefix) + len(prefix) : link_info.rindex(suffix)] ) @lru_cache def gh_get_labels(org: str, repo: str) -> List[str]: prefix = f"https://api.github.com/repos/{org}/{repo}/labels?per_page=100" header, info = request_for_labels(prefix + "&page=1") labels: List[str] = [] update_labels(labels, info) last_page = get_last_page_num_from_header(header) assert ( last_page > 0 ), "Error reading header info to determine total number of pages of labels" for page_number in range(2, last_page + 1): # skip page 1 _, info = request_for_labels(prefix + f"&page={page_number}") update_labels(labels, info) return labels def gh_add_labels( org: str, repo: str, pr_num: int, labels: Union[str, List[str]], dry_run: bool ) -> None: if dry_run: print(f"Dryrun: Adding labels {labels} to PR {pr_num}") return gh_fetch_url_and_headers( url=f"https://api.github.com/repos/{org}/{repo}/issues/{pr_num}/labels", data={"labels": labels}, ) def gh_remove_label( org: str, repo: str, pr_num: int, label: str, dry_run: bool ) -> None: if dry_run: print(f"Dryrun: Removing {label} from PR {pr_num}") return gh_fetch_url_and_headers( url=f"https://api.github.com/repos/{org}/{repo}/issues/{pr_num}/labels/{label}", method="DELETE", ) def get_release_notes_labels(org: str, repo: str) -> List[str]: return [ label for label in gh_get_labels(org, repo) if label.lstrip().startswith("release notes:") ] def has_required_labels(pr: "GitHubPR") -> bool: pr_labels = pr.get_labels() # Check if PR is not user facing is_not_user_facing_pr = any( label.strip() == "topic: not user facing" for label in pr_labels ) return is_not_user_facing_pr or any( label.strip() in get_release_notes_labels(pr.org, pr.project) for label in pr_labels ) def is_label_err_comment(comment: GitHubComment) -> bool: # comment.body_text returns text without markdown no_format_title = LABEL_ERR_MSG_TITLE.replace("`", "") return ( comment.body_text.lstrip(" #").startswith(no_format_title) and comment.author_login in BOT_AUTHORS )