|  | # Copyright 2022 The Bazel Authors. All rights reserved. | 
|  | # | 
|  | # Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | # you may not use this file except in compliance with the License. | 
|  | # You may obtain a copy of the License at | 
|  | # | 
|  | #    http://www.apache.org/licenses/LICENSE-2.0 | 
|  | # | 
|  | # Unless required by applicable law or agreed to in writing, software | 
|  | # distributed under the License is distributed on an "AS IS" BASIS, | 
|  | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | # See the License for the specific language governing permissions and | 
|  | # limitations under the License. | 
|  |  | 
|  | """Script to generate release notes.""" | 
|  |  | 
|  | import re | 
|  | import subprocess | 
|  | import sys | 
|  | import requests | 
|  |  | 
|  |  | 
|  | def git(*args): | 
|  | """Runs git as a subprocess, and returns its stdout as a list of lines.""" | 
|  | return subprocess.check_output(["git"] + | 
|  | list(args)).decode("utf-8").strip().split("\n") | 
|  |  | 
|  |  | 
|  | def extract_title(commit_message_lines): | 
|  | """Extracts first line from commit message (passed in as a list of lines).""" | 
|  | return re.sub( | 
|  | r"\[\d+\.\d+\.\d\]\s?", "", commit_message_lines[0].strip() | 
|  | ) | 
|  |  | 
|  |  | 
|  | def extract_relnotes(commit_message_lines): | 
|  | """Extracts relnotes from a commit message (passed in as a list of lines).""" | 
|  | relnote_lines = [] | 
|  | in_relnote = False | 
|  |  | 
|  | title = extract_title(commit_message_lines) | 
|  | issue_id = re.search(r"\(\#[0-9]+\)$", title.strip().split()[-1]) | 
|  |  | 
|  | for line in commit_message_lines: | 
|  | line = line.strip() | 
|  | if ( | 
|  | not line | 
|  | or line.startswith("PiperOrigin-RevId:") | 
|  | or re.match(r"^\s*(Fixes|Closes)\s+#\d+\.?\s*$", line) | 
|  | ): | 
|  | in_relnote = False | 
|  | m = re.match(r"^RELNOTES(?:\[(INC|NEW)\])?:", line) | 
|  | if m is not None: | 
|  | in_relnote = True | 
|  | line = line[len(m[0]) :] | 
|  | if line.strip().lower().rstrip(".") in ["n/a", "na", "none"]: | 
|  | return None | 
|  | if m[1] == "INC": | 
|  | line = "**[Incompatible]** " + line.strip() | 
|  | line = line.strip() | 
|  | if in_relnote and line: | 
|  | relnote_lines.append(line) | 
|  | relnote = " ".join(relnote_lines) | 
|  |  | 
|  | if issue_id and relnote: | 
|  | relnote += " " + issue_id.group(0).strip() | 
|  |  | 
|  | return relnote | 
|  |  | 
|  |  | 
|  | def get_relnotes_between(base, head, is_patch_release): | 
|  | """Gets all relnotes for commits between `base` and `head`.""" | 
|  | commits = git("rev-list", f"{base}..{head}") | 
|  | if commits == [""]: | 
|  | return [] | 
|  | relnotes = [] | 
|  | rolled_back_commits = set() | 
|  | # We go in reverse-chronological order, so that we can identify rollback | 
|  | # commits and ignore the rolled-back commits. | 
|  | for commit in commits: | 
|  | if commit in rolled_back_commits: | 
|  | continue | 
|  | lines = git("show", "-s", commit, "--pretty=format:%B") | 
|  | m = re.match(r"^Automated rollback of commit ([\dA-Fa-f]+)", lines[0]) | 
|  | if m is not None: | 
|  | rolled_back_commits.add(m[1]) | 
|  | # The rollback commit itself is also skipped. | 
|  | continue | 
|  | relnote = ( | 
|  | extract_title(lines) if is_patch_release else extract_relnotes(lines) | 
|  | ) | 
|  | if relnote is not None: | 
|  | relnotes.append(relnote) | 
|  | return relnotes | 
|  |  | 
|  |  | 
|  | def get_label(issue_id): | 
|  | """Get team-X label added to issue.""" | 
|  | auth = subprocess.check_output( | 
|  | "gsutil cat" | 
|  | " gs://bazel-trusted-encrypted-secrets/github-trusted-token.enc |" | 
|  | " gcloud kms decrypt --project bazel-public --location global" | 
|  | " --keyring buildkite --key github-trusted-token --ciphertext-file" | 
|  | " - --plaintext-file -", shell=True | 
|  | ).decode("utf-8").strip().split("\n")[0] | 
|  | headers = { | 
|  | "Authorization": "Bearer " + auth, | 
|  | "Accept": "application/vnd.github+json", | 
|  | } | 
|  | response = requests.get( | 
|  | "https://api.github.com/repos/bazelbuild/bazel/issues/" | 
|  | + issue_id + "/labels", headers=headers, | 
|  | ) | 
|  | for item in response.json(): | 
|  | for key, value in item.items(): | 
|  | if key == "name" and "team-" in value: | 
|  | return value.strip() | 
|  | return None | 
|  |  | 
|  |  | 
|  | def get_categorized_relnotes(filtered_notes): | 
|  | """Sort release notes by category.""" | 
|  | categorized_relnotes = {} | 
|  | for relnote in filtered_notes: | 
|  | issue_id = re.search(r"\(\#[0-9]+\)$", relnote.strip().split()[-1]) | 
|  | category = None | 
|  | if issue_id: | 
|  | category = get_label(re.sub(r"\(|\#|\)", "", issue_id.group(0).strip())) | 
|  |  | 
|  | if category is None: | 
|  | category = "General" | 
|  | else: | 
|  | category = re.sub("team-", "", category) | 
|  |  | 
|  | try: | 
|  | categorized_relnotes[category].append(relnote) | 
|  | except KeyError: | 
|  | categorized_relnotes[category] = [relnote] | 
|  |  | 
|  | return dict(sorted(categorized_relnotes.items())) | 
|  |  | 
|  |  | 
|  | def get_external_authors_between(base, head): | 
|  | """Gets all external authors for commits between `base` and `head`.""" | 
|  |  | 
|  | # Get all authors | 
|  | authors = git("log", f"{base}..{head}", "--format=%aN|%aE") | 
|  | authors = set( | 
|  | author.partition("|")[0].rstrip() | 
|  | for author in authors if not (author.endswith(("@google.com")))) | 
|  |  | 
|  | # Get all co-authors | 
|  | contributors = git( | 
|  | "log", f"{base}..{head}", "--format=%(trailers:key=Co-authored-by)" | 
|  | ) | 
|  |  | 
|  | coauthors = [] | 
|  | for coauthor in contributors: | 
|  | if coauthor and not re.search("@google.com", coauthor): | 
|  | coauthors.append( | 
|  | " ".join(re.sub(r"Co-authored-by: |<.*?>", "", coauthor).split()) | 
|  | ) | 
|  | return ", ".join(sorted(authors.union(coauthors), key=str.casefold)) | 
|  |  | 
|  |  | 
|  | if __name__ == "__main__": | 
|  | # Get last release and make sure it's consistent with current X.Y.Z release | 
|  | # e.g. if current_release is 5.3.3, last_release should be 5.3.2 even if | 
|  | # latest release is 6.1.1 | 
|  | current_release = git("rev-parse", "--abbrev-ref", "HEAD")[0] | 
|  |  | 
|  | if current_release.startswith("release-"): | 
|  | current_release = re.sub(r"rc\d", "", current_release[len("release-"):]) | 
|  | else: | 
|  | try: | 
|  | current_release = git("describe", "--tags")[0] | 
|  | except Exception:  # pylint: disable=broad-exception-caught | 
|  | print("Error: Not a release branch.") | 
|  | sys.exit(1) | 
|  |  | 
|  | is_patch = not current_release.endswith(".0") | 
|  |  | 
|  | tags = [tag for tag in git("tag", "--sort=refname") if "pre" not in tag] | 
|  |  | 
|  | # Get the baseline for RCs (before release tag is created) | 
|  | if current_release not in tags: | 
|  | tags.append(current_release) | 
|  |  | 
|  | tags.sort() | 
|  | last_release = tags[tags.index(current_release) - 1] | 
|  |  | 
|  | # Assuming HEAD is on the current (to-be-released) release, find the merge | 
|  | # base with the last release so that we know which commits to generate notes | 
|  | # for. | 
|  | merge_base = git("merge-base", "HEAD", last_release)[0] | 
|  |  | 
|  | # Generate notes for all commits from last branch cut to HEAD, but filter out | 
|  | # any identical notes from the previous release branch. | 
|  | cur_release_relnotes = get_relnotes_between( | 
|  | merge_base, "HEAD", is_patch | 
|  | ) | 
|  | last_release_relnotes = set( | 
|  | get_relnotes_between(merge_base, last_release, is_patch) | 
|  | ) | 
|  | filtered_relnotes = [ | 
|  | note for note in cur_release_relnotes if note not in last_release_relnotes | 
|  | ] | 
|  |  | 
|  | # Reverse so that the notes are in chronological order. | 
|  | filtered_relnotes.reverse() | 
|  | print() | 
|  | print("Release Notes:") | 
|  | print() | 
|  |  | 
|  | categorized_release_notes = get_categorized_relnotes(filtered_relnotes) | 
|  | for label in categorized_release_notes: | 
|  | print(label + ":") | 
|  | for note in categorized_release_notes[label]: | 
|  | print("+", note) | 
|  | print() | 
|  |  | 
|  | print() | 
|  | print("Acknowledgements:") | 
|  | external_authors = get_external_authors_between(merge_base, "HEAD") | 
|  | print() | 
|  | print("This release contains contributions from many people at Google" + | 
|  | ("." if not external_authors else f", as well as {external_authors}.")) |