Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
414b9f8
Added Automation for generating changelogs
soham30rane Nov 23, 2024
4243d86
Update generate_changelog.yml, updated name of secret variable
soham30rane Nov 23, 2024
cabf502
Updated create_changelog.py: Removed trailing slash for (http://www.s…
soham30rane Nov 24, 2024
f96158d
Updated create_changelog.py: now '[First Contribution]' aligns with o…
soham30rane Nov 24, 2024
38ccbd3
Added changelog for release: 10.0
github-actions[bot] Nov 24, 2024
e356fb9
Delete src/changelogs/sage-10.0.txt
soham30rane Nov 28, 2024
331fd39
Fixed typo in create_changelog.py
soham30rane Nov 28, 2024
e5054df
Now only fetching 100 most recent tags instead of 1000
soham30rane Nov 28, 2024
8b8fa92
Now generated changelogs don't have an empty line at the end
soham30rane Nov 28, 2024
dad5da3
Retrieve contributor's name from github and update conf/contributors.…
soham30rane Nov 28, 2024
0bb9407
Removed unnecessary secret 'CHANGELOG_TRIGGER_SECRET'
soham30rane Nov 28, 2024
790b6de
Used specific filenames, when pushing changes from the workflow
soham30rane Nov 28, 2024
b78b43b
Workflow should terminate when changelog is not generated
soham30rane Nov 28, 2024
2c64e64
Fixed misplaced 'url' attribute in conf/contributors.xml
soham30rane Nov 28, 2024
ce3556c
Added changelog for release: 10.0
github-actions[bot] Nov 28, 2024
a57f4da
Fixed a few typos in create_changelog.py
soham30rane Nov 29, 2024
aa9b05c
Removed unnecessary print statement in create_changelog.py
soham30rane Nov 29, 2024
286ce08
Fixed 'date_of_release' logic in create_changelog.py
soham30rane Nov 29, 2024
8679ac7
Added docstrings to functions in create_changelog.py
soham30rane Nov 29, 2024
387a6ce
Changed name 'PERSONAL_ACCESS_TOKEN' to 'SAGE_ACCESS_TOKEN' in genera…
soham30rane Nov 29, 2024
4916927
Removed colon from auto-generated commit message in generate_changelo…
soham30rane Nov 29, 2024
519e571
Improved formatting for changelog generation
soham30rane Nov 29, 2024
9c268bc
Hardcoded release manager in changelog generation
soham30rane Nov 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/generate_changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Generate Changelog

on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag of latest release'
required: true
trigger_secret:
description: 'Common Secret stored in both repos for Authentication'
required: true

permissions:
contents: write

jobs:
process-release:
runs-on: ubuntu-latest
steps:
- name: Verify Trigger Source
env:
RECIEVED_SECRET: ${{ inputs.trigger_secret }}
EXPECTED_SECRET: ${{ secrets.CHANGELOG_TRIGGER_SECRET }}
run: |
if [ "$RECIEVED_SECRET" != "$EXPECTED_SECRET" ]; then
echo "Unauthorized trigger"
exit 1
fi

- name: Checkout Repository
uses: actions/checkout@v4

- name: Install Dependencies
run: pip install -r requirements.txt

- name: Run Script
env:
RELEASE_TAG: ${{ inputs.release_tag }}
GITHUB_PAT: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
run: python scripts/create_changelog.py "$RELEASE_TAG"

- name: Commit and Push Changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Added changelog for release: ${{ inputs.release_tag }}"
git push
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ autopep8 >= 0.5
pyyaml
six
pybtex
requests
python-dotenv
unidecode
247 changes: 247 additions & 0 deletions scripts/create_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This script is used in 'Generate Changelog' workflow, defined in the 'generate_changelog.yml' file
to automate the process of creating changelogs after each stable release.

It fetches release data, extracts relevant pull request (PR) information, and generates a detailed changelog
and add its to src/changelogs.
The script uses the GitHub REST API to collect information about contributors, PR authors, and reviewers.

Additional Note:
- The script requires a GitHub personal access token (PAT) stored in an environment variable named `GITHUB_PAT`.
"""

import requests
import re
from dotenv import load_dotenv
import os
import argparse
import xml.etree.ElementTree as ET
from unidecode import unidecode

load_dotenv()
GITHUB_PAT = os.getenv('GITHUB_PAT')
BASE_URL = r"https://api.github.com/repos/sagemath/sage"
HEADERS = {'Authorization': f'token {GITHUB_PAT}', }
AUTOMATED_BOTS = ['dependabot[bot]', 'github-actions', 'renovate[bot]']

# Maps the github username to contributer name
git_to_name = {}

# Stores unique names of contributors
all_contribs = set([])

# Stores unique names of contributors
first_contribs = set([])

# Stores information for all the prs across all pre-releases
# Maps tag of pre-release to its info
all_info = {}


def map_git_to_names():
tree = ET.parse('conf/contributors.xml')
root = tree.getroot()
for c in root.findall('contributor'):
name = c.get('name')
git = c.get('github')
if git and name:
git_to_name[git] = unidecode(name)


def update_names():
"""
Replace the github usernames with real names. If name is not found in contributors.xml,
then github usernames are used in the form @<github-username>
"""
for tag in all_info:
for pr in all_info[tag]:
pr['creator'] = git_to_name.get(pr['creator'], f"@{pr['creator']}")
pr['authors'] = [git_to_name.get(a, f"@{a}") for a in pr['authors']]
pr['reviewers'] = [git_to_name.get(r, f"@{r}") for r in pr['reviewers']]
global all_contribs
global first_contribs
all_contribs = set([git_to_name.get(c, f"@{c}") for c in all_contribs])
first_contribs = set([git_to_name.get(c, f"@{c}") for c in first_contribs])


def get_release_data(tag):
url = fr"{BASE_URL}/releases/tags/{tag}"
res = requests.get(url, headers=HEADERS)
if res.status_code == 404:
print(f"{tag} release not found")
return None
if res.status_code != 200:
print(f"Failed to fetch release data: {res.status_code}")
return None
return res.json()


def get_release_date(release_data):
if not release_data:
return 'N/A'
date_time = release_data.get('published_at', '')
if not date_time:
return 'Unavailable'
return date_time.split('T')[0]


def extract_pr_info(release_data):
body = release_data.get('body', '')
pr_info = []
pattern = r"\* (.*?) by (@\S+) in https://github.com/sagemath/sage/pull/(\d+)"
matches = re.findall(pattern, body)
for match in matches:
title = match[0]
creator = match[1][1::]
pr_id = match[2]
authors = get_authors(pr_id)
reviewers = get_reviewers(pr_id, authors)
pr_info.append({
'title': title,
'creator': creator,
'pr_id': pr_id,
'authors': authors,
'reviewers': reviewers
})
return pr_info


def update_first_contribs(release_data):
body = release_data.get('body', '')
pattern = r"\* (@\S+) made their first contribution in"
matches = re.findall(pattern, body)
for match in matches:
username = match[1::]
first_contribs.add(username)


def get_authors(pr_id):
url = f"{BASE_URL}/pulls/{pr_id}/commits"
authors = []
try:
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
commits = res.json()
for commit in commits:
if commit['commit']['committer']['name'] in AUTOMATED_BOTS:
continue
if 'author' in commit and 'login' and commit['author']:
username = commit['author']['login']
if username not in AUTOMATED_BOTS:
authors.append(username)
all_contribs.add(username)
except Exception as e:
print(f"Failed to fetch commits for PR {pr_id}: {e}")
return list(set(authors))


def get_reviewers(pr_id, authors):
url = f"{BASE_URL}/pulls/{pr_id}/reviews"
reviewers = []
try:
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
reviews = res.json()
for review in reviews:
if 'user' in review and 'login' in review['user']:
username = review['user']['login']
if username not in authors and username not in AUTOMATED_BOTS:
reviewers.append(username)
all_contribs.add(username)
except Exception as e:
print(f"Failed to fetch reviews for PR {pr_id}: {e}")
return list(set(reviewers))


def get_latest_tags():
url = f"{BASE_URL}/tags?per_page=1000" # If per_page is not specified then very few tags are fetched
try:
res = requests.get(url, headers=HEADERS)
res.raise_for_status()
tags = res.json()
tags = [tag['name'] for tag in tags]
return tags
except Exception as e:
print(f"Failed to fetch tags")
return None


def sort_tags(tag):
name = tag.lower()
if "beta" in name:
return (0, name) # Beta comes first
elif "rc" in name:
return (1, name) # RC comes next
else:
return (2, name) # Stable versions come last


def save_to_file(filename, ver, date_of_release):
with open(filename, 'w') as file:
file.write(f"Sage {ver} was released on {date_of_release}. It is available from:\n\n")
file.write(f" * https://www.sagemath.org/download-source.html\n\n")
file.write(f"Sage (http://www.sagemath.org) is developed by volunteers and\n")
file.write(f"combines hundreds of open source packages.\n\n")
file.write(f"The following {len(all_contribs)} people contributed to this release.\n")
file.write(f"Of those, {len(first_contribs)} made their first contribution to Sage:\n\n")
max_name_len = max([len(c) for c in all_contribs])
for c in all_contribs:
file.write(f" - {c}{' '*(max_name_len - len(c)) + ' [First Contribution]' if c in first_contribs else ''}\n")
pr_count = sum([len(all_info[tag]) for tag in all_info])
file.write(f"\n* We merged {pr_count} pull requests in this release.\n\n")
sorted_tags = sorted(all_info.keys(), key=sort_tags)
for tag in sorted_tags:
file.write(f"Merged in sage-{tag}:\n\n")
for pr in all_info[tag]:
file.write(f"#{pr['pr_id']}: {', '.join(pr['authors'])}: {pr['title']}")
if pr['reviewers']:
file.write(f" [Reviewed by {', '.join(pr['reviewers'])}]")
file.write('\n')
file.write('\n')

print(f"Saved changelog to {filename}")


if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Fetch release data from GitHub and extract PR info")
parser.add_argument('version', type=str, help="The release version (e.g., 10.1)")
args = parser.parse_args()
ver = args.version
is_stable = re.match(r'^\d+(\.\d+){0,3}$', ver)
if not is_stable:
print(f"{ver} is not a stable release. terminating....")
exit()

filepath = f"src/changelogs/sage-{ver}.txt"
if os.path.exists(filepath):
print(f"{filepath} already exists. Exiting without making changes.")
exit()

map_git_to_names()
all_tags = get_latest_tags()
tag_pattern = fr"^{ver}.(beta|rc)\d*$"
valid_tags = set([ver,])
for tag in all_tags:
if re.match(tag_pattern, tag):
valid_tags.add(tag)

for tag in valid_tags:
release_data = get_release_data(tag)
if tag == ver:
date_of_release = get_release_date(release_data)
if release_data is None:
continue
pr_info = extract_pr_info(release_data)
all_info[tag] = pr_info
update_first_contribs(release_data)
print(f"Fetched data for tag: {tag}")

update_names()
first_contribs = first_contribs.intersection(all_contribs)
all_contribs = sorted(all_contribs, key=lambda x: (x[0].startswith('@'), x[0]))
if all_info:
save_to_file(filepath, ver, date_of_release)
else:
print("No information found.")
Loading