Skip to content

Commit 3fa284c

Browse files
Initial contributor list script
Generate contributor list and compare url for github release notes. The goal of this change is to fully automate the generation of github releases removing an error prone, tedious, manual step. Additionally this includes a minor readability update for the render notes script.
1 parent d253351 commit 3fa284c

4 files changed

Lines changed: 242 additions & 12 deletions

File tree

.github/workflows/shippable_builds.yml

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ jobs:
207207
outputs:
208208
k9mail_sha: ${{ steps.commit.outputs.k9mail_sha }}
209209
thunderbird_sha: ${{ steps.commit.outputs.thunderbird_sha }}
210-
k9mail_github_notes: ${{ steps.render_notes.outputs.k9mail_github_notes }}
211-
thunderbird_github_notes: ${{ steps.render_notes.outputs.thunderbird_github_notes }}
210+
k9mail_github_notes: ${{ steps.append_contributors.outputs.k9mail_github_notes }}
211+
thunderbird_github_notes: ${{ steps.append_contributors.outputs.thunderbird_github_notes }}
212212
old_version_code: ${{ steps.new_version_code.outputs.old_version_code }}
213213
new_version_code: ${{ steps.new_version_code.outputs.new_version_code }}
214214
steps:
@@ -415,6 +415,53 @@ jobs:
415415
echo "${APP_NAME}_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
416416
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
417417
418+
- name: Append Contributors
419+
id: append_contributors
420+
if: "${{ contains(matrix.releaseTarget, 'github') && contains(fromJSON('[\"beta\", \"release\"]'), needs.dump_config.outputs.releaseType) }}"
421+
shell: bash
422+
env:
423+
APP_NAME: ${{ matrix.appName }}
424+
RELEASE_TYPE: ${{ vars.RELEASE_TYPE }}
425+
APPLICATION_LABEL: ${{ steps.appinfo.outputs.APPLICATION_LABEL }}
426+
VERSION_CODE: ${{ steps.new_version_code.outputs.new_version_code }}
427+
FULL_VERSION_NAME: ${{ steps.appinfo.outputs.VERSION_NAME }}${{ steps.bump_version_suffix.outputs.SUFFIX || steps.appinfo.outputs.VERSION_NAME_SUFFIX }}
428+
K9MAIL_GITHUB_NOTES: ${{ steps.render_notes.outputs.k9mail_github_notes }}
429+
THUNDERBIRD_GITHUB_NOTES: ${{ steps.render_notes.outputs.thunderbird_github_notes }}
430+
run: |
431+
case "${APP_NAME}:${RELEASE_TYPE}" in
432+
thunderbird:beta) TAG_REGEX='^THUNDERBIRD_[0-9]+_0b[0-9]+$'
433+
GITHUB_NOTES=$THUNDERBIRD_GITHUB_NOTES
434+
;;
435+
thunderbird:*) TAG_REGEX='^THUNDERBIRD_[0-9]+_[0-9]+$'
436+
GITHUB_NOTES=$THUNDERBIRD_GITHUB_NOTES
437+
;;
438+
k9mail:*) TAG_REGEX='^K9MAIL_[0-9]+_[0-9]+$'
439+
GITHUB_NOTES=$K9MAIL_GITHUB_NOTES
440+
;;
441+
esac
442+
443+
TAG_NAME="${APP_NAME^^}_${FULL_VERSION_NAME//./_}"
444+
NOTES_FILE="$(mktemp -d)/github_notes.txt"
445+
CONTRIBUTORS_FILE="$(mktemp -d)/contributors.txt"
446+
echo "${GITHUB_NOTES}" >> "${NOTES_FILE}"
447+
448+
python ./scripts/ci/contributor_list.py \
449+
--regex "$TAG_REGEX" \
450+
--head "HEAD" \
451+
--changelog-head "$TAG_NAME" \
452+
"$CONTRIBUTORS_FILE"
453+
454+
cat "${CONTRIBUTORS_FILE}" >> "${NOTES_FILE}"
455+
456+
echo "${APP_NAME}_github_notes<<EOF" >> $GITHUB_OUTPUT
457+
cat $NOTES_FILE >> $GITHUB_OUTPUT
458+
echo "EOF" >> $GITHUB_OUTPUT
459+
460+
echo "<h2>${APPLICATION_LABEL} ${FULL_VERSION_NAME} Contributors (${VERSION_CODE})</h2>" | tee -a $GITHUB_STEP_SUMMARY
461+
echo -e "\n\n\`\`\`" | tee -a $GITHUB_STEP_SUMMARY
462+
cat $CONTRIBUTORS_FILE | tee -a $GITHUB_STEP_SUMMARY
463+
echo -e "\`\`\`" | tee -a $GITHUB_STEP_SUMMARY
464+
418465
- name: Summary
419466
if: ${{ contains(matrix.releaseTarget, 'github') || needs.dump_config.outputs.releaseType == 'daily' }}
420467
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0

scripts/ci/contributor_list.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import os
4+
import subprocess
5+
import sys
6+
import requests
7+
import re
8+
9+
10+
def git(*args):
11+
return subprocess.check_output(["git", *args], text=True).strip()
12+
13+
14+
def get_recent_matching_tags(pattern):
15+
output = git(
16+
"for-each-ref",
17+
"--sort=-creatordate",
18+
"--format=%(refname:short)",
19+
"refs/tags",
20+
)
21+
filtered = [line for line in output.splitlines() if re.search(pattern, line)]
22+
return filtered[:2]
23+
24+
25+
def github_get(url):
26+
headers = {
27+
"Accept": "application/vnd.github+json",
28+
"User-Agent": "contributors-between-tags",
29+
}
30+
token = os.getenv("GITHUB_TOKEN")
31+
if token:
32+
headers["Authorization"] = f"Bearer {token}"
33+
34+
response = requests.get(url, headers=headers)
35+
response.raise_for_status()
36+
37+
return response
38+
39+
40+
def github_compare(older, newer):
41+
commits = []
42+
43+
url = f"https://api.github.com/repos/thunderbird/thunderbird-android/compare/{older}...{newer}?per_page=100"
44+
45+
while True:
46+
response = github_get(url)
47+
data = response.json()
48+
49+
page_commits = data.get("commits", [])
50+
commits.extend(page_commits)
51+
52+
if "next" in response.links:
53+
url = response.links["next"]["url"]
54+
else:
55+
break
56+
57+
return commits
58+
59+
60+
def get_contributors(commits):
61+
contributors = {}
62+
for commit in commits:
63+
author = commit.get("author")
64+
65+
if not author or not author.get("login"):
66+
continue
67+
68+
author = commit.get("author").get("login")
69+
date = commit.get("commit").get("author").get("date")
70+
71+
# Track the earliest commit of each author in this set
72+
if author not in contributors:
73+
contributors[author] = commit
74+
elif date < contributors[author].get("commit").get("author").get("date"):
75+
contributors[author] = commit
76+
77+
return contributors
78+
79+
80+
def is_first_commit(author, date):
81+
url = f"https://api.github.com/repos/thunderbird/thunderbird-android/commits?author={author}&until={date}&per_page=2"
82+
data = github_get(url).json()
83+
return len(data) == 1
84+
85+
86+
def get_first_contributions(contributors):
87+
first_contributions = {}
88+
for author, commit in contributors.items():
89+
date = commit.get("commit").get("author").get("date")
90+
if is_first_commit(author, date):
91+
sha = commit.get("sha")
92+
url = f"https://api.github.com/repos/thunderbird/thunderbird-android/commits/{sha}/pulls"
93+
commit_data = github_get(url).json()
94+
if commit_data and "number" in commit_data[0]:
95+
pr_num = commit_data[0].get("number")
96+
first_contributions[author] = f"#{pr_num}"
97+
98+
return first_contributions
99+
100+
101+
def generate_contributor_list(
102+
regex,
103+
head,
104+
changelog_head,
105+
output_file
106+
):
107+
tags = get_recent_matching_tags(regex)
108+
109+
if head:
110+
if len(tags) < 1:
111+
print(f"Not enough tags matching pattern: {regex}", file=sys.stderr)
112+
sys.exit(1)
113+
114+
older = tags[0]
115+
newer = head
116+
changelog_newer = changelog_head or head
117+
else:
118+
if len(tags) < 2:
119+
print(f"Not enough tags matching pattern: {regex}", file=sys.stderr)
120+
sys.exit(1)
121+
122+
newer, older = tags[0], tags[1]
123+
changelog_newer = newer
124+
125+
commits = github_compare(older, newer)
126+
contributors = get_contributors(commits)
127+
128+
# Don't include bots in contributor list
129+
for bot in {'dependabot[bot]', 'thunderbird-botmobile[bot]', 'weblate'}:
130+
contributors.pop(bot, None)
131+
132+
first_contributions = get_first_contributions(contributors)
133+
contributors = {f"@{author}" for author in contributors}
134+
135+
with open(output_file, "w", encoding='utf-8') as f:
136+
print("\nContributors:", file=f)
137+
thanks_to = "Thanks to: " + ', '.join(sorted(contributors, key=str.casefold))
138+
print(thanks_to, file=f)
139+
140+
if first_contributions:
141+
print("\nNew Contributors:", file=f)
142+
for author, pr in first_contributions.items():
143+
print(f"* @{author} made their first contribution in {pr}", file=f)
144+
145+
changelog = f"https://github.com/thunderbird/thunderbird-android/compare/{older}...{changelog_newer}"
146+
print(f'\n**Full Changelog**: {changelog}', file=f)
147+
148+
149+
def main():
150+
parser = argparse.ArgumentParser()
151+
parser.add_argument(
152+
"--head",
153+
help="Ref/SHA to compare against the latest matching tag"
154+
)
155+
parser.add_argument(
156+
"--changelog-head",
157+
help="Ref/tag to use in the Full Changelog link"
158+
)
159+
parser.add_argument(
160+
"--regex",
161+
"-r",
162+
default=r"^THUNDERBIRD_\d+_\d+$",
163+
help="Regex Pattern to use to search recent tags",
164+
)
165+
parser.add_argument(
166+
"output_file",
167+
type=str,
168+
nargs="?",
169+
default="contributors",
170+
help="File to render contributors to",
171+
)
172+
args = parser.parse_args()
173+
174+
generate_contributor_list(
175+
args.regex,
176+
args.head,
177+
args.changelog_head,
178+
args.output_file
179+
)
180+
181+
if __name__ == "__main__":
182+
main()

scripts/ci/render-notes.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -124,33 +124,33 @@ def render_notes(
124124

125125
template_base = os.path.join(os.path.dirname(sys.argv[0]), "templates")
126126

127-
for render_file in render_files:
128-
with open(os.path.join(template_base, render_files[render_file]["template"]), "r") as file:
127+
for render_file, config in render_files.items():
128+
with open(os.path.join(template_base, config["template"]), "r") as file:
129129
template = file.read()
130-
template = Template(template, autoescape=render_files[render_file].get("autoescape", False))
131-
rendered = template.render(render_files[render_file]["render_data"])
130+
template = Template(template, autoescape=config.get("autoescape", False))
131+
rendered = template.render(config["render_data"])
132132
if render_file == "changelog_master":
133133
if print_only:
134-
print(f"\n==={render_files[render_file]['outfile']}===")
134+
print(f"\n==={config['outfile']}===")
135135
print("...")
136136
print(rendered)
137137
print("...")
138138
else:
139-
with open(render_files[render_file]["outfile"], "r") as file:
139+
with open(config["outfile"], "r") as file:
140140
lines = file.readlines()
141141
for index, line in enumerate(lines):
142142
if "<changelog>" in line:
143143
if version in lines[index + 1]:
144144
break
145145
lines.insert(index + 1, rendered)
146146
break
147-
with open(render_files[render_file]["outfile"], "w") as file:
147+
with open(config["outfile"], "w") as file:
148148
file.writelines(lines)
149149
elif render_file == "changelog" or render_file == "changelog_long":
150150
stripped = rendered.lstrip()
151-
maxlen = render_files[render_file].get("max_length", float("inf"))
151+
maxlen = config.get("max_length", float("inf"))
152152
if print_only:
153-
print(f"\n==={render_files[render_file]['outfile']}===")
153+
print(f"\n==={config['outfile']}===")
154154
print(stripped)
155155

156156
if len(stripped) > maxlen:
@@ -160,7 +160,7 @@ def render_notes(
160160
sys.exit(1)
161161

162162
if not print_only:
163-
with open(render_files[render_file]["outfile"], "x") as file:
163+
with open(config["outfile"], "x") as file:
164164
file.write(stripped)
165165

166166

scripts/test_python_scripts.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ echo ""
5454
python3 -m py_compile "$SCRIPT_DIR/ci/render-notes.py" && echo " ✓ render-notes.py"
5555
python3 -m py_compile "$SCRIPT_DIR/ci/setup_release_automation" && echo " ✓ setup_release_automation"
5656
python3 -m py_compile "$SCRIPT_DIR/ci/merges/merge_gradle.py" && echo " ✓ merge_gradle.py"
57+
python3 -m py_compile "$SCRIPT_DIR/ci/contributor_list.py" && echo " ✓ contributor_list.py"
5758

5859
echo ""
5960
echo "✓ All tests passed!"

0 commit comments

Comments
 (0)