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/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()