blob: 0caf749ee9ccde41e0a0a8543e0afdbbacc6824a [file] [log] [blame]
Xdng Yngb0357bd2022-10-28 06:02:19 -07001# Copyright 2022 The Bazel Authors. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Script to generate release notes."""
16
17import re
18import subprocess
Googler332144a2023-05-12 16:12:04 -070019import sys
Xdng Yngb0357bd2022-10-28 06:02:19 -070020import requests
21
22
Xdng Yngb0357bd2022-10-28 06:02:19 -070023def git(*args):
24 """Runs git as a subprocess, and returns its stdout as a list of lines."""
25 return subprocess.check_output(["git"] +
26 list(args)).decode("utf-8").strip().split("\n")
27
28
Googler9a333bc2024-03-25 14:40:22 -070029def extract_title(commit_message_lines):
Googlerd2e8b7e2023-08-09 13:53:00 -070030 """Extracts first line from commit message (passed in as a list of lines)."""
31 return re.sub(
32 r"\[\d+\.\d+\.\d\]\s?", "", commit_message_lines[0].strip()
33 )
34
35
36def extract_relnotes(commit_message_lines):
Xdng Yngb0357bd2022-10-28 06:02:19 -070037 """Extracts relnotes from a commit message (passed in as a list of lines)."""
38 relnote_lines = []
39 in_relnote = False
Googler9a333bc2024-03-25 14:40:22 -070040
41 title = extract_title(commit_message_lines)
42 issue_id = re.search(r"\(\#[0-9]+\)$", title.strip().split()[-1])
43
Xdng Yngb0357bd2022-10-28 06:02:19 -070044 for line in commit_message_lines:
Googler9a333bc2024-03-25 14:40:22 -070045 line = line.strip()
46 if (
47 not line
48 or line.startswith("PiperOrigin-RevId:")
49 or re.match(r"^\s*(Fixes|Closes)\s+#\d+\.?\s*$", line)
50 ):
Xdng Yngb0357bd2022-10-28 06:02:19 -070051 in_relnote = False
52 m = re.match(r"^RELNOTES(?:\[(INC|NEW)\])?:", line)
53 if m is not None:
54 in_relnote = True
Googler9a333bc2024-03-25 14:40:22 -070055 line = line[len(m[0]) :]
56 if line.strip().lower().rstrip(".") in ["n/a", "na", "none"]:
57 return None
Xdng Yngb0357bd2022-10-28 06:02:19 -070058 if m[1] == "INC":
59 line = "**[Incompatible]** " + line.strip()
60 line = line.strip()
61 if in_relnote and line:
62 relnote_lines.append(line)
63 relnote = " ".join(relnote_lines)
Googler9a333bc2024-03-25 14:40:22 -070064
65 if issue_id and relnote:
66 relnote += " " + issue_id.group(0).strip()
67
Xdng Yngb0357bd2022-10-28 06:02:19 -070068 return relnote
69
70
Googler9a333bc2024-03-25 14:40:22 -070071def get_relnotes_between(base, head, is_patch_release):
Xdng Yngb0357bd2022-10-28 06:02:19 -070072 """Gets all relnotes for commits between `base` and `head`."""
Googler332144a2023-05-12 16:12:04 -070073 commits = git("rev-list", f"{base}..{head}")
74 if commits == [""]:
75 return []
Xdng Yngb0357bd2022-10-28 06:02:19 -070076 relnotes = []
77 rolled_back_commits = set()
78 # We go in reverse-chronological order, so that we can identify rollback
79 # commits and ignore the rolled-back commits.
80 for commit in commits:
81 if commit in rolled_back_commits:
82 continue
83 lines = git("show", "-s", commit, "--pretty=format:%B")
84 m = re.match(r"^Automated rollback of commit ([\dA-Fa-f]+)", lines[0])
85 if m is not None:
86 rolled_back_commits.add(m[1])
87 # The rollback commit itself is also skipped.
88 continue
Googlerd2e8b7e2023-08-09 13:53:00 -070089 relnote = (
Googler9a333bc2024-03-25 14:40:22 -070090 extract_title(lines) if is_patch_release else extract_relnotes(lines)
Googlerd2e8b7e2023-08-09 13:53:00 -070091 )
Xdng Yngb0357bd2022-10-28 06:02:19 -070092 if relnote is not None:
93 relnotes.append(relnote)
94 return relnotes
95
96
Googler332144a2023-05-12 16:12:04 -070097def get_label(issue_id):
98 """Get team-X label added to issue."""
Googlerbf320d22023-05-24 08:31:31 -070099 auth = subprocess.check_output(
Googler332144a2023-05-12 16:12:04 -0700100 "gsutil cat"
101 " gs://bazel-trusted-encrypted-secrets/github-trusted-token.enc |"
102 " gcloud kms decrypt --project bazel-public --location global"
103 " --keyring buildkite --key github-trusted-token --ciphertext-file"
Googlerbf320d22023-05-24 08:31:31 -0700104 " - --plaintext-file -", shell=True
105 ).decode("utf-8").strip().split("\n")[0]
Googler332144a2023-05-12 16:12:04 -0700106 headers = {
107 "Authorization": "Bearer " + auth,
108 "Accept": "application/vnd.github+json",
109 }
110 response = requests.get(
111 "https://api.github.com/repos/bazelbuild/bazel/issues/"
112 + issue_id + "/labels", headers=headers,
113 )
114 for item in response.json():
115 for key, value in item.items():
116 if key == "name" and "team-" in value:
117 return value.strip()
118 return None
119
120
121def get_categorized_relnotes(filtered_notes):
122 """Sort release notes by category."""
123 categorized_relnotes = {}
124 for relnote in filtered_notes:
125 issue_id = re.search(r"\(\#[0-9]+\)$", relnote.strip().split()[-1])
126 category = None
127 if issue_id:
128 category = get_label(re.sub(r"\(|\#|\)", "", issue_id.group(0).strip()))
129
130 if category is None:
131 category = "General"
132 else:
133 category = re.sub("team-", "", category)
134
135 try:
136 categorized_relnotes[category].append(relnote)
137 except KeyError:
138 categorized_relnotes[category] = [relnote]
139
140 return dict(sorted(categorized_relnotes.items()))
141
142
Googlerf86b76a2022-12-01 07:30:11 -0800143def get_external_authors_between(base, head):
144 """Gets all external authors for commits between `base` and `head`."""
Googler332144a2023-05-12 16:12:04 -0700145
146 # Get all authors
Googlerf86b76a2022-12-01 07:30:11 -0800147 authors = git("log", f"{base}..{head}", "--format=%aN|%aE")
Googler332144a2023-05-12 16:12:04 -0700148 authors = set(
149 author.partition("|")[0].rstrip()
Googler991113a2023-05-22 09:34:24 -0700150 for author in authors if not (author.endswith(("@google.com"))))
Googler332144a2023-05-12 16:12:04 -0700151
152 # Get all co-authors
153 contributors = git(
154 "log", f"{base}..{head}", "--format=%(trailers:key=Co-authored-by)"
155 )
156
157 coauthors = []
158 for coauthor in contributors:
Googler991113a2023-05-22 09:34:24 -0700159 if coauthor and not re.search("@google.com", coauthor):
Googler332144a2023-05-12 16:12:04 -0700160 coauthors.append(
161 " ".join(re.sub(r"Co-authored-by: |<.*?>", "", coauthor).split())
162 )
163 return ", ".join(sorted(authors.union(coauthors), key=str.casefold))
Googlerf86b76a2022-12-01 07:30:11 -0800164
165
Xdng Yngb0357bd2022-10-28 06:02:19 -0700166if __name__ == "__main__":
Googler332144a2023-05-12 16:12:04 -0700167 # Get last release and make sure it's consistent with current X.Y.Z release
168 # e.g. if current_release is 5.3.3, last_release should be 5.3.2 even if
169 # latest release is 6.1.1
Googler991113a2023-05-22 09:34:24 -0700170 current_release = git("rev-parse", "--abbrev-ref", "HEAD")[0]
Googler332144a2023-05-12 16:12:04 -0700171
Googlerbf320d22023-05-24 08:31:31 -0700172 if current_release.startswith("release-"):
173 current_release = re.sub(r"rc\d", "", current_release[len("release-"):])
174 else:
175 try:
176 current_release = git("describe", "--tags")[0]
177 except Exception: # pylint: disable=broad-exception-caught
178 print("Error: Not a release branch.")
179 sys.exit(1)
180
Googler9a333bc2024-03-25 14:40:22 -0700181 is_patch = not current_release.endswith(".0")
Googler332144a2023-05-12 16:12:04 -0700182
183 tags = [tag for tag in git("tag", "--sort=refname") if "pre" not in tag]
Googler4e7e8bf2023-06-05 06:47:40 -0700184
185 # Get the baseline for RCs (before release tag is created)
Googler332144a2023-05-12 16:12:04 -0700186 if current_release not in tags:
187 tags.append(current_release)
Googler4e7e8bf2023-06-05 06:47:40 -0700188
189 tags.sort()
190 last_release = tags[tags.index(current_release) - 1]
Xdng Yngb0357bd2022-10-28 06:02:19 -0700191
192 # Assuming HEAD is on the current (to-be-released) release, find the merge
193 # base with the last release so that we know which commits to generate notes
194 # for.
195 merge_base = git("merge-base", "HEAD", last_release)[0]
Xdng Yngb0357bd2022-10-28 06:02:19 -0700196
197 # Generate notes for all commits from last branch cut to HEAD, but filter out
198 # any identical notes from the previous release branch.
Googler9a333bc2024-03-25 14:40:22 -0700199 cur_release_relnotes = get_relnotes_between(
200 merge_base, "HEAD", is_patch
201 )
Googler332144a2023-05-12 16:12:04 -0700202 last_release_relnotes = set(
Googler9a333bc2024-03-25 14:40:22 -0700203 get_relnotes_between(merge_base, last_release, is_patch)
Googler332144a2023-05-12 16:12:04 -0700204 )
Xdng Yngb0357bd2022-10-28 06:02:19 -0700205 filtered_relnotes = [
206 note for note in cur_release_relnotes if note not in last_release_relnotes
207 ]
208
209 # Reverse so that the notes are in chronological order.
210 filtered_relnotes.reverse()
211 print()
Googler332144a2023-05-12 16:12:04 -0700212 print("Release Notes:")
Googler9a333bc2024-03-25 14:40:22 -0700213 print()
Googler332144a2023-05-12 16:12:04 -0700214
Googler9a333bc2024-03-25 14:40:22 -0700215 categorized_release_notes = get_categorized_relnotes(filtered_relnotes)
216 for label in categorized_release_notes:
217 print(label + ":")
218 for note in categorized_release_notes[label]:
Googler332144a2023-05-12 16:12:04 -0700219 print("+", note)
Googler9a333bc2024-03-25 14:40:22 -0700220 print()
Googlerf86b76a2022-12-01 07:30:11 -0800221
222 print()
Googler332144a2023-05-12 16:12:04 -0700223 print("Acknowledgements:")
Googlerf86b76a2022-12-01 07:30:11 -0800224 external_authors = get_external_authors_between(merge_base, "HEAD")
Googler38682d22023-05-30 01:28:17 -0700225 print()
Googler332144a2023-05-12 16:12:04 -0700226 print("This release contains contributions from many people at Google" +
227 ("." if not external_authors else f", as well as {external_authors}."))