diff --git a/.github/workflows/release_notes.yml b/.github/workflows/release_notes.yml new file mode 100644 index 000000000..2c0acd9d0 --- /dev/null +++ b/.github/workflows/release_notes.yml @@ -0,0 +1,52 @@ +name: Release Notes Generator + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to merge release notes into.' + required: true + default: 'main' + version: + description: + 'Version to use for the release. Must be in format: X.Y.Z.' + date: + description: + 'Date of the release. Must be in format YYYY-MM-DD.' + +jobs: + releasenotesgeneration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install requests==2.31.0 + + - name: Generate release notes + env: + GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + run: > + python scripts/release_notes_generator.py + -v ${{ inputs.version }} + -d ${{ inputs.date }} + + - name: Create pull request + id: cpr + uses: peter-evans/create-pull-request@v4 + with: + token: ${{ secrets.GH_ACCESS_TOKEN }} + commit-message: Release notes for v${{ inputs.version }} + author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + title: v${{ inputs.version }} Release Notes + body: "This is an auto-generated PR to update the release notes." + branch: release-notes + branch-suffix: short-commit-hash + base: ${{ inputs.branch }} diff --git a/scripts/release_notes_generator.py b/scripts/release_notes_generator.py new file mode 100644 index 000000000..376851e3f --- /dev/null +++ b/scripts/release_notes_generator.py @@ -0,0 +1,153 @@ +"""Script to generate release notes.""" + +import argparse +import os +from collections import defaultdict + +import requests + +LABEL_TO_HEADER = { + 'feature request': 'New Features', + 'bug': 'Bugs Fixed', + 'internal': 'Internal', + 'maintenance': 'Maintenance', + 'customer success': 'Customer Success', + 'documentation': 'Documentation', + 'misc': 'Miscellaneous' +} +ISSUE_LABELS = [ + 'documentation', + 'maintenance', + 'internal', + 'bug', + 'feature request', + 'customer success' +] +NEW_LINE = '\n' +GITHUB_URL = 'https://api.github.com/repos/sdv-dev/sdv' +GITHUB_TOKEN = os.getenv('GH_ACCESS_TOKEN') + + +def _get_milestone_number(milestone_title): + url = f'{GITHUB_URL}/milestones' + headers = { + 'Authorization': f'Bearer {GITHUB_TOKEN}' + } + query_params = { + 'milestone': milestone_title, + 'state': 'all', + 'per_page': 100 + } + response = requests.get(url, headers=headers, params=query_params) + body = response.json() + if response.status_code != 200: + raise Exception(str(body)) + + milestones = body + for milestone in milestones: + if milestone.get('title') == milestone_title: + return milestone.get('number') + + raise ValueError(f'Milestone {milestone_title} not found in past 100 milestones.') + + +def _get_issues_by_milestone(milestone): + headers = { + 'Authorization': f'Bearer {GITHUB_TOKEN}' + } + # get milestone number + milestone_number = _get_milestone_number(milestone) + url = f'{GITHUB_URL}/issues' + page = 1 + query_params = { + 'milestone': milestone_number, + 'state': 'all' + } + issues = [] + while True: + query_params['page'] = page + response = requests.get(url, headers=headers, params=query_params) + body = response.json() + if response.status_code != 200: + raise Exception(str(body)) + + issues_on_page = body + if not issues_on_page: + break + + issues.extend(issues_on_page) + page += 1 + + return issues + + +def _get_issues_by_category(release_issues): + category_to_issues = defaultdict(list) + + for issue in release_issues: + issue_title = issue['title'] + issue_number = issue['number'] + issue_url = issue['html_url'] + line = f'* {issue_title} - Issue [#{issue_number}]({issue_url})' + assignee = issue.get('assignee') + if assignee: + login = assignee['login'] + line += f' by @{login}' + + # Check if any known label is marked on the issue + labels = [label['name'] for label in issue['labels']] + found_category = False + for category in ISSUE_LABELS: + if category in labels: + category_to_issues[category].append(line) + found_category = True + break + + if not found_category: + category_to_issues['misc'].append(line) + + return category_to_issues + + +def _create_release_notes(issues_by_category, version, date): + title = f'## v{version} - {date}' + release_notes = f'{title}{NEW_LINE}{NEW_LINE}' + + for category in ISSUE_LABELS + ['misc']: + issues = issues_by_category.get(category) + if issues: + section_text = ( + f'### {LABEL_TO_HEADER[category]}{NEW_LINE}{NEW_LINE}' + f'{NEW_LINE.join(issues)}{NEW_LINE}{NEW_LINE}' + ) + + release_notes += section_text + + return release_notes + + +def update_release_notes(release_notes): + """Add the release notes for the new release to the ``HISTORY.md``.""" + file_path = 'HISTORY.md' + with open(file_path, 'r') as history_file: + history = history_file.read() + + token = '# HISTORY\n\n' + split_index = history.find(token) + len(token) + 1 + header = history[:split_index] + new_notes = f'{header}{release_notes}{history[split_index:]}' + + with open(file_path, 'w') as new_history_file: + new_history_file.write(new_notes) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--version', type=str, help='Release version number (ie. v1.0.1)') + parser.add_argument('-d', '--date', type=str, help='Date of release in format YYYY-MM-DD') + args = parser.parse_args() + release_number = args.version + release_issues = _get_issues_by_milestone(release_number) + issues_by_category = _get_issues_by_category(release_issues) + release_notes = _create_release_notes(issues_by_category, release_number, args.date) + update_release_notes(release_notes)