patchnote 0.1.1__py3-none-any.whl

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.
patchnote/gitlog.py ADDED
@@ -0,0 +1,201 @@
1
+ """Read git history by calling the ``git`` binary (no GitPython)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import subprocess
8
+ from collections.abc import Sequence
9
+ from datetime import date, datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from patchnote.model import Commit, GitError, UsageError
13
+
14
+ FIELD_SEP = "\x1f"
15
+ RECORD_SEP = "\x1e"
16
+
17
+ # hash, short, author name, email, author date, parents, subject, body
18
+ PRETTY = (
19
+ f"%H{FIELD_SEP}%h{FIELD_SEP}%an{FIELD_SEP}%ae{FIELD_SEP}"
20
+ f"%aI{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%b{RECORD_SEP}"
21
+ )
22
+
23
+
24
+ class GitRunner:
25
+ """Thin wrapper around ``git`` subprocess calls, injectable in tests."""
26
+
27
+ def __init__(self, repo: Path, env: dict[str, str] | None = None) -> None:
28
+ self.repo = repo
29
+ extra = env or {}
30
+ self.env = {**os.environ, "LC_ALL": "C", "GIT_TERMINAL_PROMPT": "0", **extra}
31
+
32
+ def run(self, args: Sequence[str], *, check: bool = True, timeout: int = 60) -> str:
33
+ try:
34
+ result = subprocess.run(
35
+ ["git", *args],
36
+ cwd=self.repo,
37
+ capture_output=True,
38
+ text=True,
39
+ encoding="utf-8",
40
+ errors="replace",
41
+ timeout=timeout,
42
+ check=False,
43
+ env=self.env,
44
+ )
45
+ except FileNotFoundError as exc:
46
+ raise GitError("git executable not found on PATH") from exc
47
+ except subprocess.TimeoutExpired as exc:
48
+ raise GitError(f"git {' '.join(args)} timed out after {timeout}s") from exc
49
+ if check and result.returncode != 0:
50
+ err = (result.stderr or result.stdout or "").strip()
51
+ raise GitError(f"git {' '.join(args)} failed: {err or result.returncode}")
52
+ return result.stdout
53
+
54
+ def run_ok(self, args: Sequence[str]) -> bool:
55
+ try:
56
+ result = subprocess.run(
57
+ ["git", *args],
58
+ cwd=self.repo,
59
+ capture_output=True,
60
+ text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
63
+ timeout=30,
64
+ check=False,
65
+ env=self.env,
66
+ )
67
+ except (FileNotFoundError, subprocess.TimeoutExpired):
68
+ return False
69
+ return result.returncode == 0
70
+
71
+
72
+ def discover_repo(path: Path) -> Path:
73
+ runner = GitRunner(path)
74
+ try:
75
+ root = runner.run(["rev-parse", "--show-toplevel"]).strip()
76
+ except GitError as exc:
77
+ raise GitError(f"{path} is not a git repository") from exc
78
+ return Path(root)
79
+
80
+
81
+ def _parse_datetime(value: str) -> datetime:
82
+ text = value.strip()
83
+ if text.endswith("Z"):
84
+ text = text[:-1] + "+00:00"
85
+ try:
86
+ parsed = datetime.fromisoformat(text)
87
+ except ValueError:
88
+ return datetime.now(timezone.utc)
89
+ if parsed.tzinfo is None:
90
+ return parsed.replace(tzinfo=timezone.utc)
91
+ return parsed
92
+
93
+
94
+ def parse_log_output(raw: str) -> list[Commit]:
95
+ commits: list[Commit] = []
96
+ if not raw.strip():
97
+ return commits
98
+ records = raw.split(RECORD_SEP)
99
+ for record in records:
100
+ record = record.strip("\n")
101
+ if not record.strip():
102
+ continue
103
+ parts = record.split(FIELD_SEP)
104
+ if len(parts) < 8:
105
+ # Body may have been empty and split short; pad.
106
+ parts = parts + [""] * (8 - len(parts))
107
+ full, short, name, email, when, parents, subject = parts[:7]
108
+ body = FIELD_SEP.join(parts[7:]) if len(parts) > 8 else parts[7]
109
+ body = body.strip("\n")
110
+ parent_list = [p for p in parents.split() if p]
111
+ commits.append(
112
+ Commit(
113
+ hash=full.strip(),
114
+ short_hash=short.strip(),
115
+ author_name=name.strip(),
116
+ author_email=email.strip(),
117
+ author_date=_parse_datetime(when),
118
+ parents=parent_list,
119
+ subject=subject.strip("\n"),
120
+ body=body,
121
+ )
122
+ )
123
+ return commits
124
+
125
+
126
+ def list_commits(runner: GitRunner, from_ref: str | None, to_ref: str) -> list[Commit]:
127
+ """Return commits in ``from_ref..to_ref`` (exclusive of from, inclusive of to).
128
+
129
+ When ``from_ref`` is None, the full history reachable from ``to_ref`` is used.
130
+ """
131
+ for ref in (from_ref, to_ref):
132
+ if ref and ref.startswith("-"):
133
+ raise UsageError("Git references must not start with a dash")
134
+ rev_range = f"{from_ref}..{to_ref}" if from_ref else to_ref
135
+ raw = runner.run(["log", f"--pretty=format:{PRETTY}", "--date=iso-strict", rev_range, "--"])
136
+ return parse_log_output(raw)
137
+
138
+
139
+ def list_tags(runner: GitRunner, pattern: str = "v*") -> list[str]:
140
+ raw = runner.run(["tag", "--list", pattern, "--sort=-v:refname"], check=True)
141
+ tags = [line.strip() for line in raw.splitlines() if line.strip()]
142
+ if tags:
143
+ return tags
144
+ # Fall back to every tag, then filter with fnmatch (in case git glob differs).
145
+ raw_all = runner.run(["tag", "--list", "--sort=-v:refname"])
146
+ all_tags = [line.strip() for line in raw_all.splitlines() if line.strip()]
147
+ return [tag for tag in all_tags if fnmatch.fnmatch(tag, pattern)]
148
+
149
+
150
+ def latest_tag(runner: GitRunner, pattern: str = "v*") -> str | None:
151
+ tags = list_tags(runner, pattern)
152
+ return tags[0] if tags else None
153
+
154
+
155
+ def previous_tag(runner: GitRunner, current: str, pattern: str = "v*") -> str | None:
156
+ tags = list_tags(runner, pattern)
157
+ if current in tags:
158
+ idx = tags.index(current)
159
+ if idx + 1 < len(tags):
160
+ return tags[idx + 1]
161
+ return None
162
+ return tags[0] if tags else None
163
+
164
+
165
+ def resolve_ref(runner: GitRunner, ref: str) -> str:
166
+ return runner.run(["rev-parse", ref]).strip()
167
+
168
+
169
+ def ref_exists(runner: GitRunner, ref: str) -> bool:
170
+ if ref.startswith("-"):
171
+ return False
172
+ return runner.run_ok(
173
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"]
174
+ )
175
+
176
+
177
+ def tag_date(runner: GitRunner, tag: str) -> date | None:
178
+ raw = runner.run(["log", "-1", "--format=%aI", tag], check=False).strip()
179
+ if not raw:
180
+ return None
181
+ return _parse_datetime(raw).date()
182
+
183
+
184
+ def remote_url(runner: GitRunner, name: str = "origin") -> str | None:
185
+ raw = runner.run(["remote", "get-url", name], check=False).strip()
186
+ return raw or None
187
+
188
+
189
+ def current_branch(runner: GitRunner) -> str | None:
190
+ raw = runner.run(["rev-parse", "--abbrev-ref", "HEAD"], check=False).strip()
191
+ if not raw or raw == "HEAD":
192
+ return None
193
+ return raw
194
+
195
+
196
+ def create_tag(runner: GitRunner, tag: str, message: str) -> None:
197
+ runner.run(["tag", "-a", tag, "-m", message])
198
+
199
+
200
+ def push_tag(runner: GitRunner, tag: str, remote: str = "origin") -> None:
201
+ runner.run(["push", remote, tag])
patchnote/model.py ADDED
@@ -0,0 +1,228 @@
1
+ """Domain models and error types for Patchnote."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class PatchnoteError(Exception):
13
+ """Base error. ``exit_code`` is used by the CLI."""
14
+
15
+ exit_code = 3
16
+
17
+ def __init__(self, message: str) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+
21
+
22
+ class UsageError(PatchnoteError):
23
+ """Bad arguments or configuration the user can fix."""
24
+
25
+ exit_code = 2
26
+
27
+
28
+ class ConfigError(UsageError):
29
+ """Invalid configuration file."""
30
+
31
+ exit_code = 2
32
+
33
+
34
+ class CheckViolationError(PatchnoteError):
35
+ """``patchnote check`` found convention violations."""
36
+
37
+ exit_code = 1
38
+
39
+ def __init__(self, message: str, violations: list[str]) -> None:
40
+ super().__init__(message)
41
+ self.violations = violations
42
+
43
+
44
+ class GitError(PatchnoteError):
45
+ """The git binary failed or the repository is unusable."""
46
+
47
+ exit_code = 3
48
+
49
+
50
+ class RuntimeNetworkError(PatchnoteError):
51
+ """A required network call failed (release publish, etc.)."""
52
+
53
+ exit_code = 3
54
+
55
+
56
+ class Confidence(str, Enum):
57
+ HIGH = "high"
58
+ MEDIUM = "medium"
59
+ LOW = "low"
60
+
61
+
62
+ class EntrySource(str, Enum):
63
+ CONVENTIONAL = "conventional"
64
+ LABEL = "label"
65
+ HEURISTIC = "heuristic"
66
+ OTHER = "other"
67
+ BOT = "bot"
68
+ REVERT = "revert"
69
+
70
+
71
+ class Style(str, Enum):
72
+ KEEPACHANGELOG = "keepachangelog"
73
+ CONVENTIONAL = "conventional"
74
+
75
+
76
+ class OutputFormat(str, Enum):
77
+ MARKDOWN = "markdown"
78
+ JSON = "json"
79
+ GITHUB_RELEASE = "github-release"
80
+
81
+
82
+ class AiMode(str, Enum):
83
+ NONE = "none"
84
+ POLISH = "polish"
85
+ SUMMARY = "summary"
86
+ BOTH = "both"
87
+
88
+
89
+ class Commit(BaseModel):
90
+ """A single git commit as returned by the git binary."""
91
+
92
+ hash: str
93
+ short_hash: str
94
+ author_name: str
95
+ author_email: str
96
+ author_date: datetime
97
+ parents: list[str] = Field(default_factory=list)
98
+ subject: str
99
+ body: str = ""
100
+
101
+ @property
102
+ def is_merge(self) -> bool:
103
+ return len(self.parents) > 1
104
+
105
+ @property
106
+ def message(self) -> str:
107
+ if self.body.strip():
108
+ return f"{self.subject}\n\n{self.body}"
109
+ return self.subject
110
+
111
+
112
+ class Footer(BaseModel):
113
+ key: str
114
+ value: str
115
+
116
+
117
+ class ParsedCommit(BaseModel):
118
+ """Conventional-commit parse result. ``type`` is None when unmatched."""
119
+
120
+ type: str | None = None
121
+ scope: str | None = None
122
+ breaking: bool = False
123
+ description: str
124
+ body: str = ""
125
+ footers: list[Footer] = Field(default_factory=list)
126
+ raw: Commit
127
+ reverted_hash: str | None = None
128
+ pr_numbers: list[int] = Field(default_factory=list)
129
+
130
+ @property
131
+ def is_conventional(self) -> bool:
132
+ return self.type is not None
133
+
134
+
135
+ class PullRequest(BaseModel):
136
+ number: int
137
+ title: str
138
+ body: str | None = None
139
+ labels: list[str] = Field(default_factory=list)
140
+ author: str | None = None
141
+ url: str = ""
142
+ merged_at: datetime | None = None
143
+ merge_commit_sha: str | None = None
144
+ linked_issues: list[int] = Field(default_factory=list)
145
+
146
+
147
+ class Contributor(BaseModel):
148
+ login: str | None = None
149
+ name: str
150
+ email: str | None = None
151
+ first_time: bool = False
152
+ pr_count: int = 0
153
+
154
+
155
+ class ChangelogEntry(BaseModel):
156
+ """A single rendered changelog bullet."""
157
+
158
+ id: str
159
+ summary: str
160
+ original_summary: str
161
+ pr_body: str | None = Field(default=None, exclude=True)
162
+ section: str
163
+ scope: str | None = None
164
+ pr_number: int | None = None
165
+ pr_url: str | None = None
166
+ authors: list[str] = Field(default_factory=list)
167
+ hashes: list[str] = Field(default_factory=list)
168
+ short_hashes: list[str] = Field(default_factory=list)
169
+ breaking: bool = False
170
+ breaking_description: str | None = None
171
+ confidence: Confidence = Confidence.HIGH
172
+ source: EntrySource = EntrySource.CONVENTIONAL
173
+ labels: list[str] = Field(default_factory=list)
174
+ first_time_contributors: list[str] = Field(default_factory=list)
175
+ linked_issues: list[int] = Field(default_factory=list)
176
+
177
+ def to_ai_payload(self, *, include_bodies: bool = False) -> dict[str, Any]:
178
+ payload: dict[str, Any] = {
179
+ "id": self.id,
180
+ "summary": self.summary,
181
+ "section": self.section,
182
+ "scope": self.scope,
183
+ "pr_number": self.pr_number,
184
+ "breaking": self.breaking,
185
+ "hashes": list(self.short_hashes),
186
+ }
187
+ if include_bodies:
188
+ payload["original_summary"] = self.original_summary
189
+ if self.pr_body:
190
+ payload["pr_body"] = self.pr_body
191
+ return payload
192
+
193
+
194
+ class Changelog(BaseModel):
195
+ version: str | None = None
196
+ from_ref: str
197
+ to_ref: str
198
+ date: date
199
+ sections: dict[str, list[ChangelogEntry]] = Field(default_factory=dict)
200
+ breaking: list[ChangelogEntry] = Field(default_factory=list)
201
+ contributors: list[Contributor] = Field(default_factory=list)
202
+ compare_url: str | None = None
203
+ previous_tag: str | None = None
204
+ repository_url: str | None = None
205
+ overview: str | None = None
206
+ style: Style = Style.KEEPACHANGELOG
207
+ tag_name: str | None = None
208
+
209
+ @property
210
+ def all_entries(self) -> list[ChangelogEntry]:
211
+ items: list[ChangelogEntry] = list(self.breaking)
212
+ seen = {e.id for e in items}
213
+ for entries in self.sections.values():
214
+ for entry in entries:
215
+ if entry.id not in seen:
216
+ items.append(entry)
217
+ seen.add(entry.id)
218
+ return items
219
+
220
+ @property
221
+ def entry_count(self) -> int:
222
+ return len(self.all_entries)
223
+
224
+ def to_ai_payload(self, *, include_bodies: bool = False) -> dict[str, Any]:
225
+ return {
226
+ "version": self.version,
227
+ "entries": [e.to_ai_payload(include_bodies=include_bodies) for e in self.all_entries],
228
+ }
patchnote/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ """Render changelogs to markdown, JSON, or GitHub-release body."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from patchnote.config import Config
6
+ from patchnote.model import Changelog, OutputFormat
7
+ from patchnote.render.github_release import render_github_release
8
+ from patchnote.render.json import render_json
9
+ from patchnote.render.markdown import render_markdown
10
+
11
+
12
+ def render(changelog: Changelog, config: Config, fmt: OutputFormat) -> str:
13
+ if fmt is OutputFormat.JSON:
14
+ return render_json(changelog)
15
+ if fmt is OutputFormat.GITHUB_RELEASE:
16
+ return render_github_release(changelog, config)
17
+ return render_markdown(changelog, config, include_header=False)
@@ -0,0 +1,138 @@
1
+ """Insert or replace a version section in an existing Keep a Changelog file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from patchnote.config import Config
9
+ from patchnote.model import Changelog
10
+ from patchnote.render.markdown import render_section_body
11
+
12
+ VERSION_HEADER_RE = re.compile(
13
+ r"^## \[([^\]\n]+)\](?:\s*-\s*\d{4}-\d{2}-\d{2})?\s*$",
14
+ re.MULTILINE,
15
+ )
16
+ UNRELEASED_RE = re.compile(r"^## \[Unreleased\]\s*$", re.MULTILINE | re.IGNORECASE)
17
+ LINK_REF_RE = re.compile(r"^\[([^\]]+)\]:\s+\S+\s*$", re.MULTILINE)
18
+
19
+
20
+ def _section_bounds(text: str, start: int) -> tuple[int, int]:
21
+ """Return [start, end) of a `## …` section starting at ``start``."""
22
+ next_header = re.search(r"^## ", text[start + 3 :], re.MULTILINE)
23
+ end = start + 3 + next_header.start() if next_header else len(text)
24
+ return start, end
25
+
26
+
27
+ def _find_section(text: str, version: str) -> tuple[int, int] | None:
28
+ for match in VERSION_HEADER_RE.finditer(text):
29
+ if match.group(1).lower() == version.lower():
30
+ return _section_bounds(text, match.start())
31
+ if version.lower() == "unreleased":
32
+ unreleased = UNRELEASED_RE.search(text)
33
+ if unreleased:
34
+ return _section_bounds(text, unreleased.start())
35
+ return None
36
+
37
+
38
+ def _split_link_footer(text: str) -> tuple[str, str]:
39
+ """Peel a trailing Keep-a-Changelog link-reference block off ``text``."""
40
+ lines = text.splitlines(keepends=True)
41
+ idx = len(lines)
42
+ saw_link = False
43
+ while idx > 0:
44
+ stripped = lines[idx - 1].strip()
45
+ if not stripped:
46
+ idx -= 1
47
+ continue
48
+ if LINK_REF_RE.match(stripped):
49
+ saw_link = True
50
+ idx -= 1
51
+ continue
52
+ break
53
+ if not saw_link:
54
+ return text, ""
55
+ body = "".join(lines[:idx])
56
+ footer = "".join(lines[idx:])
57
+ return body, footer
58
+
59
+
60
+ def _upsert_link(footer: str, label: str, url: str) -> str:
61
+ pattern = re.compile(rf"^\[{re.escape(label)}\]:\s+\S+\s*$", re.MULTILINE)
62
+ line = f"[{label}]: {url}\n"
63
+ if pattern.search(footer):
64
+ return pattern.sub(line.rstrip(), footer, count=1)
65
+ if footer and not footer.endswith("\n"):
66
+ footer += "\n"
67
+ return footer + line
68
+
69
+
70
+ def prepend_release(
71
+ existing: str,
72
+ changelog: Changelog,
73
+ config: Config,
74
+ ) -> str:
75
+ """Insert ``changelog`` after the file header / Unreleased section.
76
+
77
+ Running twice for the same version replaces the existing section instead of
78
+ duplicating it. Everything else in the file is preserved byte-for-byte
79
+ outside the replaced span and the compare-link footer.
80
+ """
81
+ section = render_section_body(changelog, config).rstrip() + "\n"
82
+ # Drop the compare link from the section; we maintain those at file bottom.
83
+ section_lines = [
84
+ line for line in section.splitlines(keepends=True) if not LINK_REF_RE.match(line.strip())
85
+ ]
86
+ section = "".join(section_lines)
87
+ if not section.endswith("\n"):
88
+ section += "\n"
89
+
90
+ if not existing.strip():
91
+ header = config.header.rstrip() + "\n\n" if config.header else ""
92
+ body = header + section
93
+ footer = ""
94
+ if changelog.compare_url:
95
+ footer = _upsert_link(footer, changelog.version or "Unreleased", changelog.compare_url)
96
+ return body.rstrip() + "\n\n" + footer if footer else body
97
+
98
+ newline = "\r\n" if "\r\n" in existing else "\n"
99
+ section = section.replace("\n", newline).rstrip("\r\n") + newline * 2
100
+ body, link_footer = _split_link_footer(existing)
101
+ version = changelog.version or "Unreleased"
102
+ found = _find_section(body, version)
103
+ if found is not None:
104
+ start, end = found
105
+ if end == len(body) and link_footer.startswith(("\n", "\r")):
106
+ section = section.rstrip("\r\n") + newline
107
+ body = body[:start] + section + body[end:]
108
+ else:
109
+ unreleased = _find_section(body, "Unreleased")
110
+ first_version = VERSION_HEADER_RE.search(body)
111
+ if unreleased is not None:
112
+ at = unreleased[1]
113
+ elif first_version is not None:
114
+ at = first_version.start()
115
+ else:
116
+ at = len(body)
117
+ prefix, suffix = body[:at], body[at:]
118
+ separator = "" if not prefix or prefix.endswith(newline * 2) else newline * 2
119
+ body = prefix + separator + section + suffix
120
+ if changelog.compare_url:
121
+ label = changelog.version or "Unreleased"
122
+ pattern = re.compile(rf"^\[{re.escape(label)}\]:[^\r\n]*(?:\r?\n|$)", re.MULTILINE)
123
+ line = f"[{label}]: {changelog.compare_url}{newline}"
124
+ if pattern.search(link_footer):
125
+ link_footer = pattern.sub(lambda _match: line, link_footer, count=1)
126
+ else:
127
+ if link_footer and not link_footer.endswith(("\n", "\r")):
128
+ link_footer += newline
129
+ link_footer += line
130
+ return body + link_footer
131
+
132
+
133
+ def write_prepend(path: Path, changelog: Changelog, config: Config) -> str:
134
+ existing = path.read_bytes().decode("utf-8") if path.exists() else ""
135
+ updated = prepend_release(existing, changelog, config)
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_bytes(updated.encode("utf-8"))
138
+ return updated
@@ -0,0 +1,36 @@
1
+ """GitHub Release body: markdown without the H2 version heading or compare link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from patchnote.config import Config
6
+ from patchnote.model import Changelog
7
+ from patchnote.render.markdown import format_entry, iter_ordered_sections
8
+
9
+
10
+ def render_github_release(changelog: Changelog, config: Config) -> str:
11
+ lines: list[str] = []
12
+ if changelog.overview:
13
+ lines.append(changelog.overview.rstrip())
14
+ lines.append("")
15
+ if changelog.breaking:
16
+ lines.append(f"### {config.breaking_section}")
17
+ lines.append("")
18
+ for entry in changelog.breaking:
19
+ lines.append(f"- {format_entry(entry, config)}")
20
+ if entry.breaking_description:
21
+ lines.append(f" - **BREAKING CHANGE:** {entry.breaking_description}")
22
+ lines.append("")
23
+ for name, entries in iter_ordered_sections(changelog, config):
24
+ lines.append(f"### {name}")
25
+ lines.append("")
26
+ for entry in entries:
27
+ lines.append(f"- {format_entry(entry, config)}")
28
+ lines.append("")
29
+ if changelog.contributors and config.include_contributors:
30
+ lines.append("### Contributors")
31
+ lines.append("")
32
+ for person in changelog.contributors:
33
+ suffix = " (first-time contributor)" if person.first_time else ""
34
+ lines.append(f"- {person.name}{suffix}")
35
+ lines.append("")
36
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,12 @@
1
+ """JSON changelog rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from patchnote.model import Changelog
8
+
9
+
10
+ def render_json(changelog: Changelog) -> str:
11
+ payload = changelog.model_dump(mode="json")
12
+ return json.dumps(payload, indent=2, sort_keys=False) + "\n"