shipnote 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shipnote-0.1.0/.gitignore +22 -0
- shipnote-0.1.0/CHANGELOG.md +12 -0
- shipnote-0.1.0/LICENSE +21 -0
- shipnote-0.1.0/PKG-INFO +129 -0
- shipnote-0.1.0/README.md +104 -0
- shipnote-0.1.0/pyproject.toml +50 -0
- shipnote-0.1.0/src/shipnote/__init__.py +5 -0
- shipnote-0.1.0/src/shipnote/__main__.py +4 -0
- shipnote-0.1.0/src/shipnote/cli.py +96 -0
- shipnote-0.1.0/src/shipnote/core.py +256 -0
- shipnote-0.1.0/tests/test_core.py +93 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
|
|
12
|
+
# Tooling
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# OS
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
desktop.ini
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.1.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- group commits into Features, Bug Fixes, Performance, Breaking Changes and more
|
|
7
|
+
- understand Conventional Commits, including scopes and breaking-change markers
|
|
8
|
+
- turn commit hashes and `(#123)` PR references into links with `--repo`
|
|
9
|
+
- read an explicit range with `--from` / `--to`, defaulting to the latest tag
|
|
10
|
+
- write notes to a file with `--output`
|
|
11
|
+
|
|
12
|
+
Initial release.
|
shipnote-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seven Of Nine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shipnote-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipnote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate clean, grouped release notes from your git history. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Sev7nOfNine/shipnote
|
|
6
|
+
Project-URL: Repository, https://github.com/Sev7nOfNine/shipnote
|
|
7
|
+
Project-URL: Issues, https://github.com/Sev7nOfNine/shipnote/issues
|
|
8
|
+
Author: Seven Of Nine
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,changelog,cli,conventional-commits,git,maintainer,release,release-notes
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# shipnote
|
|
27
|
+
|
|
28
|
+
**Generate clean, grouped release notes straight from your git history — with zero dependencies.**
|
|
29
|
+
|
|
30
|
+
`shipnote` reads the commits between two refs, understands
|
|
31
|
+
[Conventional Commits](https://www.conventionalcommits.org/), and renders tidy
|
|
32
|
+
markdown release notes grouped by Features, Bug Fixes, Performance, Breaking
|
|
33
|
+
Changes, and more. No config file, no extra packages, no network calls.
|
|
34
|
+
|
|
35
|
+
It is built for the boring-but-constant chore every maintainer knows: writing
|
|
36
|
+
the changelog at release time.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
Most changelog tools pull in a tree of dependencies, want a config file, or lock
|
|
41
|
+
you into one commit convention. `shipnote` is a single small package that uses
|
|
42
|
+
nothing but the Python standard library and the `git` you already have. Drop it
|
|
43
|
+
into any project or CI job and get readable notes in one command.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install shipnote
|
|
49
|
+
# or, for an isolated CLI:
|
|
50
|
+
pipx install shipnote
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Requires Python 3.8+ and `git` on your PATH.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
From inside a git repository:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Notes from the latest tag up to HEAD
|
|
61
|
+
shipnote
|
|
62
|
+
|
|
63
|
+
# A titled section for a specific version, with commit/PR links
|
|
64
|
+
shipnote --title v1.2.0 --repo me/myproject
|
|
65
|
+
|
|
66
|
+
# An explicit range, written to a file
|
|
67
|
+
shipnote --from v1.1.0 --to v1.2.0 --output RELEASE_NOTES.md
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If the repo has no tags yet, `shipnote` walks the whole history.
|
|
71
|
+
|
|
72
|
+
### Options
|
|
73
|
+
|
|
74
|
+
| Flag | Description |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `--from REF` | Start ref, exclusive. Defaults to the latest tag. |
|
|
77
|
+
| `--to REF` | End ref, inclusive. Defaults to `HEAD`. |
|
|
78
|
+
| `-t`, `--title TEXT` | Heading for the notes, e.g. the version. |
|
|
79
|
+
| `--repo OWNER/NAME` | Turn commit hashes and `(#123)` PR refs into links. |
|
|
80
|
+
| `--base-url URL` | Base URL for links (default `https://github.com`). |
|
|
81
|
+
| `-o`, `--output FILE` | Write to a file instead of stdout. |
|
|
82
|
+
| `-C DIR` | Run as if started in `DIR`. |
|
|
83
|
+
|
|
84
|
+
## Example output
|
|
85
|
+
|
|
86
|
+
```markdown
|
|
87
|
+
## v1.2.0
|
|
88
|
+
|
|
89
|
+
### Breaking Changes
|
|
90
|
+
- drop Python 3.7 support (`a1b2c3d`)
|
|
91
|
+
|
|
92
|
+
### Features
|
|
93
|
+
- **api:** add streaming endpoint ([#42](https://github.com/me/proj/pull/42), [`9f8e7d6`](https://github.com/me/proj/commit/9f8e7d6))
|
|
94
|
+
|
|
95
|
+
### Bug Fixes
|
|
96
|
+
- handle an empty commit range gracefully (`c4d5e6f`)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## In CI
|
|
100
|
+
|
|
101
|
+
Use it in a release workflow to draft notes from the just-tagged range:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
- name: Draft release notes
|
|
105
|
+
run: |
|
|
106
|
+
pip install shipnote
|
|
107
|
+
shipnote --title "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" -o notes.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## How it groups commits
|
|
111
|
+
|
|
112
|
+
Conventional types map to sections: `feat` → Features, `fix` → Bug Fixes,
|
|
113
|
+
`perf` → Performance, `docs` → Documentation, `refactor` → Refactoring,
|
|
114
|
+
`test` → Tests, `build` → Build System, `ci` → CI, `style` → Styles,
|
|
115
|
+
`chore` → Chores, `revert` → Reverts. Anything that does not match falls under
|
|
116
|
+
**Other**, so no commit is ever silently dropped. Commits marked breaking (a `!`
|
|
117
|
+
after the type, or a `BREAKING CHANGE` footer) are surfaced under **Breaking
|
|
118
|
+
Changes**.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e .
|
|
124
|
+
python -m pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT — see [LICENSE](LICENSE).
|
shipnote-0.1.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# shipnote
|
|
2
|
+
|
|
3
|
+
**Generate clean, grouped release notes straight from your git history — with zero dependencies.**
|
|
4
|
+
|
|
5
|
+
`shipnote` reads the commits between two refs, understands
|
|
6
|
+
[Conventional Commits](https://www.conventionalcommits.org/), and renders tidy
|
|
7
|
+
markdown release notes grouped by Features, Bug Fixes, Performance, Breaking
|
|
8
|
+
Changes, and more. No config file, no extra packages, no network calls.
|
|
9
|
+
|
|
10
|
+
It is built for the boring-but-constant chore every maintainer knows: writing
|
|
11
|
+
the changelog at release time.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Most changelog tools pull in a tree of dependencies, want a config file, or lock
|
|
16
|
+
you into one commit convention. `shipnote` is a single small package that uses
|
|
17
|
+
nothing but the Python standard library and the `git` you already have. Drop it
|
|
18
|
+
into any project or CI job and get readable notes in one command.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install shipnote
|
|
24
|
+
# or, for an isolated CLI:
|
|
25
|
+
pipx install shipnote
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.8+ and `git` on your PATH.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
From inside a git repository:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Notes from the latest tag up to HEAD
|
|
36
|
+
shipnote
|
|
37
|
+
|
|
38
|
+
# A titled section for a specific version, with commit/PR links
|
|
39
|
+
shipnote --title v1.2.0 --repo me/myproject
|
|
40
|
+
|
|
41
|
+
# An explicit range, written to a file
|
|
42
|
+
shipnote --from v1.1.0 --to v1.2.0 --output RELEASE_NOTES.md
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If the repo has no tags yet, `shipnote` walks the whole history.
|
|
46
|
+
|
|
47
|
+
### Options
|
|
48
|
+
|
|
49
|
+
| Flag | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `--from REF` | Start ref, exclusive. Defaults to the latest tag. |
|
|
52
|
+
| `--to REF` | End ref, inclusive. Defaults to `HEAD`. |
|
|
53
|
+
| `-t`, `--title TEXT` | Heading for the notes, e.g. the version. |
|
|
54
|
+
| `--repo OWNER/NAME` | Turn commit hashes and `(#123)` PR refs into links. |
|
|
55
|
+
| `--base-url URL` | Base URL for links (default `https://github.com`). |
|
|
56
|
+
| `-o`, `--output FILE` | Write to a file instead of stdout. |
|
|
57
|
+
| `-C DIR` | Run as if started in `DIR`. |
|
|
58
|
+
|
|
59
|
+
## Example output
|
|
60
|
+
|
|
61
|
+
```markdown
|
|
62
|
+
## v1.2.0
|
|
63
|
+
|
|
64
|
+
### Breaking Changes
|
|
65
|
+
- drop Python 3.7 support (`a1b2c3d`)
|
|
66
|
+
|
|
67
|
+
### Features
|
|
68
|
+
- **api:** add streaming endpoint ([#42](https://github.com/me/proj/pull/42), [`9f8e7d6`](https://github.com/me/proj/commit/9f8e7d6))
|
|
69
|
+
|
|
70
|
+
### Bug Fixes
|
|
71
|
+
- handle an empty commit range gracefully (`c4d5e6f`)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## In CI
|
|
75
|
+
|
|
76
|
+
Use it in a release workflow to draft notes from the just-tagged range:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
- name: Draft release notes
|
|
80
|
+
run: |
|
|
81
|
+
pip install shipnote
|
|
82
|
+
shipnote --title "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" -o notes.md
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## How it groups commits
|
|
86
|
+
|
|
87
|
+
Conventional types map to sections: `feat` → Features, `fix` → Bug Fixes,
|
|
88
|
+
`perf` → Performance, `docs` → Documentation, `refactor` → Refactoring,
|
|
89
|
+
`test` → Tests, `build` → Build System, `ci` → CI, `style` → Styles,
|
|
90
|
+
`chore` → Chores, `revert` → Reverts. Anything that does not match falls under
|
|
91
|
+
**Other**, so no commit is ever silently dropped. Commits marked breaking (a `!`
|
|
92
|
+
after the type, or a `BREAKING CHANGE` footer) are surfaced under **Breaking
|
|
93
|
+
Changes**.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e .
|
|
99
|
+
python -m pytest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shipnote"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate clean, grouped release notes from your git history. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Seven Of Nine" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"changelog",
|
|
15
|
+
"release-notes",
|
|
16
|
+
"git",
|
|
17
|
+
"conventional-commits",
|
|
18
|
+
"release",
|
|
19
|
+
"maintainer",
|
|
20
|
+
"cli",
|
|
21
|
+
"automation",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Environment :: Console",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
32
|
+
"Topic :: Utilities",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/Sev7nOfNine/shipnote"
|
|
37
|
+
Repository = "https://github.com/Sev7nOfNine/shipnote"
|
|
38
|
+
Issues = "https://github.com/Sev7nOfNine/shipnote/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
test = ["pytest>=7"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
shipnote = "shipnote.cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/shipnote"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = ["src/shipnote", "README.md", "LICENSE", "CHANGELOG.md", "tests"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command-line interface for shipnote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .core import GitError, get_commits, render_markdown, resolve_range
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="shipnote",
|
|
16
|
+
description="Generate clean, grouped release notes from your git history.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--from",
|
|
20
|
+
dest="frm",
|
|
21
|
+
metavar="REF",
|
|
22
|
+
help="start ref (exclusive). Defaults to the latest tag, or the whole "
|
|
23
|
+
"history if there are no tags.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--to",
|
|
27
|
+
dest="to",
|
|
28
|
+
metavar="REF",
|
|
29
|
+
default="HEAD",
|
|
30
|
+
help="end ref (inclusive). Defaults to HEAD.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-t",
|
|
34
|
+
"--title",
|
|
35
|
+
metavar="TEXT",
|
|
36
|
+
help="heading for the notes, e.g. the version being released (v1.2.0).",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--repo",
|
|
40
|
+
metavar="OWNER/NAME",
|
|
41
|
+
help="GitHub repo slug used to turn commits and PRs into links.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--base-url",
|
|
45
|
+
metavar="URL",
|
|
46
|
+
default="https://github.com",
|
|
47
|
+
help="base URL for links (default: https://github.com).",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"-o",
|
|
51
|
+
"--output",
|
|
52
|
+
metavar="FILE",
|
|
53
|
+
help="write the notes to FILE instead of stdout.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-C",
|
|
57
|
+
dest="cwd",
|
|
58
|
+
metavar="DIR",
|
|
59
|
+
help="run as if shipnote were started in DIR.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--version",
|
|
63
|
+
action="version",
|
|
64
|
+
version="%(prog)s {0}".format(__version__),
|
|
65
|
+
)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
rev_range = resolve_range(args.frm, args.to, cwd=args.cwd)
|
|
75
|
+
commits = get_commits(rev_range, cwd=args.cwd)
|
|
76
|
+
except GitError as exc:
|
|
77
|
+
parser.exit(2, "shipnote: {0}\n".format(exc))
|
|
78
|
+
|
|
79
|
+
notes = render_markdown(
|
|
80
|
+
commits,
|
|
81
|
+
title=args.title,
|
|
82
|
+
repo=args.repo,
|
|
83
|
+
base_url=args.base_url,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if args.output:
|
|
87
|
+
with open(args.output, "w", encoding="utf-8") as handle:
|
|
88
|
+
handle.write(notes)
|
|
89
|
+
else:
|
|
90
|
+
sys.stdout.write(notes)
|
|
91
|
+
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Core logic for shipnote: parse git commits and render release notes.
|
|
2
|
+
|
|
3
|
+
The functions here are deliberately split into two layers:
|
|
4
|
+
|
|
5
|
+
* Pure functions (``parse_commit``, ``categorize``, ``render_markdown``) that
|
|
6
|
+
operate on plain data and are trivially unit-testable without a git repo.
|
|
7
|
+
* Thin git wrappers (``run_git``, ``latest_tag``, ``get_commits``,
|
|
8
|
+
``resolve_range``) that shell out to ``git``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Dict, List, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
# A conventional-commit subject, e.g. "feat(api)!: add streaming endpoint".
|
|
19
|
+
_CONVENTIONAL_RE = re.compile(
|
|
20
|
+
r"^(?P<type>[a-z]+)"
|
|
21
|
+
r"(?:\((?P<scope>[^)]+)\))?"
|
|
22
|
+
r"(?P<breaking>!)?"
|
|
23
|
+
r":\s*(?P<subject>.+)$",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# A trailing PR reference GitHub appends on squash-merge, e.g. "(#42)".
|
|
28
|
+
_PR_RE = re.compile(r"\(#(\d+)\)\s*$")
|
|
29
|
+
|
|
30
|
+
# Map a conventional type to a human section title.
|
|
31
|
+
TYPE_SECTIONS: Dict[str, str] = {
|
|
32
|
+
"feat": "Features",
|
|
33
|
+
"fix": "Bug Fixes",
|
|
34
|
+
"perf": "Performance",
|
|
35
|
+
"refactor": "Refactoring",
|
|
36
|
+
"docs": "Documentation",
|
|
37
|
+
"test": "Tests",
|
|
38
|
+
"build": "Build System",
|
|
39
|
+
"ci": "CI",
|
|
40
|
+
"style": "Styles",
|
|
41
|
+
"chore": "Chores",
|
|
42
|
+
"revert": "Reverts",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# The order sections appear in the rendered notes.
|
|
46
|
+
SECTION_ORDER: List[str] = [
|
|
47
|
+
"Breaking Changes",
|
|
48
|
+
"Features",
|
|
49
|
+
"Bug Fixes",
|
|
50
|
+
"Performance",
|
|
51
|
+
"Refactoring",
|
|
52
|
+
"Documentation",
|
|
53
|
+
"Tests",
|
|
54
|
+
"Build System",
|
|
55
|
+
"CI",
|
|
56
|
+
"Styles",
|
|
57
|
+
"Chores",
|
|
58
|
+
"Reverts",
|
|
59
|
+
"Other",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GitError(RuntimeError):
|
|
64
|
+
"""Raised when an underlying git command fails."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Commit:
|
|
69
|
+
"""A single parsed commit."""
|
|
70
|
+
|
|
71
|
+
sha: str
|
|
72
|
+
subject: str
|
|
73
|
+
body: str = ""
|
|
74
|
+
type: Optional[str] = None
|
|
75
|
+
scope: Optional[str] = None
|
|
76
|
+
breaking: bool = False
|
|
77
|
+
pr: Optional[int] = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def short_sha(self) -> str:
|
|
81
|
+
return self.sha[:7]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_commit(sha: str, subject: str, body: str = "") -> Commit:
|
|
85
|
+
"""Parse a raw commit into a :class:`Commit`, recognising conventional commits."""
|
|
86
|
+
subject = subject.strip()
|
|
87
|
+
body = body.strip()
|
|
88
|
+
|
|
89
|
+
pr: Optional[int] = None
|
|
90
|
+
pr_match = _PR_RE.search(subject)
|
|
91
|
+
if pr_match:
|
|
92
|
+
pr = int(pr_match.group(1))
|
|
93
|
+
# Drop the trailing "(#42)" so it is not repeated next to the link.
|
|
94
|
+
subject = _PR_RE.sub("", subject).strip()
|
|
95
|
+
|
|
96
|
+
breaking = "BREAKING CHANGE" in body or "BREAKING-CHANGE" in body
|
|
97
|
+
|
|
98
|
+
match = _CONVENTIONAL_RE.match(subject)
|
|
99
|
+
if not match:
|
|
100
|
+
return Commit(sha=sha, subject=subject, body=body, breaking=breaking, pr=pr)
|
|
101
|
+
|
|
102
|
+
ctype = match.group("type").lower()
|
|
103
|
+
if match.group("breaking"):
|
|
104
|
+
breaking = True
|
|
105
|
+
|
|
106
|
+
return Commit(
|
|
107
|
+
sha=sha,
|
|
108
|
+
subject=match.group("subject").strip(),
|
|
109
|
+
body=body,
|
|
110
|
+
type=ctype,
|
|
111
|
+
scope=match.group("scope"),
|
|
112
|
+
breaking=breaking,
|
|
113
|
+
pr=pr,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def categorize(commits: Sequence[Commit]) -> "Dict[str, List[Commit]]":
|
|
118
|
+
"""Group commits into ordered sections.
|
|
119
|
+
|
|
120
|
+
Breaking changes are surfaced in their own section and are not duplicated
|
|
121
|
+
under their conventional type.
|
|
122
|
+
"""
|
|
123
|
+
sections: Dict[str, List[Commit]] = {name: [] for name in SECTION_ORDER}
|
|
124
|
+
for commit in commits:
|
|
125
|
+
if commit.breaking:
|
|
126
|
+
sections["Breaking Changes"].append(commit)
|
|
127
|
+
continue
|
|
128
|
+
section = TYPE_SECTIONS.get(commit.type or "", "Other")
|
|
129
|
+
sections[section].append(commit)
|
|
130
|
+
return {name: items for name, items in sections.items() if items}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _render_commit(commit: Commit, repo: Optional[str], base_url: str) -> str:
|
|
134
|
+
bullet = commit.subject
|
|
135
|
+
if commit.scope:
|
|
136
|
+
bullet = "**{0}:** {1}".format(commit.scope, bullet)
|
|
137
|
+
|
|
138
|
+
refs: List[str] = []
|
|
139
|
+
if commit.pr is not None:
|
|
140
|
+
if repo:
|
|
141
|
+
refs.append("[#{0}]({1}/{2}/pull/{0})".format(commit.pr, base_url, repo))
|
|
142
|
+
else:
|
|
143
|
+
refs.append("#{0}".format(commit.pr))
|
|
144
|
+
if repo:
|
|
145
|
+
refs.append(
|
|
146
|
+
"[`{0}`]({1}/{2}/commit/{3})".format(
|
|
147
|
+
commit.short_sha, base_url, repo, commit.sha
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
refs.append("`{0}`".format(commit.short_sha))
|
|
152
|
+
|
|
153
|
+
return "- {0} ({1})".format(bullet, ", ".join(refs))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_markdown(
|
|
157
|
+
commits: Sequence[Commit],
|
|
158
|
+
title: Optional[str] = None,
|
|
159
|
+
repo: Optional[str] = None,
|
|
160
|
+
base_url: str = "https://github.com",
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Render grouped commits to a markdown release-notes string."""
|
|
163
|
+
base_url = base_url.rstrip("/")
|
|
164
|
+
lines: List[str] = []
|
|
165
|
+
if title:
|
|
166
|
+
lines.append("## {0}".format(title))
|
|
167
|
+
lines.append("")
|
|
168
|
+
|
|
169
|
+
sections = categorize(commits)
|
|
170
|
+
if not sections:
|
|
171
|
+
lines.append("_No notable changes._")
|
|
172
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
173
|
+
|
|
174
|
+
for name in SECTION_ORDER:
|
|
175
|
+
items = sections.get(name)
|
|
176
|
+
if not items:
|
|
177
|
+
continue
|
|
178
|
+
lines.append("### {0}".format(name))
|
|
179
|
+
for commit in items:
|
|
180
|
+
lines.append(_render_commit(commit, repo, base_url))
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- git wrappers -------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
# Field/record separators unlikely to appear in commit text.
|
|
189
|
+
_FIELD_SEP = "\x1f"
|
|
190
|
+
_RECORD_SEP = "\x1e"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_git(args: Sequence[str], cwd: Optional[str] = None) -> str:
|
|
194
|
+
"""Run a git command and return stdout, raising :class:`GitError` on failure."""
|
|
195
|
+
try:
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
["git", *args],
|
|
198
|
+
cwd=cwd,
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
)
|
|
203
|
+
except FileNotFoundError as exc: # git not installed
|
|
204
|
+
raise GitError("git executable not found on PATH") from exc
|
|
205
|
+
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
raise GitError(result.stderr.strip() or "git {0} failed".format(" ".join(args)))
|
|
208
|
+
return result.stdout
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def latest_tag(cwd: Optional[str] = None) -> Optional[str]:
|
|
212
|
+
"""Return the most recent tag reachable from HEAD, or ``None`` if there is none."""
|
|
213
|
+
try:
|
|
214
|
+
out = run_git(["describe", "--tags", "--abbrev=0"], cwd=cwd)
|
|
215
|
+
except GitError:
|
|
216
|
+
return None
|
|
217
|
+
tag = out.strip()
|
|
218
|
+
return tag or None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_range(
|
|
222
|
+
frm: Optional[str], to: str, cwd: Optional[str] = None
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Build the git revision range to read commits from.
|
|
225
|
+
|
|
226
|
+
With no explicit ``frm``, fall back to the latest tag, then to the full
|
|
227
|
+
history if the repo has no tags yet.
|
|
228
|
+
"""
|
|
229
|
+
if frm:
|
|
230
|
+
return "{0}..{1}".format(frm, to)
|
|
231
|
+
tag = latest_tag(cwd=cwd)
|
|
232
|
+
if tag:
|
|
233
|
+
return "{0}..{1}".format(tag, to)
|
|
234
|
+
return to
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_commits(rev_range: str, cwd: Optional[str] = None) -> List[Commit]:
|
|
238
|
+
"""Read and parse commits in ``rev_range`` (newest first), skipping merges."""
|
|
239
|
+
fmt = _FIELD_SEP.join(["%H", "%s", "%b"]) + _RECORD_SEP
|
|
240
|
+
out = run_git(
|
|
241
|
+
["log", "--no-merges", "--format={0}".format(fmt), rev_range],
|
|
242
|
+
cwd=cwd,
|
|
243
|
+
)
|
|
244
|
+
commits: List[Commit] = []
|
|
245
|
+
for record in out.split(_RECORD_SEP):
|
|
246
|
+
record = record.strip("\n")
|
|
247
|
+
if not record:
|
|
248
|
+
continue
|
|
249
|
+
parts = record.split(_FIELD_SEP)
|
|
250
|
+
sha = parts[0] if len(parts) > 0 else ""
|
|
251
|
+
subject = parts[1] if len(parts) > 1 else ""
|
|
252
|
+
body = parts[2] if len(parts) > 2 else ""
|
|
253
|
+
if not sha:
|
|
254
|
+
continue
|
|
255
|
+
commits.append(parse_commit(sha, subject, body))
|
|
256
|
+
return commits
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Unit tests for shipnote's pure logic (no git repo required)."""
|
|
2
|
+
|
|
3
|
+
from shipnote.core import (
|
|
4
|
+
Commit,
|
|
5
|
+
categorize,
|
|
6
|
+
parse_commit,
|
|
7
|
+
render_markdown,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_parse_plain_commit():
|
|
12
|
+
commit = parse_commit("a" * 40, "just a plain message")
|
|
13
|
+
assert commit.type is None
|
|
14
|
+
assert commit.subject == "just a plain message"
|
|
15
|
+
assert commit.breaking is False
|
|
16
|
+
assert commit.short_sha == "aaaaaaa"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_parse_conventional_with_scope():
|
|
20
|
+
commit = parse_commit("b" * 40, "feat(api): add streaming endpoint")
|
|
21
|
+
assert commit.type == "feat"
|
|
22
|
+
assert commit.scope == "api"
|
|
23
|
+
assert commit.subject == "add streaming endpoint"
|
|
24
|
+
assert commit.breaking is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_parse_breaking_bang():
|
|
28
|
+
commit = parse_commit("c" * 40, "feat!: drop python 3.7")
|
|
29
|
+
assert commit.type == "feat"
|
|
30
|
+
assert commit.breaking is True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_parse_breaking_footer():
|
|
34
|
+
commit = parse_commit(
|
|
35
|
+
"d" * 40,
|
|
36
|
+
"refactor: rework config",
|
|
37
|
+
body="BREAKING CHANGE: config file moved",
|
|
38
|
+
)
|
|
39
|
+
assert commit.breaking is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_parse_pr_reference():
|
|
43
|
+
commit = parse_commit("e" * 40, "fix: handle empty range (#42)")
|
|
44
|
+
assert commit.pr == 42
|
|
45
|
+
assert commit.subject == "handle empty range"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_categorize_groups_and_drops_empty():
|
|
49
|
+
commits = [
|
|
50
|
+
parse_commit("1" * 40, "feat: a"),
|
|
51
|
+
parse_commit("2" * 40, "fix: b"),
|
|
52
|
+
parse_commit("3" * 40, "chore: c"),
|
|
53
|
+
]
|
|
54
|
+
grouped = categorize(commits)
|
|
55
|
+
assert set(grouped) == {"Features", "Bug Fixes", "Chores"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_categorize_breaking_not_duplicated():
|
|
59
|
+
commits = [parse_commit("4" * 40, "feat!: big change")]
|
|
60
|
+
grouped = categorize(commits)
|
|
61
|
+
assert "Breaking Changes" in grouped
|
|
62
|
+
assert "Features" not in grouped
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_render_unknown_type_goes_to_other():
|
|
66
|
+
commits = [Commit(sha="f" * 40, subject="something", type="wip")]
|
|
67
|
+
out = render_markdown(commits)
|
|
68
|
+
assert "### Other" in out
|
|
69
|
+
assert "- something (`fffffff`)" in out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_render_with_repo_links():
|
|
73
|
+
commits = [parse_commit("a1b2c3d4e5" + "0" * 30, "fix: thing (#7)")]
|
|
74
|
+
out = render_markdown(commits, title="v1.0.0", repo="me/proj")
|
|
75
|
+
assert out.startswith("## v1.0.0")
|
|
76
|
+
assert "[#7](https://github.com/me/proj/pull/7)" in out
|
|
77
|
+
assert "https://github.com/me/proj/commit/" in out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_render_empty():
|
|
81
|
+
out = render_markdown([])
|
|
82
|
+
assert "No notable changes" in out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_render_section_order():
|
|
86
|
+
commits = [
|
|
87
|
+
parse_commit("1" * 40, "docs: d"),
|
|
88
|
+
parse_commit("2" * 40, "feat: f"),
|
|
89
|
+
parse_commit("3" * 40, "fix: x"),
|
|
90
|
+
]
|
|
91
|
+
out = render_markdown(commits)
|
|
92
|
+
assert out.index("### Features") < out.index("### Bug Fixes")
|
|
93
|
+
assert out.index("### Bug Fixes") < out.index("### Documentation")
|