From 53e352ccad78ebf61953f9e4883cf6104ba7edfa Mon Sep 17 00:00:00 2001 From: Charles Beauville Date: Fri, 22 Dec 2023 13:26:48 +0100 Subject: [PATCH] Add script to automatically update changelog (#2660) --------- Co-authored-by: Daniel J. Beutel Co-authored-by: Robert Steiner --- .github/PULL_REQUEST_TEMPLATE.md | 21 +- ...tributor-tutorial-contribute-on-github.rst | 98 +++++++- pyproject.toml | 1 + src/py/flwr_tool/update_changelog.py | 230 ++++++++++++++++++ 4 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 src/py/flwr_tool/update_changelog.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8d73ed618919..479f88c1bbd5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -37,10 +37,29 @@ Example: The variable `rnd` was renamed to `server_round` to improve readability - [ ] Implement proposed change - [ ] Write tests - [ ] Update [documentation](https://flower.dev/docs/writing-documentation.html) -- [ ] Update [changelog](https://github.com/adap/flower/blob/main/doc/source/changelog.rst) +- [ ] Update the changelog entry below - [ ] Make CI checks pass - [ ] Ping maintainers on [Slack](https://flower.dev/join-slack/) (channel `#contributions`) + + +### Changelog entry + + + ### Any other comments? ", "", entry_text, flags=re.DOTALL).strip() + + token_markers = { + "general": "", + "skip": "", + "baselines": "", + "examples": "", + "sdk": "", + "simulations": "", + } + + # Find the token based on the presence of its marker in entry_text + token = next( + (token for token, marker in token_markers.items() if marker in entry_text), None + ) + + return entry_text, token + + +def _update_changelog(prs): + """Update the changelog file with entries from provided pull requests.""" + with open(CHANGELOG_FILE, "r+", encoding="utf-8") as file: + content = file.read() + unreleased_index = content.find("## Unreleased") + + if unreleased_index == -1: + print("Unreleased header not found in the changelog.") + return + + # Find the end of the Unreleased section + next_header_index = content.find("##", unreleased_index + 1) + next_header_index = ( + next_header_index if next_header_index != -1 else len(content) + ) + + for pr_info in prs: + pr_entry_text, category = _extract_changelog_entry(pr_info) + + # Skip if PR should be skipped or already in changelog + if category == "skip" or f"#{pr_info.number}]" in content: + continue + + pr_reference = _format_pr_reference( + pr_info.title, pr_info.number, pr_info.html_url + ) + + # Process based on category + if category in ["general", "baselines", "examples", "sdk", "simulations"]: + entry_title = _get_category_title(category) + content = _update_entry( + content, + entry_title, + pr_info, + unreleased_index, + next_header_index, + ) + + elif pr_entry_text: + content = _insert_new_entry( + content, pr_info, pr_reference, pr_entry_text, unreleased_index + ) + + else: + content = _insert_entry_no_desc(content, pr_reference, unreleased_index) + + next_header_index = content.find("##", unreleased_index + 1) + next_header_index = ( + next_header_index if next_header_index != -1 else len(content) + ) + + # Finalize content update + file.seek(0) + file.write(content) + file.truncate() + + print("Changelog updated.") + + +def _get_category_title(category): + """Get the title of a changelog section based on its category.""" + headers = { + "general": "General improvements", + "baselines": "General updates to Flower Baselines", + "examples": "General updates to Flower Examples", + "sdk": "General updates to Flower SDKs", + "simulations": "General updates to Flower Simulations", + } + return headers.get(category, "") + + +def _update_entry( + content, category_title, pr_info, unreleased_index, next_header_index +): + """Update a specific section in the changelog content.""" + if ( + section_index := content.find( + category_title, unreleased_index, next_header_index + ) + ) != -1: + newline_index = content.find("\n", section_index) + closing_parenthesis_index = content.rfind(")", unreleased_index, newline_index) + updated_entry = f", [{pr_info.number}]({pr_info.html_url})" + content = ( + content[:closing_parenthesis_index] + + updated_entry + + content[closing_parenthesis_index:] + ) + else: + new_section = ( + f"\n- **{category_title}** ([#{pr_info.number}]({pr_info.html_url}))\n" + ) + insert_index = content.find("\n", unreleased_index) + 1 + content = content[:insert_index] + new_section + content[insert_index:] + return content + + +def _insert_new_entry(content, pr_info, pr_reference, pr_entry_text, unreleased_index): + """Insert a new entry into the changelog.""" + if (existing_entry_start := content.find(pr_entry_text)) != -1: + pr_ref_end = content.rfind("\n", 0, existing_entry_start) + updated_entry = ( + f"{content[pr_ref_end]}\n, [{pr_info.number}]({pr_info.html_url})" + ) + content = content[:pr_ref_end] + updated_entry + content[existing_entry_start:] + else: + insert_index = content.find("\n", unreleased_index) + 1 + content = ( + content[:insert_index] + + pr_reference + + "\n " + + pr_entry_text + + "\n" + + content[insert_index:] + ) + return content + + +def _insert_entry_no_desc(content, pr_reference, unreleased_index): + """Insert a changelog entry for a pull request with no specific description.""" + insert_index = content.find("\n", unreleased_index) + 1 + content = ( + content[:insert_index] + "\n" + pr_reference + "\n" + content[insert_index:] + ) + return content + + +def main(): + """Update changelog using the descriptions of PRs since the latest tag.""" + # Initialize GitHub Client with provided token (as argument) + gh_api = Github(argv[1]) + latest_tag = _get_latest_tag(gh_api) + if not latest_tag: + print("No tags found in the repository.") + return + + prs = _get_pull_requests_since_tag(gh_api, latest_tag) + _update_changelog(prs) + + +if __name__ == "__main__": + main()