-
Notifications
You must be signed in to change notification settings - Fork 12
feat(build): add a change log generator #2832
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
Draft
smailio
wants to merge
11
commits into
dev
Choose a base branch
from
feat/changelog-generator
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+191
−4
Draft
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
eba34d4
wip
2776dd2
wip
19d9244
renaming and refacto
b35a99f
fix typo
351cab6
wip
85706af
integrate generate change log in update script
883653b
Merge remote-tracking branch 'origin/dev' into feat/changelog-generator
0f64256
fix integration
99f3b95
make target branch head instead of dev
3746175
clean and add typing
dffa95e
fix multi lien body bug
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| #!/usr/bin/env python3 | ||
| import re | ||
| import subprocess | ||
| import typing as t | ||
| from collections import defaultdict | ||
|
|
||
| # Adjust if your main branch is named differently | ||
| BASE_BRANCH = "origin/master" | ||
| TARGET_BRANCH = "HEAD" | ||
| REPO_URL = "https://github.com/AntaresSimulatorTeam/AntaREST" | ||
|
|
||
|
|
||
| ChangelogEntry: t.TypeAlias = t.Tuple[str, str, str, bool] # (scope, message, pr_number, is_breaking) | ||
| ChangeLogByCategory: t.TypeAlias = t.Dict[str, list[ChangelogEntry]] | ||
|
|
||
|
|
||
| def run_git(*args: str) -> str: | ||
| """Run a git command and return its output.""" | ||
| """Run a git command and return its output. Raises a detailed error if the command fails.""" | ||
| try: | ||
| result = subprocess.run(["git", *args], capture_output=True, text=True, check=True) | ||
| return result.stdout.strip() | ||
| except subprocess.CalledProcessError as e: | ||
| cmd_str = "git " + " ".join(args) | ||
| error_msg = ( | ||
| f"Git command failed: {cmd_str}\nReturn code: {e.returncode}\nstdout:\n{e.stdout}\nstderr:\n{e.stderr}" | ||
| ) | ||
| raise RuntimeError(error_msg) from e | ||
|
|
||
|
|
||
| def fetch_branches() -> None: | ||
| """Fetch the latest changes from origin for master and dev branches.""" | ||
| subprocess.run(["git", "fetch", "origin", "master", "dev"], capture_output=True, check=True) | ||
|
|
||
|
|
||
| def extract_pr_number(subject: str) -> t.Optional[str]: | ||
| """Extract PR number from commit subject""" | ||
| pattern = r"\(#(\d+)\)" # PR number pattern | ||
| if match := re.search(pattern, subject): | ||
| return match.group(1) | ||
| return None | ||
|
|
||
|
|
||
| def get_commits(base_branch: str, target_branch: str) -> list[str]: | ||
| """Get commits in target_branch that are not in base_branch.""" | ||
| log_format = "%H|%s|%an" # Commit hash | Subject | Author name | ||
| output = run_git("log", f"{base_branch}..{target_branch}", f"--pretty=format:{log_format}") | ||
| return output.splitlines() if output else [] | ||
|
|
||
|
|
||
| def get_commits_for_release() -> t.Iterator[str]: | ||
| """Generate formatted commit messages with PR links.""" | ||
| fetch_branches() | ||
| commits = get_commits(BASE_BRANCH, TARGET_BRANCH) | ||
|
|
||
| if not commits: | ||
| print(f"No commits found in {TARGET_BRANCH} that are not in {BASE_BRANCH}.") | ||
| raise ValueError("No commits to generate changelog from.") | ||
|
|
||
| for line in commits: | ||
| parts = line.split("|", 2) | ||
| if len(parts) < 3: | ||
| raise ValueError(f"Unexpected commit format: {line}", commits) | ||
| _, subject, author = parts | ||
| pr = extract_pr_number(subject) | ||
| subject_without_pr = subject.replace(f"(#{pr})", "").strip() | ||
| pr_link = f"{REPO_URL}/pull/{pr}" if pr else "(no PR link)" | ||
| yield f"* {subject_without_pr} by @{author} in {pr_link}" | ||
|
|
||
|
|
||
| def parse_changes(release_commits_list: list[str]) -> ChangeLogByCategory: | ||
| """Parse commit messages and group by category (conventional commits format).""" | ||
| CATEGORY_MAP = { | ||
| "feat": "Features", | ||
| "fix": "Bug fixes", | ||
| "refactor": "Refactorings", | ||
| "perf": "Performances", | ||
| "doc": "Documentation", | ||
| "docs": "Documentation", | ||
| "test": "Tests", | ||
| "build": "Build", | ||
| "chore": "Chore", | ||
| } | ||
| grouped_changes: ChangeLogByCategory = defaultdict(list[ChangelogEntry]) | ||
| unmatched_commits = [] | ||
|
|
||
| # Regex pattern to match conventional commits with optional breaking change indicator. | ||
| # Pattern breakdown: | ||
| # \* (?P<kind>\w+) - Commit type (feat, fix, etc.) after '* ' | ||
| # \((?P<scope>[^)]+)\) - Scope inside parentheses | ||
| # (?P<breaking>!?) - Optional '!' for breaking changes | ||
| # : (?P<message>.*?) - Commit message (non-greedy, up to ' by @') | ||
| # by @.+ in .+/(?P<pr_number>\d+) - Author and PR number at the end | ||
| # Note: The message group is non-greedy to avoid over-matching if ' by @' appears in the message. | ||
| pattern = ( | ||
| r"\* " | ||
| r"(?P<kind>\w+)" | ||
| r"\((?P<scope>[^)]+)\)" | ||
| r"(?P<breaking>!?): " | ||
| r"(?P<message>.+?)" | ||
| r" by @.+ in .+/" | ||
| r"(?P<pr_number>\d+)" | ||
| ) | ||
| for line in release_commits_list: | ||
| match = re.match(pattern, line.strip()) | ||
| if not match: | ||
| unmatched_commits.append(line) | ||
| continue | ||
| kind = match.group("kind") | ||
| scope = match.group("scope") | ||
| breaking_indicator = match.group("breaking") | ||
| message = match.group("message") | ||
| pr_number = match.group("pr_number") | ||
|
|
||
| category = CATEGORY_MAP.get(kind, "Other") | ||
| is_breaking = breaking_indicator == "!" | ||
| grouped_changes[category].append((scope, message, pr_number, is_breaking)) | ||
|
|
||
| if unmatched_commits: | ||
| error_msg = ( | ||
| f"The following {len(unmatched_commits)} commit(s) do not follow the conventional commit format:\n" | ||
| + "\n".join(f" - {commit}" for commit in unmatched_commits) | ||
| ) | ||
| raise ValueError(error_msg) | ||
|
|
||
| return grouped_changes | ||
|
|
||
|
|
||
| def mk_changelog_str(grouped_changes: t.Dict[str, list[ChangelogEntry]]) -> str: | ||
| """Generate a formatted changelog string from grouped changes.""" | ||
| changelog = [] | ||
| # Define the order of categories | ||
| category_order = [ | ||
| "Features", | ||
| "Bug fixes", | ||
| "Performances", | ||
| "Refactorings", | ||
| "Documentation", | ||
| "Tests", | ||
| "Build", | ||
| "Chore", | ||
| "Other", | ||
| ] | ||
|
|
||
| for category in category_order: | ||
| if category in grouped_changes: | ||
| changelog.append(f"### {category}\n") | ||
|
smailio marked this conversation as resolved.
|
||
| for scope, message, pr_number, is_breaking in grouped_changes[category]: | ||
| badge = ( | ||
| " " | ||
| if is_breaking | ||
| else "" | ||
| ) | ||
| pr_link = f"{REPO_URL}/pull/{pr_number}" | ||
| changelog.append(f"* **{scope}**: {message} [`#{pr_number}`]({pr_link}){badge}") | ||
| changelog.append("") # Add blank line between categories | ||
|
|
||
| return "\n".join(changelog) | ||
|
|
||
|
|
||
| def generate_changelog() -> str: | ||
| """Generate the complete changelog.""" | ||
| release_commits_list = list(get_commits_for_release()) | ||
|
|
||
| if not release_commits_list: | ||
| return "No changes to report." | ||
|
|
||
| changes_by_category = parse_changes(release_commits_list) | ||
| if not changes_by_category: | ||
| raise ValueError("No conventional commits found to generate changelog.") | ||
| return mk_changelog_str(changes_by_category) | ||
|
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| print(generate_changelog()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to
run_git(), this subprocess call withcheck=Truewill raise a generic CalledProcessError on failure. Consider adding error handling or a descriptive message for when the fetch operation fails.