Skip to content

Commit

Permalink
Merge pull request #31 from igordertigor/feature/changelog
Browse files Browse the repository at this point in the history
Feature/changelog
  • Loading branch information
igordertigor authored Sep 2, 2023
2 parents 8f3ce23 + a3e2764 commit e1e0a97
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 19 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/attempt-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ jobs:
git tag
echo $${{ steps.set_output.outputs.should_publish }}
- name: Create changelog
run: semv --changelog > ${{ github.workspace }}-CHANGELOG.md

# The following steps should be more or less standard python package publishing
# Feel free to omit the github release.
- name: Create github release
uses: softprops/action-gh-release@v1
if: ${{ steps.set_output.outputs.should_publish }} == 'true'
with:
tag_name: ${{ steps.print-version.outputs.semv-out }}
generate_release_notes: true # autogenerate release notes while semv doesn't create change logs yet
body_path: ${{ github.workspace }}-CHANGELOG.md
generate_release_notes: false # do not generate release notes, we generated them with semv

- name: Build package
if: ${{ steps.set_output.outputs.should_publish }} == 'true'
Expand Down
47 changes: 46 additions & 1 deletion docs/alternative-usage.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Alternative usage

## As a `commit-msg` hook

You can use semv as a `commit-msg` hook to check commit messages before
commiting them to version control. This will only validate the commit format
committing them to version control. This will only validate the commit format
but not run additional checks. To run semv as a `commit-msg` hook, store the following in a file `.git/hooks/commit-msg`:
```bash
#!/bin/bash
Expand All @@ -13,3 +15,46 @@ and make the file executable by running
$ chmod u+x .git/hooks/commit-msg
```
Next time you commit an invalid commit message, git should give you an error.


## As an automatic changelog generator

If your commits have useful messages beyond the type/scope labelling, you may
want to use them for generating a changelog. Because semv already parses commit
messages, version v2.4.0 introduced a feature to automatically generate
changelogs. To do so, run semv with the `--changelog` option. The commit will
be formatted in [Markdown](https://www.markdownguide.org) and will start by
listing breaking changes. After that changes are grouped by type (first types
that imply a major release, them types that imply a minor release, and finally
types that imply a patch release) and then by scope.

To make most out of the changelog feature, use the default types. Other types
are supported, but the formatting is less nice. Keep in mind that commits are
grouped by type and scope—this allows you to incrementally write the
changelog with your commits. Keeping that in mind will also help you decide on
the wording for your commit messages. For example, this changelog:
```markdown
# New features
- changelog:
- Internal commit representation of summary attributes
- Add command for creating changelog with --changelog
- Markdown formatting of changelog
- Supports breaking changes (multiple per commit)
- Group commits by scope
- commit-parsing: Include commit summary
```
Is much nicer than this changelog:
```markdown
# New features
- General: Commit supports summary stuff
- command: Initial draft for changelog command
- parse: Include commit summary when parsing
- changelog:
- Changelog properly formatted except breaking change comments
- Support breaking changes
- group commits by scope
```
In fact, they both refer to the same commit history.

Note: You can rewrite past commits using `git rebase -i` *if you haven't pushed
them to a remote yet or if you can force-push to the remote*.
9 changes: 6 additions & 3 deletions docs/commit_parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ types *will not trigger a new version*.

## The `scope` keyword

Is parsed but not used at the moment (version v2.2.0).
Is parsed but not used for determining the new version. However, starting with
v2.4.0, semv offers a [changelog feature](alternative-usage) which does use the
scope.

*New*: Starting with version v2.2.0, you can now omit the scope. As a result, a
commit message like "fix: General overhaul" is now valid.
Starting with version v2.2.0, you can now omit the scope. As a result, a
commit message like "fix: General overhaul" is now valid and will be treated as
applying generally in the changelog (starting from v2.4.0).

## The `body` and `footer`

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Action to take if encountering an invalid commit—typically a commit for wh
Possible values are

- *warning*: Prints a warning to stderr when encountering an invalid commit.
- *error*: Exit with return code 2 if encountering an invalid commit. Note that currently (Version v1.4.5), reverts and merge commits are considered invalid and would therefore result in an error.
- *error*: Exit with return code 2 if encountering an invalid commit.
- *skip*: Silently skips invalid commits. This is a little dangerous as it may silently give an incorrect version. If you use this, it is recommended to use a [commit template](https://gist.github.com/lisawolderiksen/a7b99d94c92c6671181611be1641c733) like [this](https://github.com/igordertigor/semv/blob/master/.gitmessage) to ensure correct commit messages.

Example configuration setting the invalid commit action:
Expand Down
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ description>`, where `type` would be a commit type like "feat" or "fix" and
"scope" would be the thing that was actually changed. For example, the commit
message "feat(parsing): Parsing can now handle foo as well" would describe a
commit that adds a new feature to the parsing component of your application.
At the moment (v1.4.5), semv doesn't parse the scope.
Starting with version v2.4.0, semv will use the scope for the new [changelog
feature](alternative-usage.md).

Below the first line, users can add a body (as is good practice with commit
messages in general). The body should be separated from the title by an empty
Expand Down
83 changes: 83 additions & 0 deletions src/semv/changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from typing import List, Dict, Iterator
from collections import defaultdict

from .types import Commit


GroupedCommits = Dict[str, Dict[str, List[Commit]]]


class Changelog:
def group_commits(self, commits: Iterator[Commit]) -> GroupedCommits:

out: GroupedCommits = defaultdict(lambda: defaultdict(list))
for commit in commits:
if commit.breaking:
out['breaking'][commit.scope].append(commit)
else:
out[commit.type][commit.scope].append(commit)
return out

def format_breaking(
self, breaking_commits: Dict[str, List[Commit]]
) -> str:
lines: List[str] = []
if breaking_commits:
lines.append('# Breaking changes')

general = breaking_commits.pop(':global:', None)
if general:
for c in general:
lines.extend(self.format_breaking_commit('', c))

for scope, commits in breaking_commits.items():
for c in commits:
lines.extend(self.format_breaking_commit(scope, c))
return '\n'.join(lines)

def format_breaking_commit(self, scope: str, commit: Commit) -> List[str]:
if scope:
out = [f'- {scope}: {commit.summary}']
else:
out = [f'- {commit.summary}']
for summary in commit.breaking_summaries:
out.append(f' - {summary}')
return out

def format_release_commits(
self, types: Iterator[str], commits: GroupedCommits
) -> str:
lines: List[str] = []
for type_name in types:
type_commits = commits.pop(type_name, None)
if type_commits:
lines.append(f'# {self.translate_types(type_name)}')

if type_commits:

general = type_commits.pop(':global:', None)
if general:
lines.extend(self.format_scope('General', general))

for scope, cmts in type_commits.items():
lines.extend(self.format_scope(scope, cmts))

return '\n'.join(lines)

def format_scope(self, scope: str, commits: List[Commit]) -> List[str]:
if len(commits) == 1:
return [f'- {scope}: {commits[0].summary}']
elif len(commits) > 1:
return [f'- {scope}:'] + [f' - {c.summary}' for c in commits]
else:
return []

def translate_types(self, name: str) -> str:
translations = {
'feat': 'New features',
'feature': 'New features',
'fix': 'Fixes',
'perf': 'Performance Improvements',
'performance': 'Performance Improvements',
}
return translations.get(name, name)
33 changes: 33 additions & 0 deletions src/semv/commands.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from typing import Dict, List
from itertools import groupby
from .increment import DefaultIncrementer
from .parse import AngularCommitParser
from .version_control_system import Git
from .config import Config
from . import errors
from .types import Version, VersionIncrement, RawCommit, InvalidCommitAction
from . import hooks
from .changelog import Changelog


def list_types(config: Config) -> str:
Expand Down Expand Up @@ -71,3 +74,33 @@ def commit_msg(filename: str, config: Config):
)
if parsed_commit is not None:
version_incrementer.get_version_increment(iter([parsed_commit]))


def changelog(config: Config):
vcs = Git()
cp = AngularCommitParser(
config.invalid_commit_action,
config.skip_commit_patterns,
valid_scopes=config.valid_scopes,
)
current_version = vcs.get_current_version()
commits_or_none = (
cp.parse(c) for c in vcs.get_commits_without(current_version)
)
commits = reversed([c for c in commits_or_none if c is not None])
cngl = Changelog()
grouped_commits = cngl.group_commits(commits)
messages = []
breaking = grouped_commits.pop('breaking', None)
if breaking:
messages.append(cngl.format_breaking(breaking))

messages += [
cngl.format_release_commits(iter(types), grouped_commits)
for types in [
config.commit_types_major,
config.commit_types_minor,
config.commit_types_patch,
]
]
print('\n\n'.join(m for m in messages if m))
2 changes: 2 additions & 0 deletions src/semv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def main():
print(commands.version_string(config), end='')
elif len(sys.argv) == 3 and sys.argv[1] == '--commit-msg':
commands.commit_msg(sys.argv[2], config)
elif len(sys.argv) and sys.argv[1] == '--changelog':
commands.changelog(config)
except errors.NoNewVersion:
sys.stderr.write('WARNING: No changes for new version\n')
sys.exit(1)
Expand Down
21 changes: 15 additions & 6 deletions src/semv/parse.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional, Set, Literal, Union
from typing import Optional, Set, Literal, Union, List
import re
from .interface import RawCommit, Commit, CommitParser
from . import errors
Expand All @@ -14,10 +14,10 @@ def __init__(
valid_scopes: Union[Set[str], Literal[':anyscope:']] = ':anyscope:',
):
self.type_and_scope_pattern = re.compile(
r'(?P<type>\w+)\(?(?P<scope>[a-zA-Z-_]*)\)?: .*'
r'(?P<type>\w+)\(?(?P<scope>[a-zA-Z-_]*)\)?: (?P<summary>.*)'
)
self.breaking_pattern = re.compile(
r'BREAKING CHANGE: .*', flags=re.DOTALL
r'BREAKING CHANGE: (?P<summary>.*)', flags=re.DOTALL
)
self.invalid_commit_action = invalid_commit_action
self.valid_scopes = valid_scopes
Expand All @@ -37,15 +37,17 @@ def parse(self, commit: RawCommit) -> Optional[Commit]:
)
return None

mb = self.breaking_pattern.findall(commit.body)

return self._prepare_commit(
m,
mb,
commit.sha,
bool(self.breaking_pattern.match(commit.body)),
commit.title,
)

def _prepare_commit(
self, m: re.Match, sha: str, breaking: bool, title: str
self, m: re.Match, mb: List[str], sha: str, title: str
) -> Commit:
type = m.group('type')
scope = m.group('scope')
Expand All @@ -61,7 +63,14 @@ def _prepare_commit(
)
else:
scope = ':global:'
return Commit(sha=sha, type=type, scope=scope, breaking=breaking)
return Commit(
sha=sha,
type=type,
scope=scope,
breaking=bool(mb),
summary=m.group('summary'),
breaking_summaries=mb,
)

def should_skip_by_pattern(self, title: str) -> bool:
for pattern in self.skip_commit_patterns:
Expand Down
5 changes: 4 additions & 1 deletion src/semv/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass, replace
from typing import List
from dataclasses import dataclass, replace, field
from enum import Enum


Expand All @@ -21,6 +22,8 @@ class Commit:
type: str
scope: str
breaking: bool
summary: str = ''
breaking_summaries: List[str] = field(default_factory=list)


class VersionIncrement(str, Enum):
Expand Down
Loading

0 comments on commit e1e0a97

Please sign in to comment.