Skip to content

Automatically Create Changelog Entry for PRs #313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
36 changes: 36 additions & 0 deletions .github/CHANGELOG_AUTOMATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Automatic Changelog Generation

This repository automatically generates changelog entries for new PRs using a GitHub Action.

## How it works

1. **Trigger**: When a PR is opened or edited
2. **Processing**: The action runs a Python script that:
- Parses the `docs/changelog.md` file
- Finds the "## UNRELEASED" section
- Adds a new entry with format: `- PR_TITLE [#PR_NUMBER](PR_URL)`
- Checks for duplicates to avoid repeated entries
3. **Update**: Commits the changes back to the PR branch

## Files

- `.github/workflows/update-changelog.yml` - GitHub Action workflow
- `.github/scripts/update_changelog.py` - Python script that updates the changelog

## Safety features

- Only runs for PRs from the same repository (not forks)
- Prevents infinite loops by excluding commits made by GitHub Action
- Includes duplicate detection
- Proper error handling and logging

## Manual usage

You can also run the script manually:

```bash
python .github/scripts/update_changelog.py \
--pr-number="123" \
--pr-title="My PR Title" \
--pr-url="https://github.com/egraphs-good/egglog-python/pull/123"
```
100 changes: 100 additions & 0 deletions .github/scripts/test_update_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Simple test for the changelog update script.
"""

import os
import tempfile
import sys
from pathlib import Path

# Add the scripts directory to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))

from update_changelog import update_changelog, find_unreleased_section


def test_find_unreleased_section():
"""Test finding the unreleased section."""
lines = [
"# Changelog\n",
"\n",
"## UNRELEASED\n",
"\n",
"- Some existing entry\n",
"\n",
"## 1.0.0\n",
"- Released version\n"
]

unreleased_start, content_start = find_unreleased_section(lines)
assert unreleased_start == 2, f"Expected unreleased_start=2, got {unreleased_start}"
assert content_start == 4, f"Expected content_start=4, got {content_start}"
print("✓ find_unreleased_section test passed")


def test_update_changelog():
"""Test updating the changelog."""
# Create a temporary changelog file
changelog_content = """# Changelog

## UNRELEASED

- Existing entry

## 1.0.0

- Released version
"""

with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
f.write(changelog_content)
temp_path = f.name

try:
# Update the changelog
result = update_changelog(temp_path, "999", "Test PR", "https://example.com/pr/999")
assert result == True, "update_changelog should return True on success"

# Read the updated content
with open(temp_path, 'r') as f:
updated_content = f.read()

# Check that the entry was added
assert "- Test PR [#999](https://example.com/pr/999)" in updated_content

# Check that it was added in the right place (after UNRELEASED)
lines = updated_content.split('\n')
unreleased_idx = lines.index("## UNRELEASED")
entry_idx = None
for i, line in enumerate(lines):
if "Test PR [#999]" in line:
entry_idx = i
break

assert entry_idx is not None, "Entry should be found"
assert entry_idx > unreleased_idx, "Entry should be after UNRELEASED section"

# Test duplicate detection
result2 = update_changelog(temp_path, "999", "Test PR", "https://example.com/pr/999")
assert result2 == False, "update_changelog should return False for duplicates"

print("✓ update_changelog test passed")

finally:
# Clean up
os.unlink(temp_path)


def main():
"""Run all tests."""
print("Running changelog automation tests...")

test_find_unreleased_section()
test_update_changelog()

print("✓ All tests passed!")


if __name__ == '__main__':
main()
96 changes: 96 additions & 0 deletions .github/scripts/update_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Script to automatically update the changelog with PR information.
"""

import argparse
import re
import sys
from pathlib import Path


def find_unreleased_section(lines):
"""Find the line number where UNRELEASED section starts and ends."""
unreleased_start = None
content_start = None

for i, line in enumerate(lines):
if line.strip() == "## UNRELEASED":
unreleased_start = i
continue

if unreleased_start is not None and content_start is None:
# Skip empty lines after ## UNRELEASED
if line.strip() == "":
continue
else:
content_start = i
break

return unreleased_start, content_start


def update_changelog(changelog_path, pr_number, pr_title, pr_url):
"""Update the changelog with the new PR entry."""

# Read the current changelog
with open(changelog_path, 'r', encoding='utf-8') as f:
lines = f.readlines()

# Find the UNRELEASED section
unreleased_start, content_start = find_unreleased_section(lines)

if unreleased_start is None:
print("ERROR: Could not find '## UNRELEASED' section in changelog")
return False

if content_start is None:
print("ERROR: Could not find content start after UNRELEASED section")
return False

# Create the new entry
new_entry = f"- {pr_title} [#{pr_number}]({pr_url})\n"

# Check if this PR entry already exists to avoid duplicates
for line in lines[content_start:]:
if f"[#{pr_number}]" in line:
print(f"Changelog entry for PR #{pr_number} already exists")
return False
# Stop checking when we reach the next section
if line.startswith("## ") and not line.strip() == "## UNRELEASED":
break

# Insert the new entry at the beginning of the unreleased content
lines.insert(content_start, new_entry)

# Write the updated changelog
with open(changelog_path, 'w', encoding='utf-8') as f:
f.writelines(lines)

print(f"Added changelog entry for PR #{pr_number}: {pr_title}")
return True


def main():
parser = argparse.ArgumentParser(description='Update changelog with PR information')
parser.add_argument('--pr-number', required=True, help='Pull request number')
parser.add_argument('--pr-title', required=True, help='Pull request title')
parser.add_argument('--pr-url', required=True, help='Pull request URL')
parser.add_argument('--changelog-path', default='docs/changelog.md', help='Path to changelog file')

args = parser.parse_args()

changelog_path = Path(args.changelog_path)

if not changelog_path.exists():
print(f"ERROR: Changelog file not found: {changelog_path}")
sys.exit(1)

success = update_changelog(changelog_path, args.pr_number, args.pr_title, args.pr_url)

if not success:
sys.exit(1)


if __name__ == '__main__':
main()
56 changes: 56 additions & 0 deletions .github/workflows/update-changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Update Changelog

on:
pull_request:
types: [opened, edited]

jobs:
update-changelog:
# Only run if this is not a PR from a fork to avoid permission issues
# and not a commit made by GitHub Action to avoid infinite loops
if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# Checkout the PR head ref
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Test changelog script
run: python .github/scripts/test_update_changelog.py

- name: Update changelog
run: |
python .github/scripts/update_changelog.py \
--pr-number="${{ github.event.pull_request.number }}" \
--pr-title="${{ github.event.pull_request.title }}" \
--pr-url="${{ github.event.pull_request.html_url }}"

- name: Check for changes
id: changes
run: |
if git diff --quiet docs/changelog.md; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Commit and push changes
if: steps.changes.outputs.changed == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add docs/changelog.md
git commit -m "Add changelog entry for PR #${{ github.event.pull_request.number }}"
git push
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ _This project uses semantic versioning_

## UNRELEASED

- [WIP] Automatically Generate Changelog Entries for PRs [#313](https://github.com/egraphs-good/egglog-python/pull/313)
- Automatically Generate Changelog Entries for PRs [#312](https://github.com/egraphs-good/egglog-python/pull/312)
- Upgrade egglog which includes new backend.
- Fixes implementation of the Python Object sort to work with objects with dupliating hashes but the same value.
Also changes the representation to be an index into a list instead of the ID, making egglog programs more deterministic.
Expand Down