Skip to content

Commit e8da454

Browse files
authored
Add: script to automatically produce changelogs based on the commits (#8)
This needs post-processing before it becomes a good changelog, but at least this helps in ordering and pruning what is possible in an automated way.
1 parent 92edd47 commit e8da454

File tree

2 files changed

+281
-2
lines changed

2 files changed

+281
-2
lines changed

.github/workflows/testing.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
- name: Flake8
3737
uses: TrueBrain/actions-flake8@v2
3838
with:
39-
path: backport
39+
path: backport changelog
4040

4141
black:
4242
name: Black
@@ -52,7 +52,7 @@ jobs:
5252
run: |
5353
python -m pip install --upgrade pip
5454
pip install black
55-
black -l 120 --check backport
55+
black -l 120 --check backport changelog
5656
5757
check_annotations:
5858
name: Check Annotations

changelog/changelog.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""
2+
Put this file in a master checkout under .github/.
3+
4+
This assumes your git "origin" points to your fork, and "upstream" to upstream.
5+
This script will overwrite the branch "changelog".
6+
7+
The GITHUB_TOKEN is a classic token you need to generate that has public_repo
8+
scope enabled. This token is used to fetch information from the GitHub API.
9+
10+
Execute with:
11+
12+
$ export GITHUB_TOKEN=ghp_XXX
13+
$ python3 .github/backport.py <last-commit-of-previous-release>
14+
"""
15+
16+
import json
17+
import os
18+
import subprocess
19+
import sys
20+
21+
BEARER_TOKEN = os.getenv("GITHUB_TOKEN")
22+
23+
if not BEARER_TOKEN:
24+
print("Please set the GITHUB_TOKEN environment variable.")
25+
sys.exit(1)
26+
27+
PRIORITY = {
28+
"Feature": 1,
29+
"Add": 2,
30+
"Change": 3,
31+
"Fix": 4,
32+
"Remove": 5,
33+
}
34+
35+
commit_pr_query = """
36+
query ($hash: String) {
37+
repository(owner: "OpenTTD", name: "OpenTTD") {
38+
ref(qualifiedName: "master") {
39+
target {
40+
... on Commit {
41+
history(first: 100, after: $hash) {
42+
pageInfo{
43+
endCursor
44+
}
45+
edges {
46+
node {
47+
oid
48+
associatedPullRequests(first: 1) {
49+
edges {
50+
node {
51+
number
52+
labels(first: 10) {
53+
edges {
54+
node {
55+
name
56+
}
57+
}
58+
}
59+
closingIssuesReferences(first: 10) {
60+
edges {
61+
node {
62+
number
63+
}
64+
}
65+
}
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
78+
"""
79+
80+
81+
def do_query(query, variables):
82+
query = query.replace("\n", "").replace("\\", "\\\\").replace('"', '\\"')
83+
variables = json.dumps(variables).replace("\\", "\\\\").replace('"', '\\"')
84+
res = subprocess.run(
85+
[
86+
"curl",
87+
"--fail",
88+
"-H",
89+
f"Authorization: bearer {BEARER_TOKEN}",
90+
"-X",
91+
"POST",
92+
"-d",
93+
f'{{"query": "{query}", "variables": "{variables}"}}',
94+
"https://api.github.com/graphql",
95+
],
96+
capture_output=True,
97+
)
98+
if res.returncode != 0:
99+
return None
100+
return json.loads(res.stdout)
101+
102+
103+
def do_command(command):
104+
return subprocess.run(command, capture_output=True)
105+
106+
107+
def main():
108+
last_commit = sys.argv[1]
109+
110+
do_command(["git", "fetch", "upstream"])
111+
do_command(["git", "checkout", "upstream/master", "-B", "changelog"])
112+
113+
commit_list = do_command(["git", "log", "--pretty=format:%ce|%H|%s", f"{last_commit}..HEAD"])
114+
commits = commit_list.stdout.decode().splitlines()
115+
116+
commit_to_pr = {}
117+
backported = set()
118+
commits_seen = set()
119+
issues = {}
120+
121+
cache_filename = f".changelog-cache-{last_commit}.{len(commits)}"
122+
123+
if os.path.exists(cache_filename):
124+
with open(cache_filename, "r") as f:
125+
data = json.loads(f.read())
126+
commit_to_pr = data["commit_to_pr"]
127+
backported = set(data["backported"])
128+
commits_seen = set(data["commits_seen"])
129+
issues = data["issues"]
130+
else:
131+
print("Fetching commits and their associated PRs ... this might take a while ...")
132+
133+
count = len(commits)
134+
variables = {}
135+
while count > 0:
136+
print(f"{count} commits left ...")
137+
138+
res = do_query(commit_pr_query, variables)
139+
variables["hash"] = res["data"]["repository"]["ref"]["target"]["history"]["pageInfo"]["endCursor"]
140+
141+
# Walk all commits.
142+
for edge in res["data"]["repository"]["ref"]["target"]["history"]["edges"]:
143+
count -= 1
144+
if count == 0:
145+
break
146+
if not edge["node"]["associatedPullRequests"]["edges"]:
147+
continue
148+
149+
pr = edge["node"]["associatedPullRequests"]["edges"][0]["node"]
150+
151+
# Links the commit to a PR.
152+
commit_to_pr[edge["node"]["oid"]] = pr["number"]
153+
# Link the hashes between 6 and 10 in size to the list of "seen commits".
154+
for i in range(6, 10):
155+
commits_seen.add(edge["node"]["oid"][0:i])
156+
157+
# Check if this PR was backported.
158+
for label in pr["labels"]["edges"]:
159+
if label["node"]["name"] == "backported":
160+
backported.add(pr["number"])
161+
162+
# Track which issues were closed because of this PR.
163+
if pr["closingIssuesReferences"]["edges"]:
164+
issues[pr["number"]] = []
165+
for issue in pr["closingIssuesReferences"]["edges"]:
166+
issues[pr["number"]].append(issue["node"]["number"])
167+
168+
with open(cache_filename, "w") as f:
169+
f.write(
170+
json.dumps(
171+
{
172+
"commit_to_pr": commit_to_pr,
173+
"backported": list(backported),
174+
"commits_seen": list(commits_seen),
175+
"issues": issues,
176+
}
177+
)
178+
)
179+
180+
messages = []
181+
182+
for commit in commit_list.stdout.decode().splitlines():
183+
(email, hash, message) = commit.split("|", 2)
184+
185+
if email in ("[email protected]",):
186+
continue
187+
188+
# Ignore all commit messages that don't change functionality.
189+
if message.startswith(("Codechange", "Codefix", "Doc", "Update", "Upgrade", "Cleanup", "Prepare", "Revert")):
190+
continue
191+
# Ignore everything related to the CI.
192+
if "[CI]" in message or "[Dependabot]" in message or "[DorpsGek]" in message:
193+
continue
194+
195+
pr = commit_to_pr.get(hash)
196+
if pr is None:
197+
pr = -1
198+
199+
# Skip everything already backported.
200+
if pr in backported:
201+
continue
202+
203+
# Remove the PR number from the commit message.
204+
if message.endswith(f"(#{pr})"):
205+
message = message[: -len(f"(#{pr})")]
206+
207+
commit_type, commit_message = message.strip().split(":", 1)
208+
subject = None
209+
210+
# Remove trailing dots.
211+
commit_message = commit_message.strip().rstrip(".")
212+
commit_message = commit_message[0].upper() + commit_message[1:]
213+
# If the string starts with [, capitalize the first letter after the ].
214+
if commit_message.startswith("["):
215+
part1, part2 = commit_message.split("]", 1)
216+
part2 = part2.strip()
217+
commit_message = part1 + "] " + part2[0].upper() + part2[1:]
218+
219+
if " " in commit_type:
220+
commit_type, subject = commit_type.split(" ", 1)
221+
222+
if commit_type != "Fix":
223+
# Remove subject if not a fix.
224+
subject = None
225+
else:
226+
reference = False
227+
ticket = None
228+
229+
# Check all parts of the subject for either a ticket or hash.
230+
for sub in subject.split(","):
231+
sub = sub.strip()
232+
233+
# If we reference a ticket, that will be the subject.
234+
if sub.startswith("#"):
235+
ticket = sub
236+
continue
237+
238+
# If the hash is in our set of commits, it is a fix for
239+
# something unreleased; so don't mention it.
240+
if sub in commits_seen:
241+
reference = True
242+
break
243+
244+
if reference:
245+
continue
246+
subject = ticket
247+
248+
if commit_type == "Fix" and issues.get(str(pr)):
249+
issue_list = issues[str(pr)]
250+
251+
# Check if any of the linked issues are mentioned in the commit.
252+
for issue in issue_list:
253+
if subject and f"#{issue}" in subject:
254+
break
255+
else:
256+
# The linked issue is not mentioned. Create the link.
257+
issue = ", ".join([f"#{issue}" for issue in issue_list])
258+
259+
if subject and subject != issue:
260+
print(
261+
f"WARNING: commit {hash} has a different references than the PR {pr}: '{subject}' vs '{issue}'"
262+
)
263+
subject = issue
264+
265+
message = commit_type
266+
if subject:
267+
message += f" {subject}"
268+
message += f": {commit_message}"
269+
if pr != -1:
270+
message += f" (#{pr})"
271+
272+
messages.append((PRIORITY[commit_type], int(pr), message))
273+
274+
for message in sorted(messages, key=lambda x: (x[0], -x[1])):
275+
print(message[2])
276+
277+
278+
if __name__ == "__main__":
279+
main()

0 commit comments

Comments
 (0)