From a41af0c250329ba65f1b230063e3c25e048e72a0 Mon Sep 17 00:00:00 2001 From: changelog bot Date: Tue, 16 Apr 2024 12:23:06 +0200 Subject: [PATCH] Automatically populate the changelog from GitHub PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the `gh` tool, it’s available by default on GitHub actions. --- .github/pull_request_template.md | 1 - .github/workflows/mkchangelog.yml | 19 ++++ .github/workflows/require-labels.yml | 14 +++ scripts/mkchangelog | 136 +++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mkchangelog.yml create mode 100644 .github/workflows/require-labels.yml create mode 100755 scripts/mkchangelog diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 98f7160b40d..9c1daebaad4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,7 +10,6 @@ ## :rotating_light: À vérifier -- [ ] Ajouter l'étiquette « no-changelog » ? - [ ] Mettre à jour le CHANGELOG_breaking_changes.md ? ## :desert_island: Comment tester diff --git a/.github/workflows/mkchangelog.yml b/.github/workflows/mkchangelog.yml new file mode 100644 index 00000000000..18ffd82c509 --- /dev/null +++ b/.github/workflows/mkchangelog.yml @@ -0,0 +1,19 @@ +name: 📃 Changelog + +on: + schedule: + - cron: 0 0 * * 1 + +jobs: + generate-changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.2 + - name: 💂 Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: 📥 Generate changelog + run: scripts/mkchangelog + env: + GH_TOKEN: ${{ secrets.ITOU_TECH_GH_TOKEN }} diff --git a/.github/workflows/require-labels.yml b/.github/workflows/require-labels.yml new file mode 100644 index 00000000000..81207389f7c --- /dev/null +++ b/.github/workflows/require-labels.yml @@ -0,0 +1,14 @@ +name: Label Checks + +on: [pull_request, merge_group] + +jobs: + require-label: + runs-on: ubuntu-latest + steps: + - name: Verify changelog label + uses: mheap/github-action-required-labels@v5.4.0 + with: + mode: exactly + count: 1 + labels: "ajouté, modifié, supprimé, dependencies, no-changelog" diff --git a/scripts/mkchangelog b/scripts/mkchangelog new file mode 100755 index 00000000000..cddb26acd63 --- /dev/null +++ b/scripts/mkchangelog @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + + +import datetime +import json +import logging +import os +import pathlib +import re +import subprocess +import sys +import tempfile + + +logger = logging.getLogger(__name__) + + +def gh(*args, **kwargs): + env = ( + { + "GH_TOKEN": os.getenv("GH_TOKEN", ""), + "PATH": os.environ["PATH"], + } + if os.getenv("CI", False) + else None + ) + return subprocess.run(["gh", *args], check=True, env=env, **kwargs) + + +def list_pull_requests(start, end, labels): + limit = 1000 + result = gh( + "search", + "prs", + "--merged-at", + f"{start}..{end}", + "--repo", + "gip-inclusion/les-emplois", + "--limit", + f"{limit}", + "--json", + "title,url,labels,body", + "--", + labels, + capture_output=True, + ) + pull_requests = json.loads(result.stdout) + if len(pull_requests) == limit: + sys.exit("Limit has been exceeded when fetching pull requests, keep up the good work!") + return pull_requests + + +def list_missing_pull_requests(start, end): + return list_pull_requests(start, end, "-label:ajouté,modifié,supprimé,dependencies,no-changelog") + + +def list_merged_pull_requests(start, end): + return list_pull_requests(start, end, "label:ajouté,modifié,supprimé") + + +def open_pull_request(branch, title): + gh("pr", "create", "--label", "no-changelog", "--title", title, "--body", "") + + +def main(): + changelog_path = pathlib.Path("CHANGELOG.md") + with ( + open(changelog_path, "r+") as changelog, + tempfile.NamedTemporaryFile(dir=os.getcwd(), mode="w+", suffix="~", delete=False) as new_changelog, + ): + try: + for line in changelog: + if line.strip() in ["# Journal des modifications", ""]: + new_changelog.write(line) + continue + break + last_entry_header = line + + _h2, sprint_start_text = last_entry_header.split() + sprint_start = datetime.date.fromisoformat(sprint_start_text) + sprint_end = sprint_start + datetime.timedelta(days=7) + + if missing := list_missing_pull_requests(sprint_start, sprint_end): + missing_pull_requests = "\n- ".join(pr["url"] for pr in missing) + sys.exit(f"The following pull requests should have a label:\n- {missing_pull_requests}\n") + + added, changed, removed = [], [], [] + for pull_request in list_merged_pull_requests(sprint_start, sprint_end): + title = re.sub(r"\[(GEN|ITOU)-[0-9]+\]", "", pull_request["title"]).strip() + title = f"[{title}]({pull_request['url']})" + if re.search(r"\!\[[^]]+]", pull_request["body"]): + title += " 🖼" + labels = [label["name"] for label in pull_request["labels"]] + if len({"ajouté", "modifié", "supprimé"}.intersection(set(labels))) != 1: + label_names = " ".join(labels) + raise ValueError( + f"Expected only one of “ajouté”, “modifié” or “supprimé” in labels, got {label_names}." + ) + if "ajouté" in labels: + added.append(title) + elif "modifié" in labels: + changed.append(title) + else: + removed.append(title) + + if added or changed or removed: + new_changelog.write(f"## {sprint_end}\n\n") + if added: + new_changelog.write("### Ajouté\n\n") + for title in sorted(added): + new_changelog.write(f"- {title}\n") + new_changelog.write("\n") + if changed: + new_changelog.write("### Modifié\n\n") + for title in sorted(changed): + new_changelog.write(f"- {title}\n") + new_changelog.write("\n") + if removed: + new_changelog.write("### Supprimé\n\n") + for title in sorted(removed): + new_changelog.write(f"- {title}\n") + new_changelog.write("\n") + + # Don’t forget the previous header. + new_changelog.write(last_entry_header) + for line in changelog: + new_changelog.write(line) + + # Atomically replace file. + pathlib.Path(new_changelog.name).rename(changelog_path) + finally: + pathlib.Path(new_changelog.name).unlink(missing_ok=True) + + +if __name__ == "__main__": + main()