Skip to content

Commit

Permalink
Merge pull request #2628 from mashehu/automated-changelog
Browse files Browse the repository at this point in the history
Automatically create changelog entries from PRs
  • Loading branch information
mashehu authored Jan 4, 2024
2 parents 1cd6bb6 + 7d38db3 commit f1e6126
Show file tree
Hide file tree
Showing 2 changed files with 292 additions and 0 deletions.
220 changes: 220 additions & 0 deletions .github/workflows/changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""
Taken from https://github.com/MultiQC/MultiQC/blob/main/.github/workflows/changelog.py and updated for nf-core
To be called by a CI action. Assumes the following environment variables are set:
PR_TITLE, PR_NUMBER, GITHUB_WORKSPACE.
Adds a line into the CHANGELOG.md:
* Looks for the section to add the line to, based on the PR title, e.g. `Template:`, `Modules:`.
* All other change will go under the "### General" section.
* If an entry for the PR is already added, it will not run.
Other assumptions:
- CHANGELOG.md has a running section for an ongoing "dev" version
(i.e. titled "## nf-core vX.Ydev").
"""

import os
import re
import subprocess
import sys
from pathlib import Path
from typing import List

REPO_URL = "https://github.com/nf-core/tools"

# Assumes the environment is set by the GitHub action.
pr_title = os.environ["PR_TITLE"]
pr_number = os.environ["PR_NUMBER"]
comment = os.environ.get("COMMENT", "")
workspace_path = Path(os.environ.get("GITHUB_WORKSPACE", ""))

assert pr_title, pr_title
assert pr_number, pr_number

# Trim the PR number added when GitHub squashes commits, e.g. "Template: Updated (#2026)"
pr_title = pr_title.removesuffix(f" (#{pr_number})")

changelog_path = workspace_path / "CHANGELOG.md"

if any(
line in pr_title.lower()
for line in [
"skip changelog",
"skip change log",
"no changelog",
"no change log",
"bump version",
]
):
print("Skipping changelog update")
sys.exit(0)


def _run_cmd(cmd):
print(cmd)
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Error executing command: {result.stderr}")
return result


def _determine_change_type(pr_title) -> str:
"""
Determine the type of the PR: Template, Download, Linting, Modules, Subworkflows, or General
Returns a tuple of the section name and the module info.
"""
sections = {
"Template": "### Template updates",
"Download": "### Download updates",
"Linting": "### Linting updates",
"Modules": "### Modules",
"Subworkflows": "### Subworkflows",
}
current_section = "### General"

# Check if the PR in any of the sections.
for section, section_header in sections.items():
# check if the PR title contains any of the section headers, with some loose matching, e.g. removing plural and suffixes
if re.sub(r"s$", "", section.lower().replace("ing", "")) in pr_title.lower():
current_section = section_header

return current_section


# Determine the type of the PR: new module, module update, or core update.
section = _determine_change_type(pr_title)

# Prepare the change log entry.
pr_link = f"([#{pr_number}]({REPO_URL}/pull/{pr_number}))"

# Handle manual changelog entries through comments.
if comment := comment.removeprefix("@nf-core-bot changelog").strip():
pr_title = comment
new_lines = [
f"- {pr_title} {pr_link}\n",
]

# Finally, updating the changelog.
# Read the current changelog lines. We will print them back as is, except for one new
# entry, corresponding to this new PR.
with changelog_path.open("r") as f:
orig_lines = f.readlines()
updated_lines: List[str] = []


def _skip_existing_entry_for_this_pr(line: str, same_section: bool = True) -> str:
if line.strip().endswith(pr_link):
existing_lines = [line]
if new_lines and new_lines == existing_lines and same_section:
print(f"Found existing identical entry for this pull request #{pr_number} in the same section:")
print("".join(existing_lines))
sys.exit(0) # Just leaving the CHANGELOG intact
else:
print(
f"Found existing entry for this pull request #{pr_number}. It will be replaced and/or moved to proper section"
)
print("".join(existing_lines))
for _ in range(len(existing_lines)):
try:
line = orig_lines.pop(0)
except IndexError:
break
return line


# Find the next line in the change log that matches the pattern "## MultiQC v.*dev"
# If it doesn't exist, exist with code 1 (let's assume that a new section is added
# manually or by CI when a release is pushed).
# Else, find the next line that matches the `section` variable, and insert a new line
# under it (we also assume that section headers are added already).
inside_version_dev = False
already_added_entry = False
while orig_lines:
line = orig_lines.pop(0)

# If the line already contains a link to the PR, don't add it again.
line = _skip_existing_entry_for_this_pr(line, same_section=False)

if line.startswith("# ") and not line.strip() == "# nf-core/tools: Changelog": # Version header, e.g. "# v2.12dev"
updated_lines.append(line)

# Parse version from the line `# v2.12dev` or
# `# [v2.11.1 - Magnesium Dragon Patch](https://github.com/nf-core/tools/releases/tag/2.11) - [2023-12-20]` ...
if not (m := re.match(r".*(v\d+\.\d+(dev)?).*", line)):
print(f"Cannot parse version from line {line.strip()}.", file=sys.stderr)
sys.exit(1)
version = m.group(1)

if not inside_version_dev:
if not version.endswith("dev"):
print(
"Can't find a 'dev' version section in the changelog. Make sure "
"it's created, and all the required sections, e.g. `### Template` are created under it .",
file=sys.stderr,
)
sys.exit(1)
inside_version_dev = True
else:
if version.endswith("dev"):
print(
f"Found another 'dev' version section in the changelog, make"
f"sure to change it to a 'release' stable version tag. "
f"Line: {line.strip()}",
file=sys.stderr,
)
sys.exit(1)
# We are past the dev version, so just add back the rest of the lines and break.
while orig_lines:
line = orig_lines.pop(0)
line = _skip_existing_entry_for_this_pr(line, same_section=False)
if line:
updated_lines.append(line)
break
continue

if inside_version_dev and line.lower().startswith(section.lower()): # Section of interest header
if already_added_entry:
print(f"Already added new lines into section {section}, is the section duplicated?", file=sys.stderr)
sys.exit(1)
updated_lines.append(line)
# Collecting lines until the next section.
section_lines: List[str] = []
while True:
line = orig_lines.pop(0)
if line.startswith("#"):
# Found the next section header, so need to put all the lines we collected.
updated_lines.append("\n")
_updated_lines = [_l for _l in section_lines + new_lines if _l.strip()]
updated_lines.extend(_updated_lines)
updated_lines.append("\n")
if new_lines:
print(f"Updated {changelog_path} section '{section}' with lines:\n" + "".join(new_lines))
else:
print(f"Removed existing entry from {changelog_path} section '{section}'")
already_added_entry = True
# Pushing back the next section header line
orig_lines.insert(0, line)
break
# If the line already contains a link to the PR, don't add it again.
line = _skip_existing_entry_for_this_pr(line, same_section=True)
section_lines.append(line)
else:
updated_lines.append(line)


def collapse_newlines(lines: List[str]) -> List[str]:
updated = []
for idx in range(len(lines)):
if idx != 0 and not lines[idx].strip() and not lines[idx - 1].strip():
continue
updated.append(lines[idx])
return updated


updated_lines = collapse_newlines(updated_lines)


# Finally, writing the updated lines back.
with changelog_path.open("w") as f:
f.writelines(updated_lines)
72 changes: 72 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Update CHANGELOG.md
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened]

jobs:
update_changelog:
runs-on: ubuntu-latest
# Run if comment is on a PR with the main repo, and if it contains the magic keywords.
# Or run on PR creation, unless asked otherwise in the title.
if: |
github.repository_owner == 'nf-core' && (
github.event_name == 'pull_request_target' ||
github.event.issue.pull_request && startsWith(github.event.comment.body, '@nf-core-bot changelog')
)
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.NF_CORE_BOT_AUTH_TOKEN }}

# Action runs on the issue comment, so we don't get the PR by default.
# Use the GitHub CLI to check out the PR:
- name: Checkout Pull Request
env:
GH_TOKEN: ${{ secrets.NF_CORE_BOT_AUTH_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "issue_comment" ]]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [[ "${{ github.event_name }}" == "pull_request_target" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
fi
gh pr checkout $PR_NUMBER
- uses: actions/setup-python@v5

- name: Install packages
run: |
python -m pip install --upgrade pip
pip install pyyaml
- name: Update CHANGELOG.md from the PR title
env:
COMMENT: ${{ github.event.comment.body }}
GH_TOKEN: ${{ secrets.NF_CORE_BOT_AUTH_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "issue_comment" ]]; then
export PR_NUMBER='${{ github.event.issue.number }}'
export PR_TITLE='${{ github.event.issue.title }}'
elif [[ "${{ github.event_name }}" == "pull_request_target" ]]; then
export PR_NUMBER='${{ github.event.pull_request.number }}'
export PR_TITLE='${{ github.event.pull_request.title }}'
fi
python ${GITHUB_WORKSPACE}/.github/workflows/changelog.py
- name: Check if CHANGELOG.md actually changed
run: |
git diff --exit-code ${GITHUB_WORKSPACE}/CHANGELOG.md || echo "changed=YES" >> $GITHUB_ENV
echo "File changed: ${{ env.changed }}"
- name: Commit and push changes
if: env.changed == 'YES'
run: |
git config user.name 'nf-core bot'
git config user.email 'nf-core-bot@nf-co.re'
git config push.default upstream
git add ${GITHUB_WORKSPACE}/CHANGELOG.md
git status
git commit -m "[automated] Update CHANGELOG.md"
git push

0 comments on commit f1e6126

Please sign in to comment.