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.
@@ -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.
@@ -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).
@@ -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,5 @@
1
+ """shipnote — generate clean, grouped release notes from your git history."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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")