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/__init__.py +7 -0
- patchnote/__main__.py +7 -0
- patchnote/action_entry.py +148 -0
- patchnote/ai/__init__.py +126 -0
- patchnote/ai/cache.py +40 -0
- patchnote/ai/client.py +89 -0
- patchnote/ai/prompts.py +71 -0
- patchnote/ai/validate.py +87 -0
- patchnote/check.py +67 -0
- patchnote/classify.py +353 -0
- patchnote/cli.py +690 -0
- patchnote/config.py +461 -0
- patchnote/conventional.py +197 -0
- patchnote/filters.py +199 -0
- patchnote/github.py +388 -0
- patchnote/gitlog.py +201 -0
- patchnote/model.py +228 -0
- patchnote/py.typed +0 -0
- patchnote/render/__init__.py +17 -0
- patchnote/render/changelog_file.py +138 -0
- patchnote/render/github_release.py +36 -0
- patchnote/render/json.py +12 -0
- patchnote/render/markdown.py +185 -0
- patchnote/render/templates/changelog.md.j2 +43 -0
- patchnote-0.1.1.dist-info/METADATA +291 -0
- patchnote-0.1.1.dist-info/RECORD +29 -0
- patchnote-0.1.1.dist-info/WHEEL +4 -0
- patchnote-0.1.1.dist-info/entry_points.txt +2 -0
- patchnote-0.1.1.dist-info/licenses/LICENSE +21 -0
patchnote/config.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Load, validate, and default ``patchnote.yaml`` configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from patchnote.model import ConfigError, Style
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONFIG_NAME = "patchnote.yaml"
|
|
15
|
+
|
|
16
|
+
KEEPACHANGELOG_SECTIONS = (
|
|
17
|
+
"Added",
|
|
18
|
+
"Changed",
|
|
19
|
+
"Deprecated",
|
|
20
|
+
"Removed",
|
|
21
|
+
"Fixed",
|
|
22
|
+
"Security",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
CONVENTIONAL_SECTIONS = (
|
|
26
|
+
"Features",
|
|
27
|
+
"Bug Fixes",
|
|
28
|
+
"Performance",
|
|
29
|
+
"Refactoring",
|
|
30
|
+
"Documentation",
|
|
31
|
+
"Tests",
|
|
32
|
+
"Build",
|
|
33
|
+
"Continuous Integration",
|
|
34
|
+
"Chores",
|
|
35
|
+
"Reverts",
|
|
36
|
+
"Other",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
DEFAULT_TYPE_TO_KEEPACHANGELOG: dict[str, str] = {
|
|
40
|
+
"feat": "Added",
|
|
41
|
+
"fix": "Fixed",
|
|
42
|
+
"perf": "Changed",
|
|
43
|
+
"refactor": "Changed",
|
|
44
|
+
"docs": "Changed",
|
|
45
|
+
"style": "Changed",
|
|
46
|
+
"test": "Changed",
|
|
47
|
+
"build": "Changed",
|
|
48
|
+
"ci": "Changed",
|
|
49
|
+
"chore": "Changed",
|
|
50
|
+
"revert": "Removed",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
DEFAULT_TYPE_TO_CONVENTIONAL: dict[str, str] = {
|
|
54
|
+
"feat": "Features",
|
|
55
|
+
"fix": "Bug Fixes",
|
|
56
|
+
"perf": "Performance",
|
|
57
|
+
"refactor": "Refactoring",
|
|
58
|
+
"docs": "Documentation",
|
|
59
|
+
"style": "Other",
|
|
60
|
+
"test": "Tests",
|
|
61
|
+
"build": "Build",
|
|
62
|
+
"ci": "Continuous Integration",
|
|
63
|
+
"chore": "Chores",
|
|
64
|
+
"revert": "Reverts",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
DEFAULT_LABEL_MAP: dict[str, str] = {
|
|
68
|
+
"bug": "Fixed",
|
|
69
|
+
"bugfix": "Fixed",
|
|
70
|
+
"fix": "Fixed",
|
|
71
|
+
"enhancement": "Added",
|
|
72
|
+
"feature": "Added",
|
|
73
|
+
"feat": "Added",
|
|
74
|
+
"breaking": "Breaking Changes",
|
|
75
|
+
"breaking-change": "Breaking Changes",
|
|
76
|
+
"breaking change": "Breaking Changes",
|
|
77
|
+
"deprecated": "Deprecated",
|
|
78
|
+
"deprecation": "Deprecated",
|
|
79
|
+
"removed": "Removed",
|
|
80
|
+
"remove": "Removed",
|
|
81
|
+
"security": "Security",
|
|
82
|
+
"change": "Changed",
|
|
83
|
+
"changed": "Changed",
|
|
84
|
+
"documentation": "Changed",
|
|
85
|
+
"dependencies": "Changed",
|
|
86
|
+
"deps": "Changed",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
DEFAULT_SKIP_TYPES = ("chore", "ci", "test", "docs", "build", "style")
|
|
90
|
+
|
|
91
|
+
DEFAULT_IGNORE_PATTERNS = (
|
|
92
|
+
r"\[skip changelog\]",
|
|
93
|
+
r"\[changelog skip\]",
|
|
94
|
+
r"\[skip-changelog\]",
|
|
95
|
+
r"^Merge branch ",
|
|
96
|
+
r"^Merge remote-tracking branch ",
|
|
97
|
+
r"^Merge pull request ",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
DEFAULT_BOT_PATTERNS = (
|
|
101
|
+
r"dependabot",
|
|
102
|
+
r"renovate",
|
|
103
|
+
r"github-actions",
|
|
104
|
+
r"\[bot\]",
|
|
105
|
+
r"^bot@",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
CONVENTIONAL_TYPES = (
|
|
109
|
+
"feat",
|
|
110
|
+
"fix",
|
|
111
|
+
"perf",
|
|
112
|
+
"refactor",
|
|
113
|
+
"docs",
|
|
114
|
+
"test",
|
|
115
|
+
"build",
|
|
116
|
+
"ci",
|
|
117
|
+
"chore",
|
|
118
|
+
"revert",
|
|
119
|
+
"style",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SectionConfig(BaseModel):
|
|
124
|
+
model_config = ConfigDict(extra="forbid")
|
|
125
|
+
|
|
126
|
+
types: list[str] = Field(default_factory=list)
|
|
127
|
+
labels: list[str] = Field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BotsConfig(BaseModel):
|
|
131
|
+
model_config = ConfigDict(extra="forbid")
|
|
132
|
+
|
|
133
|
+
skip: bool = False
|
|
134
|
+
collapse: bool = True
|
|
135
|
+
patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_BOT_PATTERNS))
|
|
136
|
+
section: str = "Changed"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AiConfig(BaseModel):
|
|
140
|
+
model_config = ConfigDict(extra="forbid")
|
|
141
|
+
|
|
142
|
+
enabled: bool = False
|
|
143
|
+
base_url: str = "https://api.openai.com/v1"
|
|
144
|
+
model: str = "gpt-4o-mini"
|
|
145
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
146
|
+
include_bodies: bool = False
|
|
147
|
+
cache_dir: str = ".patchnote-cache"
|
|
148
|
+
temperature: float = 0.2
|
|
149
|
+
timeout_seconds: float = 60.0
|
|
150
|
+
|
|
151
|
+
@field_validator("timeout_seconds")
|
|
152
|
+
@classmethod
|
|
153
|
+
def _timeout_positive(cls, value: float) -> float:
|
|
154
|
+
if not math.isfinite(value) or value <= 0:
|
|
155
|
+
raise ValueError("timeout_seconds must be positive and finite")
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
@field_validator("temperature")
|
|
159
|
+
@classmethod
|
|
160
|
+
def _temp_range(cls, value: float) -> float:
|
|
161
|
+
if not 0.0 <= value <= 2.0:
|
|
162
|
+
raise ValueError("temperature must be between 0 and 2")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
@field_validator("base_url")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _strip_slash(cls, value: str) -> str:
|
|
168
|
+
return value.rstrip("/")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GitHubCfg(BaseModel):
|
|
172
|
+
model_config = ConfigDict(extra="forbid")
|
|
173
|
+
|
|
174
|
+
token_env: str = "GITHUB_TOKEN"
|
|
175
|
+
api_url: str = "https://api.github.com"
|
|
176
|
+
graphql_url: str = "https://api.github.com/graphql"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class CheckConfig(BaseModel):
|
|
180
|
+
model_config = ConfigDict(extra="forbid")
|
|
181
|
+
|
|
182
|
+
require_conventional: bool = True
|
|
183
|
+
allow_merge: bool = True
|
|
184
|
+
types: list[str] = Field(default_factory=lambda: list(CONVENTIONAL_TYPES))
|
|
185
|
+
require_scope: bool = False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Config(BaseModel):
|
|
189
|
+
"""Validated Patchnote configuration."""
|
|
190
|
+
|
|
191
|
+
model_config = ConfigDict(extra="forbid")
|
|
192
|
+
|
|
193
|
+
style: Style = Style.KEEPACHANGELOG
|
|
194
|
+
tag_pattern: str = "v*"
|
|
195
|
+
repository: str | None = None
|
|
196
|
+
sections: dict[str, SectionConfig] = Field(default_factory=dict)
|
|
197
|
+
label_map: dict[str, str] = Field(default_factory=lambda: dict(DEFAULT_LABEL_MAP))
|
|
198
|
+
skip_types: list[str] = Field(default_factory=lambda: list(DEFAULT_SKIP_TYPES))
|
|
199
|
+
ignore_patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
|
|
200
|
+
skip_merge_commits: bool = True
|
|
201
|
+
bots: BotsConfig = Field(default_factory=BotsConfig)
|
|
202
|
+
include_contributors: bool = True
|
|
203
|
+
mark_first_time_contributors: bool = True
|
|
204
|
+
trailing_period: bool = False
|
|
205
|
+
sentence_case: bool = True
|
|
206
|
+
include_hashes: bool = True
|
|
207
|
+
include_authors: bool = True
|
|
208
|
+
include_pr_links: bool = True
|
|
209
|
+
header: str = (
|
|
210
|
+
"# Changelog\n\n"
|
|
211
|
+
"All notable changes to this project will be documented in this file.\n\n"
|
|
212
|
+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
|
|
213
|
+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n"
|
|
214
|
+
)
|
|
215
|
+
footer: str = ""
|
|
216
|
+
template: str | None = None
|
|
217
|
+
other_section: str = "Other"
|
|
218
|
+
breaking_section: str = "Breaking Changes"
|
|
219
|
+
ai: AiConfig = Field(default_factory=AiConfig)
|
|
220
|
+
github: GitHubCfg = Field(default_factory=GitHubCfg)
|
|
221
|
+
check: CheckConfig = Field(default_factory=CheckConfig)
|
|
222
|
+
|
|
223
|
+
@field_validator("style", mode="before")
|
|
224
|
+
@classmethod
|
|
225
|
+
def _coerce_style(cls, value: Any) -> Any:
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
lowered = value.lower().replace("-", "").replace("_", "")
|
|
228
|
+
if lowered in {"keepachangelog", "keepachange"}:
|
|
229
|
+
return Style.KEEPACHANGELOG
|
|
230
|
+
if lowered == "conventional":
|
|
231
|
+
return Style.CONVENTIONAL
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
@field_validator("skip_types")
|
|
235
|
+
@classmethod
|
|
236
|
+
def _lower_types(cls, value: list[str]) -> list[str]:
|
|
237
|
+
return [item.lower() for item in value]
|
|
238
|
+
|
|
239
|
+
@model_validator(mode="after")
|
|
240
|
+
def _default_sections(self) -> Config:
|
|
241
|
+
if not self.sections:
|
|
242
|
+
if self.style is Style.KEEPACHANGELOG:
|
|
243
|
+
grouped: dict[str, list[str]] = {name: [] for name in KEEPACHANGELOG_SECTIONS}
|
|
244
|
+
for ctype, section in DEFAULT_TYPE_TO_KEEPACHANGELOG.items():
|
|
245
|
+
grouped.setdefault(section, []).append(ctype)
|
|
246
|
+
self.sections = {
|
|
247
|
+
name: SectionConfig(types=grouped.get(name, []), labels=[])
|
|
248
|
+
for name in KEEPACHANGELOG_SECTIONS
|
|
249
|
+
}
|
|
250
|
+
else:
|
|
251
|
+
grouped = {name: [] for name in CONVENTIONAL_SECTIONS}
|
|
252
|
+
for ctype, section in DEFAULT_TYPE_TO_CONVENTIONAL.items():
|
|
253
|
+
grouped.setdefault(section, []).append(ctype)
|
|
254
|
+
self.sections = {
|
|
255
|
+
name: SectionConfig(types=grouped.get(name, []), labels=[])
|
|
256
|
+
for name in CONVENTIONAL_SECTIONS
|
|
257
|
+
if grouped.get(name)
|
|
258
|
+
}
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def type_to_section(self) -> dict[str, str]:
|
|
262
|
+
mapping: dict[str, str] = {}
|
|
263
|
+
for section, cfg in self.sections.items():
|
|
264
|
+
for ctype in cfg.types:
|
|
265
|
+
mapping[ctype.lower()] = section
|
|
266
|
+
return mapping
|
|
267
|
+
|
|
268
|
+
def label_to_section(self) -> dict[str, str]:
|
|
269
|
+
mapping = {key.lower(): value for key, value in self.label_map.items()}
|
|
270
|
+
for section, cfg in self.sections.items():
|
|
271
|
+
for label in cfg.labels:
|
|
272
|
+
mapping[label.lower()] = section
|
|
273
|
+
return mapping
|
|
274
|
+
|
|
275
|
+
def section_order(self) -> list[str]:
|
|
276
|
+
if self.style is Style.KEEPACHANGELOG:
|
|
277
|
+
names = list(KEEPACHANGELOG_SECTIONS)
|
|
278
|
+
else:
|
|
279
|
+
names = list(CONVENTIONAL_SECTIONS)
|
|
280
|
+
extra = [name for name in self.sections if name not in names]
|
|
281
|
+
if self.other_section not in names and self.other_section not in extra:
|
|
282
|
+
extra.append(self.other_section)
|
|
283
|
+
return names + extra
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def format_validation_error(exc: ValidationError) -> str:
|
|
287
|
+
lines = ["Invalid configuration in patchnote.yaml:"]
|
|
288
|
+
for err in exc.errors():
|
|
289
|
+
loc = ".".join(str(part) for part in err.get("loc", ()))
|
|
290
|
+
msg = err.get("msg", "invalid value")
|
|
291
|
+
lines.append(f" - {loc or '<root>'}: {msg}")
|
|
292
|
+
return "\n".join(lines)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def load_yaml_file(path: Path) -> dict[str, Any]:
|
|
296
|
+
try:
|
|
297
|
+
text = path.read_text(encoding="utf-8")
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
raise ConfigError(f"Cannot read config file {path}: {exc}") from exc
|
|
300
|
+
try:
|
|
301
|
+
data = yaml.safe_load(text)
|
|
302
|
+
except yaml.YAMLError as exc:
|
|
303
|
+
raise ConfigError(f"Cannot parse YAML in {path}: invalid YAML syntax") from exc
|
|
304
|
+
if data is None:
|
|
305
|
+
return {}
|
|
306
|
+
if not isinstance(data, dict):
|
|
307
|
+
raise ConfigError(f"Config file {path} must contain a mapping at the top level")
|
|
308
|
+
return data
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def parse_config(data: dict[str, Any] | None = None) -> Config:
|
|
312
|
+
try:
|
|
313
|
+
return Config.model_validate(data or {})
|
|
314
|
+
except ValidationError as exc:
|
|
315
|
+
raise ConfigError(format_validation_error(exc)) from exc
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def find_config_path(start: Path, explicit: Path | None = None) -> Path | None:
|
|
319
|
+
if explicit is not None:
|
|
320
|
+
if not explicit.is_file():
|
|
321
|
+
raise ConfigError(f"Config file not found: {explicit}")
|
|
322
|
+
return explicit
|
|
323
|
+
current = start.resolve()
|
|
324
|
+
if current.is_file():
|
|
325
|
+
current = current.parent
|
|
326
|
+
for directory in [current, *current.parents]:
|
|
327
|
+
candidate = directory / DEFAULT_CONFIG_NAME
|
|
328
|
+
if candidate.is_file():
|
|
329
|
+
return candidate
|
|
330
|
+
if (directory / ".git").exists():
|
|
331
|
+
break
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def load_config(repo: Path, explicit: Path | None = None) -> Config:
|
|
336
|
+
path = find_config_path(repo, explicit)
|
|
337
|
+
if path is None:
|
|
338
|
+
return parse_config({})
|
|
339
|
+
config = parse_config(load_yaml_file(path))
|
|
340
|
+
if config.template and not Path(config.template).is_absolute():
|
|
341
|
+
config.template = str((path.parent / config.template).resolve())
|
|
342
|
+
return config
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
INIT_YAML = """\
|
|
346
|
+
# Patchnote configuration
|
|
347
|
+
# https://github.com/zahidhasann88/patchnote
|
|
348
|
+
#
|
|
349
|
+
# All keys are optional. Uncomment and edit to override defaults.
|
|
350
|
+
|
|
351
|
+
# Grouping style: "keepachangelog" (Added/Fixed/...) or "conventional"
|
|
352
|
+
# (Features/Bug Fixes/...).
|
|
353
|
+
# style: keepachangelog
|
|
354
|
+
|
|
355
|
+
# Glob used to pick the previous release tag when --from is omitted.
|
|
356
|
+
# tag_pattern: "v*"
|
|
357
|
+
|
|
358
|
+
# Override the GitHub repository (owner/name). Auto-detected from `origin`.
|
|
359
|
+
# repository: zahidhasann88/patchnote
|
|
360
|
+
|
|
361
|
+
# Conventional types and PR labels mapped into changelog sections.
|
|
362
|
+
# Labels take precedence over the conventional type of a commit.
|
|
363
|
+
# sections:
|
|
364
|
+
# Added:
|
|
365
|
+
# types: [feat]
|
|
366
|
+
# labels: [enhancement, feature]
|
|
367
|
+
# Changed:
|
|
368
|
+
# types: [perf, refactor]
|
|
369
|
+
# labels: [change]
|
|
370
|
+
# Deprecated:
|
|
371
|
+
# types: []
|
|
372
|
+
# labels: [deprecated]
|
|
373
|
+
# Removed:
|
|
374
|
+
# types: []
|
|
375
|
+
# labels: [removed]
|
|
376
|
+
# Fixed:
|
|
377
|
+
# types: [fix]
|
|
378
|
+
# labels: [bug, bugfix]
|
|
379
|
+
# Security:
|
|
380
|
+
# types: []
|
|
381
|
+
# labels: [security]
|
|
382
|
+
|
|
383
|
+
# Extra label → section mapping (merged with section labels above).
|
|
384
|
+
# label_map:
|
|
385
|
+
# bug: Fixed
|
|
386
|
+
# enhancement: Added
|
|
387
|
+
# security: Security
|
|
388
|
+
|
|
389
|
+
# Conventional types omitted from the changelog unless --include-all.
|
|
390
|
+
# skip_types: [chore, ci, test, docs, build, style]
|
|
391
|
+
|
|
392
|
+
# Regexes matched against the commit subject. Matching commits are dropped.
|
|
393
|
+
# ignore_patterns:
|
|
394
|
+
# - "\\\\[skip changelog\\\\]"
|
|
395
|
+
# - "\\\\[changelog skip\\\\]"
|
|
396
|
+
# - "^Merge branch "
|
|
397
|
+
# - "^Merge pull request "
|
|
398
|
+
|
|
399
|
+
# skip_merge_commits: true
|
|
400
|
+
|
|
401
|
+
# bots:
|
|
402
|
+
# skip: false # drop bot commits entirely
|
|
403
|
+
# collapse: true # or fold them into one "N dependency updates" line
|
|
404
|
+
# patterns:
|
|
405
|
+
# - dependabot
|
|
406
|
+
# - renovate
|
|
407
|
+
# - "\\\\[bot\\\\]"
|
|
408
|
+
# section: Changed
|
|
409
|
+
|
|
410
|
+
# include_contributors: true
|
|
411
|
+
# mark_first_time_contributors: true
|
|
412
|
+
|
|
413
|
+
# Summary cleanup.
|
|
414
|
+
# trailing_period: false # strip a trailing "." from entries
|
|
415
|
+
# sentence_case: true # capitalize the first character
|
|
416
|
+
# include_hashes: true
|
|
417
|
+
# include_authors: true
|
|
418
|
+
# include_pr_links: true
|
|
419
|
+
|
|
420
|
+
# other_section: Other
|
|
421
|
+
# breaking_section: Breaking Changes
|
|
422
|
+
|
|
423
|
+
# header: |
|
|
424
|
+
# # Changelog
|
|
425
|
+
#
|
|
426
|
+
# All notable changes to this project will be documented in this file.
|
|
427
|
+
|
|
428
|
+
# footer: ""
|
|
429
|
+
|
|
430
|
+
# Path (relative to the config file) of a custom Jinja2 markdown template.
|
|
431
|
+
# template: null
|
|
432
|
+
|
|
433
|
+
# ai:
|
|
434
|
+
# enabled: false
|
|
435
|
+
# base_url: https://api.openai.com/v1
|
|
436
|
+
# model: gpt-4o-mini
|
|
437
|
+
# api_key_env: OPENAI_API_KEY
|
|
438
|
+
# include_bodies: false
|
|
439
|
+
# cache_dir: .patchnote-cache
|
|
440
|
+
# temperature: 0.2
|
|
441
|
+
# timeout_seconds: 60
|
|
442
|
+
|
|
443
|
+
# github:
|
|
444
|
+
# token_env: GITHUB_TOKEN
|
|
445
|
+
# api_url: https://api.github.com
|
|
446
|
+
# graphql_url: https://api.github.com/graphql
|
|
447
|
+
|
|
448
|
+
# check:
|
|
449
|
+
# require_conventional: true
|
|
450
|
+
# allow_merge: true
|
|
451
|
+
# require_scope: false
|
|
452
|
+
# types: [feat, fix, perf, refactor, docs, test, build, ci, chore, revert, style]
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def write_init_config(directory: Path, *, force: bool = False) -> Path:
|
|
457
|
+
path = directory / DEFAULT_CONFIG_NAME
|
|
458
|
+
if path.exists() and not force:
|
|
459
|
+
raise ConfigError(f"{path} already exists (pass --force to overwrite)")
|
|
460
|
+
path.write_text(INIT_YAML, encoding="utf-8")
|
|
461
|
+
return path
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Parse Conventional Commits, including breaking changes and odd formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from patchnote.model import Commit, Footer, ParsedCommit
|
|
8
|
+
|
|
9
|
+
# Spec: type(scope)!: description
|
|
10
|
+
# Be slightly lenient about whitespace around the colon.
|
|
11
|
+
HEADER_RE = re.compile(
|
|
12
|
+
r"^(?P<type>[A-Za-z]+)"
|
|
13
|
+
r"(?:\((?P<scope>[^)]*)\))?"
|
|
14
|
+
r"(?P<breaking>!)?"
|
|
15
|
+
r"\s*:\s+"
|
|
16
|
+
r"(?P<description>.+)$"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# "feat : message" (space before colon) is non-spec; still try a fallback.
|
|
20
|
+
HEADER_LOOSE_RE = re.compile(
|
|
21
|
+
r"^(?P<type>[A-Za-z]+)"
|
|
22
|
+
r"(?:\((?P<scope>[^)]*)\))?"
|
|
23
|
+
r"(?P<breaking>!)?"
|
|
24
|
+
r"\s*:\s*"
|
|
25
|
+
r"(?P<description>.+)$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FOOTER_RE = re.compile(
|
|
29
|
+
r"^(?P<key>BREAKING CHANGE|BREAKING-CHANGE|[A-Za-z0-9-]+)"
|
|
30
|
+
r"(?P<sep>: | #)"
|
|
31
|
+
r"(?P<value>.*)$"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
REVERT_SUBJECT_RE = re.compile(r'^Revert\s+"(?P<subject>.*)"\s*$', re.IGNORECASE)
|
|
35
|
+
REVERT_HASH_RE = re.compile(
|
|
36
|
+
r"This reverts commit (?P<hash>[0-9a-f]{7,40})\b",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
PR_REF_RE = re.compile(r"(?:#|pull/|pulls/|issues/)(\d+)\b")
|
|
40
|
+
PR_PARENS_RE = re.compile(r"\(#(\d+)\)")
|
|
41
|
+
MERGE_PR_RE = re.compile(r"^Merge pull request #(\d+)\b", re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
KNOWN_TYPES = {
|
|
44
|
+
"feat",
|
|
45
|
+
"fix",
|
|
46
|
+
"perf",
|
|
47
|
+
"refactor",
|
|
48
|
+
"docs",
|
|
49
|
+
"style",
|
|
50
|
+
"test",
|
|
51
|
+
"build",
|
|
52
|
+
"ci",
|
|
53
|
+
"chore",
|
|
54
|
+
"revert",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_pr_numbers(text: str) -> list[int]:
|
|
59
|
+
"""Return unique PR/issue numbers referenced in ``text``, in order."""
|
|
60
|
+
found: list[int] = []
|
|
61
|
+
seen: set[int] = set()
|
|
62
|
+
for match in PR_PARENS_RE.finditer(text):
|
|
63
|
+
number = int(match.group(1))
|
|
64
|
+
if number not in seen:
|
|
65
|
+
found.append(number)
|
|
66
|
+
seen.add(number)
|
|
67
|
+
merge = MERGE_PR_RE.match(text.strip().split("\n", 1)[0])
|
|
68
|
+
if merge:
|
|
69
|
+
number = int(merge.group(1))
|
|
70
|
+
if number not in seen:
|
|
71
|
+
found.append(number)
|
|
72
|
+
seen.add(number)
|
|
73
|
+
for match in PR_REF_RE.finditer(text):
|
|
74
|
+
number = int(match.group(1))
|
|
75
|
+
if number not in seen:
|
|
76
|
+
found.append(number)
|
|
77
|
+
seen.add(number)
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_footers(body: str) -> tuple[str, list[Footer]]:
|
|
82
|
+
"""Split a conventional body into description body + git-style footers."""
|
|
83
|
+
if not body.strip():
|
|
84
|
+
return "", []
|
|
85
|
+
lines = body.splitlines()
|
|
86
|
+
footer_start: int | None = None
|
|
87
|
+
for index, line in enumerate(lines):
|
|
88
|
+
if FOOTER_RE.match(line.strip()) and (
|
|
89
|
+
index == 0 or not lines[index - 1].strip() or line.startswith("BREAKING")
|
|
90
|
+
):
|
|
91
|
+
footer_start = index
|
|
92
|
+
break
|
|
93
|
+
if footer_start is None:
|
|
94
|
+
return body.strip("\n"), []
|
|
95
|
+
raw_body = "\n".join(lines[:footer_start]).strip("\n")
|
|
96
|
+
footers: list[Footer] = []
|
|
97
|
+
current: Footer | None = None
|
|
98
|
+
for line in lines[footer_start:]:
|
|
99
|
+
stripped = line.strip()
|
|
100
|
+
if not stripped:
|
|
101
|
+
continue
|
|
102
|
+
match = FOOTER_RE.match(stripped)
|
|
103
|
+
if match:
|
|
104
|
+
key = match.group("key")
|
|
105
|
+
value = match.group("value").strip()
|
|
106
|
+
if match.group("sep") == " #":
|
|
107
|
+
value = "#" + value if not value.startswith("#") else value
|
|
108
|
+
current = Footer(key=key, value=value)
|
|
109
|
+
footers.append(current)
|
|
110
|
+
elif current is not None:
|
|
111
|
+
current.value = f"{current.value} {stripped}".strip()
|
|
112
|
+
return raw_body, footers
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _breaking_from_footers(footers: list[Footer]) -> tuple[bool, str | None]:
|
|
116
|
+
for footer in footers:
|
|
117
|
+
if footer.key.upper().replace("-", " ") == "BREAKING CHANGE":
|
|
118
|
+
return True, footer.value or None
|
|
119
|
+
return False, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _reverted_hash(subject: str, body: str) -> str | None:
|
|
123
|
+
match = REVERT_HASH_RE.search(body) or REVERT_HASH_RE.search(subject)
|
|
124
|
+
if match:
|
|
125
|
+
return match.group("hash").lower()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_commit(commit: Commit) -> ParsedCommit:
|
|
130
|
+
"""Parse a commit message into a Conventional Commits structure."""
|
|
131
|
+
subject = commit.subject.strip()
|
|
132
|
+
body = commit.body.strip("\n")
|
|
133
|
+
match = HEADER_RE.match(subject) or HEADER_LOOSE_RE.match(subject)
|
|
134
|
+
pr_numbers = list(
|
|
135
|
+
dict.fromkeys(int(match.group(1)) for match in PR_PARENS_RE.finditer(subject))
|
|
136
|
+
)
|
|
137
|
+
merge_pr = MERGE_PR_RE.match(subject)
|
|
138
|
+
if merge_pr and int(merge_pr.group(1)) not in pr_numbers:
|
|
139
|
+
pr_numbers.append(int(merge_pr.group(1)))
|
|
140
|
+
|
|
141
|
+
if match:
|
|
142
|
+
ctype = match.group("type").lower()
|
|
143
|
+
scope_raw = match.group("scope")
|
|
144
|
+
scope = scope_raw.strip() if scope_raw is not None and scope_raw.strip() else None
|
|
145
|
+
bang = bool(match.group("breaking"))
|
|
146
|
+
description = match.group("description").strip()
|
|
147
|
+
parsed_body, footers = _parse_footers(body)
|
|
148
|
+
footer_breaking, _ = _breaking_from_footers(footers)
|
|
149
|
+
breaking = bang or footer_breaking
|
|
150
|
+
reverted = _reverted_hash(subject, body)
|
|
151
|
+
if ctype == "revert" and reverted is None:
|
|
152
|
+
reverted = _reverted_hash(description, body)
|
|
153
|
+
return ParsedCommit(
|
|
154
|
+
type=ctype,
|
|
155
|
+
scope=scope,
|
|
156
|
+
breaking=breaking,
|
|
157
|
+
description=description,
|
|
158
|
+
body=parsed_body,
|
|
159
|
+
footers=footers,
|
|
160
|
+
raw=commit,
|
|
161
|
+
reverted_hash=reverted,
|
|
162
|
+
pr_numbers=pr_numbers,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
revert_match = REVERT_SUBJECT_RE.match(subject)
|
|
166
|
+
if revert_match:
|
|
167
|
+
parsed_body, footers = _parse_footers(body)
|
|
168
|
+
return ParsedCommit(
|
|
169
|
+
type="revert",
|
|
170
|
+
scope=None,
|
|
171
|
+
breaking=False,
|
|
172
|
+
description=revert_match.group("subject").strip(),
|
|
173
|
+
body=parsed_body,
|
|
174
|
+
footers=footers,
|
|
175
|
+
raw=commit,
|
|
176
|
+
reverted_hash=_reverted_hash(subject, body),
|
|
177
|
+
pr_numbers=pr_numbers,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
parsed_body, footers = _parse_footers(body)
|
|
181
|
+
footer_breaking, _ = _breaking_from_footers(footers)
|
|
182
|
+
return ParsedCommit(
|
|
183
|
+
type=None,
|
|
184
|
+
scope=None,
|
|
185
|
+
breaking=footer_breaking,
|
|
186
|
+
description=subject,
|
|
187
|
+
body=parsed_body,
|
|
188
|
+
footers=footers,
|
|
189
|
+
raw=commit,
|
|
190
|
+
reverted_hash=_reverted_hash(subject, body),
|
|
191
|
+
pr_numbers=pr_numbers,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def strip_pr_suffix(summary: str) -> str:
|
|
196
|
+
"""Remove a trailing ``(#123)`` used by GitHub squash merges."""
|
|
197
|
+
return re.sub(r"\s*\(#\d+\)\s*$", "", summary).strip()
|