Option C Tools
Loading...
Searching...
No Matches
changelog.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/changelog.py
4
5"""
6Purpose
7-------
8Generate a Keep a Changelog formatted changelog from Conventional Commits
9history (Phase 4E — G-E1).
10
11Responsibilities
12----------------
13- Parse git log output into structured changelog entries.
14- Map Conventional Commits types to Keep a Changelog sections.
15- Render changelog in Markdown and JSON formats.
16
17Diagnostics
18-----------
19Domain: GIT
20Levels:
21 L2 — changelog generation lifecycle
22 L3 — entry collection details
23 L4 — per-commit parsing trace
24
25Contracts
26---------
27- ``collect_changelog_entries`` returns an empty list on fresh repos.
28- ``format_changelog`` always returns valid Markdown.
29- Non-conventional commits are silently skipped (never raises).
30"""
31
32from __future__ import annotations
33
34from dataclasses import dataclass
35from datetime import date
36from pathlib import Path
37
38from oct.core.diagnostics import _dbg
39from oct.git.conventional import parse_commit_message
40
41
42# =====================================================================
43# Constants
44# =====================================================================
45
46#: Map Conventional Commits types to Keep a Changelog sections.
47#: Types not listed here (style, test, chore) are omitted.
48_TYPE_TO_SECTION: dict[str, str] = {
49 "feat": "Added",
50 "fix": "Fixed",
51 "docs": "Documentation",
52 "refactor": "Changed",
53 "perf": "Changed",
54 "sec": "Security",
55}
56
57#: Section display order (Keep a Changelog convention).
58_SECTION_ORDER: list[str] = [
59 "Added",
60 "Changed",
61 "Fixed",
62 "Security",
63 "Documentation",
64]
65
66
67# =====================================================================
68# Data structures
69# =====================================================================
70
71
72@dataclass
74 """One parsed commit mapped to a changelog section."""
75
76 section: str
77 scope: str | None
78 subject: str
79 breaking: bool
80 sha: str
81
82
83# =====================================================================
84# Entry collection
85# =====================================================================
86
87
89 repo_root: Path,
90 since_ref: str | None = None,
91) -> list[ChangelogEntry]:
92 """Read git log, parse each conventional commit, return entries.
93
94 Non-conventional commits are silently skipped. If *since_ref* is
95 ``None``, the latest tag is used; if no tags exist, the entire
96 history is scanned.
97 """
98 func = "collect_changelog_entries"
99 _dbg("GIT", func, f"repo_root={repo_root} since_ref={since_ref!r}", 2)
100
101 from oct.core.git import list_tags, get_commit_log
102
103 # Determine starting point.
104 effective_ref = since_ref
105 if effective_ref is None:
106 tags = list_tags(repo_root)
107 if tags:
108 effective_ref = tags[0]
109 _dbg("GIT", func, f"using latest tag: {effective_ref}", 3)
110
111 # Fetch log lines: "SHA<SEP>subject+body"
112 # Use a separator unlikely to appear in commit messages.
113 sep = "|||"
114 raw_lines = get_commit_log(
115 repo_root,
116 since_ref=effective_ref,
117 format_str=f"%h{sep}%B%x00",
118 )
119
120 # get_commit_log returns lines; with %B%x00 the output may span
121 # multiple lines per commit. Rejoin and split on the null byte.
122 raw_text = "\n".join(raw_lines)
123 raw_commits = [c.strip() for c in raw_text.split("\x00") if c.strip()]
124
125 entries: list[ChangelogEntry] = []
126 for raw in raw_commits:
127 # Split on separator to extract short SHA and message.
128 if sep not in raw:
129 continue
130 sha_part, message = raw.split(sep, 1)
131 sha = sha_part.strip()
132 message = message.strip()
133
134 parsed = parse_commit_message(message)
135 if parsed is None:
136 _dbg("GIT", func, f"skipping non-conventional: {sha}", 4)
137 continue
138
139 section = _TYPE_TO_SECTION.get(parsed.type)
140 if section is None:
141 _dbg("GIT", func, f"skipping unmapped type: {parsed.type}", 4)
142 continue
143
144 entries.append(ChangelogEntry(
145 section=section,
146 scope=parsed.scope,
147 subject=parsed.subject,
148 breaking=parsed.breaking,
149 sha=sha,
150 ))
151
152 _dbg("GIT", func, f"collected {len(entries)} entries", 2)
153 return entries
154
155
156# =====================================================================
157# Formatting
158# =====================================================================
159
160
162 entries: list[ChangelogEntry],
163 version: str | None = None,
164) -> str:
165 """Render entries in Keep a Changelog format.
166
167 Groups entries by section in the canonical order. If *version* is
168 provided, the header uses ``## [version] - YYYY-MM-DD``; otherwise
169 ``## [Unreleased]``.
170 """
171 lines: list[str] = []
172
173 # Header.
174 if version:
175 lines.append(f"## [{version}] - {date.today().isoformat()}")
176 else:
177 lines.append("## [Unreleased]")
178 lines.append("")
179
180 # Group entries by section.
181 grouped: dict[str, list[ChangelogEntry]] = {}
182 for entry in entries:
183 grouped.setdefault(entry.section, []).append(entry)
184
185 if not grouped:
186 lines.append("No notable changes.")
187 lines.append("")
188 return "\n".join(lines)
189
190 for section in _SECTION_ORDER:
191 section_entries = grouped.get(section)
192 if not section_entries:
193 continue
194 lines.append(f"### {section}")
195 lines.append("")
196 for entry in section_entries:
197 prefix = "**BREAKING:** " if entry.breaking else ""
198 scope = f"**{entry.scope}:** " if entry.scope else ""
199 lines.append(f"- {prefix}{scope}{entry.subject} ({entry.sha})")
200 lines.append("")
201
202 return "\n".join(lines)
203
204
206 entries: list[ChangelogEntry],
207 version: str | None = None,
208) -> dict:
209 """Return structured dict for ``--json`` output."""
210 grouped: dict[str, list[dict]] = {}
211 for entry in entries:
212 grouped.setdefault(entry.section, []).append({
213 "scope": entry.scope,
214 "subject": entry.subject,
215 "breaking": entry.breaking,
216 "sha": entry.sha,
217 })
218
219 return {
220 "version": version or "Unreleased",
221 "date": date.today().isoformat(),
222 "sections": {
223 section: grouped.get(section, [])
224 for section in _SECTION_ORDER
225 if grouped.get(section)
226 },
227 "total_entries": len(entries),
228 }
Definition changelog.py:73
dict changelog_to_json(list[ChangelogEntry] entries, str|None version=None)
Definition changelog.py:208
list[ChangelogEntry] collect_changelog_entries(Path repo_root, str|None since_ref=None)
Definition changelog.py:91
str format_changelog(list[ChangelogEntry] entries, str|None version=None)
Definition changelog.py:164