Skip to content

Commit 9ced8a1

Browse files
authored
CSHARP-4918: Release notes automation (#1677)
1 parent 3155c39 commit 9ced8a1

File tree

5 files changed

+273
-3
lines changed

5 files changed

+273
-3
lines changed

evergreen/evergreen.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ functions:
300300
content_type: text/markdown
301301
display_name: ssdlc_compliance_report.md
302302

303+
generate-release-notes:
304+
- command: shell.exec
305+
params:
306+
working_dir: "mongo-csharp-driver"
307+
shell: "bash"
308+
env:
309+
GITHUB_APIKEY: ${github_apikey}
310+
script: |
311+
${PREPARE_SHELL}
312+
./evergreen/generate-release-notes.sh ${PACKAGE_VERSION} ${triggered_by_git_tag}
313+
303314
bootstrap-mongohoused:
304315
- command: shell.exec
305316
params:
@@ -1581,6 +1592,10 @@ tasks:
15811592
- func: download-and-promote-augmented-sbom-to-s3-bucket
15821593
- func: generate-ssdlc-report
15831594

1595+
- name: generate-release-notes
1596+
commands:
1597+
- func: generate-release-notes
1598+
15841599
- name: validate-apidocs
15851600
commands:
15861601
- func: install-dotnet
@@ -2525,7 +2540,6 @@ buildvariants:
25252540
depends_on:
25262541
- name: build-packages
25272542
variant: ".build-packages"
2528-
## add dependency onto packages smoke test once it implemented
25292543

25302544
- matrix_name: test-SK
25312545
matrix_spec:
@@ -2549,7 +2563,6 @@ buildvariants:
25492563
depends_on:
25502564
- name: build-packages
25512565
variant: ".build-packages"
2552-
## add dependency onto packages smoke test once it implemented
25532566

25542567
- matrix_name: push-packages-myget
25552568
matrix_spec:
@@ -2561,7 +2574,18 @@ buildvariants:
25612574
depends_on:
25622575
- name: build-packages
25632576
variant: ".build-packages"
2564-
## add dependency onto packages smoke test once it implemented
2577+
2578+
- matrix_name: generate-release-notes
2579+
matrix_spec:
2580+
os: "ubuntu-2004"
2581+
display_name: "Generate release notes"
2582+
tags: ["release-tag"]
2583+
tasks:
2584+
- name: generate-release-notes
2585+
git_tag_only: true
2586+
depends_on:
2587+
- name: push-packages-nuget
2588+
variant: ".push-packages"
25652589

25662590
- matrix_name: ssdlc-reports
25672591
matrix_spec:

evergreen/generate-release-notes.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
set -o errexit # Exit the script with error if any of the commands fail
3+
4+
version=$1
5+
version_tag=$2
6+
previous_commit_sha=$(git rev-list ${version_tag} --skip=1 --max-count=1)
7+
previous_tag=$(git describe ${previous_commit_sha} --tags --abbrev=0)
8+
9+
if [[ "$version" == *.0 ]]; then
10+
template_file="./evergreen/release-notes.yml"
11+
else
12+
template_file="./evergreen/patch-notes.yml"
13+
fi
14+
15+
python ./evergreen/release-notes.py ${version} mongodb/mongo-csharp-driver ${version_tag} ${previous_tag} ${template_file}

evergreen/patch-notes.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
title: ".NET Driver Version ${version} Release Notes"
2+
3+
autoformat:
4+
- match: '(CSHARP-\d+)'
5+
replace: '[\1](https://jira.mongodb.org/browse/\1)'
6+
7+
ignore:
8+
labels:
9+
- chore
10+
11+
sections:
12+
- title: 'This is a patch release that contains fixes and stability improvements:'
13+
labels: "*"
14+
15+
- The full list of issues resolved in this release is available at [CSHARP JIRA project](https://jira.mongodb.org/issues/?jql=project%20%3D%20CSHARP%20AND%20fixVersion%20%3D%20${version}%20ORDER%20BY%20key%20ASC).
16+
- Documentation on the .NET driver can be found [here](https://www.mongodb.com/docs/drivers/csharp/${docs_version}/).

evergreen/release-notes.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import argparse
2+
import yaml
3+
import requests
4+
import re
5+
import os
6+
7+
parser = argparse.ArgumentParser()
8+
parser.add_argument('version')
9+
parser.add_argument('repo')
10+
parser.add_argument('version_tag')
11+
parser.add_argument('previous_tag')
12+
parser.add_argument('template_file')
13+
14+
options = parser.parse_args()
15+
16+
options.docs_version = options.version_tag[:options.version_tag.rfind('.')]
17+
options.github_api_base_url = 'https://api.github.com/repos/'
18+
options.github_api_key = os.environ.get("GITHUB_APIKEY")
19+
options.github_headers = {
20+
"Authorization": "Bearer {api_key}".format(api_key=options.github_api_key),
21+
"X-GitHub-Api-Version": "2022-11-28",
22+
"Accept": "application/vnd.github+json"
23+
}
24+
25+
print("Preparing release notes for: tag {version_tag}, previous tag {previous_tag}".format(version_tag = options.version_tag, previous_tag = options.previous_tag))
26+
27+
def load_config(opts):
28+
print("Loading template...")
29+
with open(opts.template_file, 'r') as stream:
30+
try:
31+
opts.template = yaml.safe_load(stream)
32+
for section in opts.template["sections"]:
33+
if type(section) is dict:
34+
section["items"] = []
35+
except yaml.YAMLError as e:
36+
print('Cannot load template file:', e)
37+
38+
39+
def mapPullRequest(pullRequest, opts):
40+
title = pullRequest["title"]
41+
for regex in opts.template["autoformat"]:
42+
title = re.sub(regex["match"], regex["replace"], title)
43+
44+
return {
45+
"title": title,
46+
"labels": list(map(lambda l: l["name"], pullRequest["labels"]))
47+
}
48+
49+
50+
def is_in_section(pullrequest, section):
51+
if section is None:
52+
return False
53+
if type(section) is str:
54+
return False
55+
if "exclude-labels" in section:
56+
for lbl in section["exclude-labels"]:
57+
if lbl in pullrequest["labels"]:
58+
return False
59+
60+
if "labels" in section:
61+
if section["labels"] == "*":
62+
return True
63+
for lbl in section["labels"]:
64+
if lbl in pullrequest["labels"]:
65+
return True
66+
return False
67+
68+
return True
69+
70+
71+
def load_pull_requests(opts):
72+
print("Loading changeset...")
73+
page = 0
74+
total_pages = 1
75+
page_size = 100
76+
commits_url = "{github_api_base_url}{repo}/compare/{previous_tag}...{version_tag}".format(
77+
github_api_base_url=opts.github_api_base_url,
78+
repo=opts.repo,
79+
previous_tag=opts.previous_tag,
80+
version_tag=opts.version_tag)
81+
ignore_section = opts.template["ignore"]
82+
83+
while total_pages > page:
84+
response = requests.get(commits_url, params={'per_page': page_size, 'page': page}, headers=opts.github_headers)
85+
response.raise_for_status()
86+
commits = response.json()
87+
total_pages = commits["total_commits"] / page_size
88+
89+
for commit in commits["commits"]:
90+
pullrequests_url = "{github_api_base_url}{repo}/commits/{commit_sha}/pulls".format(
91+
github_api_base_url=opts.github_api_base_url,
92+
repo=opts.repo,
93+
commit_sha=commit["sha"])
94+
pullrequests = requests.get(pullrequests_url, headers=opts.github_headers).json()
95+
for pullrequest in pullrequests:
96+
mapped = mapPullRequest(pullrequest, opts)
97+
if is_in_section(mapped, ignore_section):
98+
break
99+
100+
for section in opts.template["sections"]:
101+
if is_in_section(mapped, section):
102+
if mapped in section["items"]:
103+
break # PR was already added to the section
104+
section["items"].append(mapped)
105+
break # adding PR to the section, skip evaluating next sections
106+
else:
107+
opts.template["unclassified"].append(mapped)
108+
page = page + 1
109+
110+
111+
def get_field(source, path):
112+
elements = path.split('.')
113+
for elem in elements:
114+
source = getattr(source, elem)
115+
return source
116+
117+
118+
def apply_template(template, parameters):
119+
return re.sub(r'\$\{([\w.]+)}', lambda m: get_field(parameters, m.group(1)), template)
120+
121+
122+
def process_section(section):
123+
if type(section) is str:
124+
return apply_template(section, options)
125+
if len(section["items"]) == 0:
126+
return ""
127+
128+
content = ""
129+
title = section.get("title", "")
130+
if title != "":
131+
content = apply_template(title, options) + '\n'
132+
133+
for pullrequest in section["items"]:
134+
content = content + '\n - ' + pullrequest["title"]
135+
136+
return content
137+
138+
139+
def publish_release_notes(opts, title, content):
140+
print("Publishing release notes...")
141+
url = '{github_api_base_url}{repo}/releases/tags/{tag}'.format(github_api_base_url=opts.github_api_base_url, repo=opts.repo, tag=opts.version_tag)
142+
response = requests.get(url, headers=opts.github_headers)
143+
response.raise_for_status()
144+
if response.status_code != 404:
145+
raise SystemExit("Release with the tag already exists")
146+
147+
post_data = {
148+
"tag_name": opts.version_tag,
149+
"name": title,
150+
"body": content,
151+
"draft": True,
152+
"generate_release_notes": False,
153+
"make_latest": "false"
154+
}
155+
response = requests.post(url, json=post_data, headers=opts.github_headers)
156+
response.raise_for_status()
157+
158+
159+
load_config(options)
160+
options.template["unclassified"] = []
161+
load_pull_requests(options)
162+
163+
print("Processing title...")
164+
release_title = apply_template(options.template["title"], options)
165+
print("Title: {title}".format(title=release_title))
166+
167+
print("Processing content...")
168+
release_content = ""
169+
for section in options.template["sections"]:
170+
section_content = process_section(section)
171+
if section_content != "":
172+
release_content += "\n\n" + section_content
173+
174+
if len(options.template["unclassified"]) > 0:
175+
release_content += "\n\n================================"
176+
release_content += "\n\n!!!UNCLASSIFIED PULL REQUESTS!!!"
177+
for pr in options.template["unclassified"]:
178+
release_content += "\n" + pr["title"]
179+
release_content += "\n\n================================"
180+
181+
print("----------")
182+
print(release_content)
183+
print("----------")
184+
185+
publish_release_notes(options, release_title, release_content)
186+
187+
print("Done.")

evergreen/release-notes.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
title: ".NET Driver Version ${version} Release Notes"
2+
3+
autoformat:
4+
- match: '(CSHARP-\d+)'
5+
replace: '[\1](https://jira.mongodb.org/browse/\1)'
6+
7+
ignore:
8+
labels:
9+
- chore
10+
11+
sections:
12+
- This is the general availability release for the ${version} version of the driver.
13+
14+
- title: "### The main new features in ${version} include:"
15+
labels:
16+
- feature
17+
- title: "### Improvements:"
18+
labels:
19+
- improvement
20+
- title: "### Fixes:"
21+
labels:
22+
- bug
23+
- title: "### Maintenance:"
24+
labels:
25+
- maintenance
26+
27+
- The full list of issues resolved in this release is available at [CSHARP JIRA project](https://jira.mongodb.org/issues/?jql=project%20%3D%20CSHARP%20AND%20fixVersion%20%3D%20${version}%20ORDER%20BY%20key%20ASC).
28+
- Documentation on the .NET driver can be found [here](https://www.mongodb.com/docs/drivers/csharp/${docs_version}/).

0 commit comments

Comments
 (0)