github-agent-cli 1.2.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.
- ghagent.py +511 -0
- ghalib/__init__.py +5 -0
- ghalib/assets.py +475 -0
- ghalib/audit.py +649 -0
- ghalib/changelog.py +473 -0
- ghalib/config.py +52 -0
- ghalib/install.py +189 -0
- ghalib/mdcheck.py +424 -0
- ghalib/meta.py +274 -0
- ghalib/report.py +90 -0
- ghalib/util.py +522 -0
- ghalib/wiki.py +512 -0
- github_agent_cli-1.2.1.dist-info/METADATA +431 -0
- github_agent_cli-1.2.1.dist-info/RECORD +17 -0
- github_agent_cli-1.2.1.dist-info/WHEEL +4 -0
- github_agent_cli-1.2.1.dist-info/entry_points.txt +2 -0
- github_agent_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
ghalib/changelog.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""CHANGELOG.md work: conformance check, releasing Unreleased, generating entries.
|
|
2
|
+
|
|
3
|
+
The contract this module enforces is Keep a Changelog 1.1.0 plus semantic versioning:
|
|
4
|
+
|
|
5
|
+
# Changelog
|
|
6
|
+
All notable changes are documented here. Format: Keep a Changelog 1.1.0, SemVer 2.0.0.
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
## [1.2.3] - 2026-10-24
|
|
9
|
+
### Added
|
|
10
|
+
- Something a user can see.
|
|
11
|
+
...
|
|
12
|
+
[Unreleased]: https://github.com/<owner>/<repo>/compare/v1.2.3...HEAD
|
|
13
|
+
[1.2.3]: https://github.com/<owner>/<repo>/compare/v1.2.2...v1.2.3
|
|
14
|
+
|
|
15
|
+
Two rules are not obvious and are easy to get wrong:
|
|
16
|
+
* a version section without a date cannot be linked or sorted, so it is an error;
|
|
17
|
+
* 'Unreleased' is the only heading that is allowed to have no date.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .util import Report, read_text, run, scan_structure, write_text
|
|
28
|
+
|
|
29
|
+
TYPES_EN = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
|
|
30
|
+
TYPES_RU = ["Добавлено", "Изменено", "Устарело", "Удалено", "Исправлено", "Безопасность"]
|
|
31
|
+
TYPE_ALIASES = {name.lower(): name for name in TYPES_EN}
|
|
32
|
+
TYPE_ALIASES.update({name.lower(): name for name in TYPES_RU})
|
|
33
|
+
|
|
34
|
+
VERSION_RE = re.compile(
|
|
35
|
+
r"^\[?(?P<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?(?:\+[0-9A-Za-z.\-]+)?)\]?"
|
|
36
|
+
r"(?:\s*[-—–]\s*(?P<date>\d{4}-\d{2}-\d{2}))?\s*$"
|
|
37
|
+
)
|
|
38
|
+
LINK_REF_RE = re.compile(r"^\[(?P<label>[^\]]+)\]:\s*(?P<url>\S+)\s*$")
|
|
39
|
+
CONVENTIONAL_RE = re.compile(
|
|
40
|
+
r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]*)\))?(?P<breaking>!)?:\s*(?P<description>.+)$"
|
|
41
|
+
)
|
|
42
|
+
TYPE_TO_SECTION = {
|
|
43
|
+
"feat": "Added",
|
|
44
|
+
"feature": "Added",
|
|
45
|
+
"fix": "Fixed",
|
|
46
|
+
"bugfix": "Fixed",
|
|
47
|
+
"perf": "Changed",
|
|
48
|
+
"refactor": "Changed",
|
|
49
|
+
"docs": "Changed",
|
|
50
|
+
"style": "Changed",
|
|
51
|
+
"revert": "Removed",
|
|
52
|
+
"remove": "Removed",
|
|
53
|
+
"security": "Security",
|
|
54
|
+
"deprecate": "Deprecated",
|
|
55
|
+
"build": "Changed",
|
|
56
|
+
}
|
|
57
|
+
NOISE_TYPES = {"chore", "test", "tests", "ci", "wip", "release"}
|
|
58
|
+
SECTION_ORDER = {name.lower(): index for index, name in enumerate(TYPES_EN)}
|
|
59
|
+
SECTION_ORDER.update({name.lower(): index for index, name in enumerate(TYPES_RU)})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class VersionSection:
|
|
64
|
+
version: str
|
|
65
|
+
date: str
|
|
66
|
+
line: int
|
|
67
|
+
sections: list[tuple[str, int]] # (heading text, line)
|
|
68
|
+
items: int = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def section_name(heading: str) -> str | None:
|
|
72
|
+
return TYPE_ALIASES.get(heading.strip().lower())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
|
|
76
|
+
headings, _, _, lines = scan_structure(text)
|
|
77
|
+
versions: list[VersionSection] = []
|
|
78
|
+
references: dict[str, str] = {}
|
|
79
|
+
for raw in lines:
|
|
80
|
+
match = LINK_REF_RE.match(raw.strip())
|
|
81
|
+
if match:
|
|
82
|
+
references[match.group("label").lower()] = match.group("url")
|
|
83
|
+
|
|
84
|
+
current: VersionSection | None = None
|
|
85
|
+
current_section_start: int | None = None
|
|
86
|
+
for index, raw in enumerate(lines, start=1):
|
|
87
|
+
heading_match = re.match(r"^(#{1,6})\s+(.*)$", raw)
|
|
88
|
+
if heading_match:
|
|
89
|
+
level = len(heading_match.group(1))
|
|
90
|
+
title = heading_match.group(2).strip()
|
|
91
|
+
if level == 2:
|
|
92
|
+
if current and current_section_start is not None:
|
|
93
|
+
current.items += _count_items(lines, current_section_start, index)
|
|
94
|
+
current_section_start = None
|
|
95
|
+
if title.strip().strip("[]").lower() in {"unreleased", "не выпущено", "невыпущено"}:
|
|
96
|
+
current = VersionSection(version="Unreleased", date="", line=index, sections=[])
|
|
97
|
+
versions.append(current)
|
|
98
|
+
continue
|
|
99
|
+
match = VERSION_RE.match(title)
|
|
100
|
+
if match:
|
|
101
|
+
current = VersionSection(version=match.group("version"),
|
|
102
|
+
date=match.group("date") or "", line=index, sections=[])
|
|
103
|
+
versions.append(current)
|
|
104
|
+
else:
|
|
105
|
+
current = None
|
|
106
|
+
continue
|
|
107
|
+
if level == 3 and current is not None:
|
|
108
|
+
current.sections.append((title, index))
|
|
109
|
+
current_section_start = index
|
|
110
|
+
if current and current_section_start is not None:
|
|
111
|
+
current.items += _count_items(lines, current_section_start, len(lines) + 1)
|
|
112
|
+
return versions, references
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _count_items(lines: list[str], start: int, end: int) -> int:
|
|
116
|
+
count = 0
|
|
117
|
+
for raw in lines[start:end - 1]:
|
|
118
|
+
if re.match(r"^\s*[-*]\s+\S", raw):
|
|
119
|
+
count += 1
|
|
120
|
+
return count
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _semver_key(version: str) -> tuple:
|
|
124
|
+
core = version.split("-", 1)[0].split("+", 1)[0]
|
|
125
|
+
parts = [int(p) for p in core.split(".")]
|
|
126
|
+
while len(parts) < 3:
|
|
127
|
+
parts.append(0)
|
|
128
|
+
pre = version.split("-", 1)[1].split("+", 1)[0] if "-" in version else ""
|
|
129
|
+
return (parts[0], parts[1], parts[2], 0 if pre else 1, pre)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check(path: Path, *, report: Report | None = None, slug: str = "", today: dt.date | None = None) -> Report:
|
|
133
|
+
path = Path(path)
|
|
134
|
+
report = report or Report(title="ghagent changelog check", root=str(path.parent))
|
|
135
|
+
if not path.exists():
|
|
136
|
+
report.error("changelog.missing", f"{path.name} is absent")
|
|
137
|
+
return report
|
|
138
|
+
text, meta = read_text(path)
|
|
139
|
+
if meta.has_bom:
|
|
140
|
+
report.error("doc.bom", "CHANGELOG starts with a BOM", path=path.name)
|
|
141
|
+
if meta.has_crlf:
|
|
142
|
+
report.error("doc.crlf", "CHANGELOG uses CRLF", path=path.name)
|
|
143
|
+
|
|
144
|
+
headings, _, _, lines = scan_structure(text)
|
|
145
|
+
h1 = [h for h in headings if h.level == 1]
|
|
146
|
+
if not h1:
|
|
147
|
+
report.error("changelog.h1", "no H1 heading", path=path.name)
|
|
148
|
+
elif len(h1) > 1:
|
|
149
|
+
report.error("changelog.h1", f"{len(h1)} H1 headings", path=path.name)
|
|
150
|
+
if h1 and h1[0].line != 1:
|
|
151
|
+
report.warn("changelog.h1-position", f"H1 is on line {h1[0].line}, not line 1", path=path.name)
|
|
152
|
+
|
|
153
|
+
preamble_lines: list[str] = []
|
|
154
|
+
for raw in lines:
|
|
155
|
+
if re.match(r"^##\s", raw):
|
|
156
|
+
break
|
|
157
|
+
preamble_lines.append(raw)
|
|
158
|
+
preamble = "\n".join(preamble_lines).lower()
|
|
159
|
+
if "keep a changelog" not in preamble:
|
|
160
|
+
report.warn("changelog.format-link", "the preamble does not mention Keep a Changelog",
|
|
161
|
+
path=path.name, hint="state the format and link it")
|
|
162
|
+
if "semantic versioning" not in preamble and "semver" not in preamble:
|
|
163
|
+
report.warn("changelog.semver-link", "the preamble does not mention Semantic Versioning", path=path.name)
|
|
164
|
+
|
|
165
|
+
versions, references = parse(text)
|
|
166
|
+
released = [v for v in versions if v.version != "Unreleased"]
|
|
167
|
+
today = today or dt.date.today()
|
|
168
|
+
|
|
169
|
+
if not versions:
|
|
170
|
+
report.error("changelog.versions", "no version sections found", path=path.name)
|
|
171
|
+
return report
|
|
172
|
+
if not any(v.version == "Unreleased" for v in versions):
|
|
173
|
+
report.warn("changelog.unreleased", "no [Unreleased] section", path=path.name,
|
|
174
|
+
hint="pending work becomes invisible without it")
|
|
175
|
+
if versions[0].version != "Unreleased" and released and versions[0] is not released[0]:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
seen: dict[str, int] = {}
|
|
179
|
+
previous_key: tuple | None = None
|
|
180
|
+
previous_date: str | None = None
|
|
181
|
+
for section in released:
|
|
182
|
+
if section.version in seen:
|
|
183
|
+
report.error("changelog.duplicate", f"version {section.version} appears twice",
|
|
184
|
+
path=path.name, line=section.line)
|
|
185
|
+
seen[section.version] = section.line
|
|
186
|
+
if not section.date:
|
|
187
|
+
report.error("changelog.date-missing", f"version {section.version} has no ISO date",
|
|
188
|
+
path=path.name, line=section.line, hint="## [x.y.z] - YYYY-MM-DD")
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
parsed_date = dt.date.fromisoformat(section.date)
|
|
192
|
+
except ValueError:
|
|
193
|
+
report.error("changelog.date-format", f"version {section.version} has a non-ISO date '{section.date}'",
|
|
194
|
+
path=path.name, line=section.line)
|
|
195
|
+
else:
|
|
196
|
+
if parsed_date > today:
|
|
197
|
+
report.warn("changelog.date-future", f"version {section.version} is dated in the future",
|
|
198
|
+
path=path.name, line=section.line)
|
|
199
|
+
if previous_date and section.date > previous_date:
|
|
200
|
+
report.error("changelog.order", f"version {section.version} is dated after the entry above it",
|
|
201
|
+
path=path.name, line=section.line, hint="the latest version comes first")
|
|
202
|
+
previous_date = section.date or previous_date
|
|
203
|
+
key = _semver_key(section.version)
|
|
204
|
+
if previous_key and key > previous_key:
|
|
205
|
+
report.error("changelog.order", f"version {section.version} is greater than the version listed above it",
|
|
206
|
+
path=path.name, line=section.line, hint="sort newest first")
|
|
207
|
+
previous_key = key
|
|
208
|
+
|
|
209
|
+
if not section.sections:
|
|
210
|
+
report.error("changelog.empty-version", f"version {section.version} has no change subsections",
|
|
211
|
+
path=path.name, line=section.line)
|
|
212
|
+
if section.sections and section.items == 0:
|
|
213
|
+
report.error("changelog.empty-version", f"version {section.version} has headings but no entries",
|
|
214
|
+
path=path.name, line=section.line)
|
|
215
|
+
|
|
216
|
+
names: list[str] = []
|
|
217
|
+
for heading, line in section.sections:
|
|
218
|
+
canonical = section_name(heading)
|
|
219
|
+
if canonical is None:
|
|
220
|
+
report.warn("changelog.types", f"unknown change type '{heading}'",
|
|
221
|
+
path=path.name, line=line, hint="allowed: " + ", ".join(TYPES_EN))
|
|
222
|
+
continue
|
|
223
|
+
names.append(canonical)
|
|
224
|
+
indexes = [SECTION_ORDER.get(name.lower(), 99) for name in names]
|
|
225
|
+
if indexes != sorted(indexes):
|
|
226
|
+
report.warn("changelog.type-order", f"version {section.version}: sections are out of the canonical order",
|
|
227
|
+
path=path.name, line=section.line, hint="order: " + ", ".join(TYPES_EN))
|
|
228
|
+
|
|
229
|
+
uses_brackets = any(re.match(r"^##\s+\[", raw) for raw in lines)
|
|
230
|
+
if uses_brackets:
|
|
231
|
+
if not references:
|
|
232
|
+
report.error("changelog.link-refs", "versions use [x.y.z] but there are no link references at the bottom",
|
|
233
|
+
path=path.name, hint="add `[x.y.z]: https://…/compare/…` lines")
|
|
234
|
+
else:
|
|
235
|
+
for label in ["unreleased", *[v.version.lower() for v in released]]:
|
|
236
|
+
if label not in references:
|
|
237
|
+
report.warn("changelog.link-refs", f"no link reference for [{label}]", path=path.name)
|
|
238
|
+
if slug and references:
|
|
239
|
+
wrong = [value for value in references.values() if "github.com" in value and slug not in value]
|
|
240
|
+
if wrong:
|
|
241
|
+
report.warn("changelog.link-refs", "a link reference points at another repository",
|
|
242
|
+
path=path.name, hint=f"expected links under github.com/{slug}")
|
|
243
|
+
|
|
244
|
+
report.data.update({
|
|
245
|
+
"versions": len(released),
|
|
246
|
+
"unreleased_items": next((v.items for v in versions if v.version == "Unreleased"), 0),
|
|
247
|
+
"latest": released[0].version if released else "",
|
|
248
|
+
"links": len(references),
|
|
249
|
+
})
|
|
250
|
+
return report
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------
|
|
254
|
+
# release: promote Unreleased
|
|
255
|
+
# --------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def release(path: Path, version: str, *, date: str = "", write: bool = False, slug: str = "",
|
|
258
|
+
report: Report | None = None) -> Report:
|
|
259
|
+
path = Path(path)
|
|
260
|
+
report = report or Report(title=f"ghagent changelog release {version}", root=str(path.parent))
|
|
261
|
+
if not version or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?", version):
|
|
262
|
+
report.error("changelog.version", f"'{version}' is not a semantic version")
|
|
263
|
+
return report
|
|
264
|
+
if not path.exists():
|
|
265
|
+
report.error("changelog.missing", f"{path.name} is absent")
|
|
266
|
+
return report
|
|
267
|
+
|
|
268
|
+
text, _ = read_text(path)
|
|
269
|
+
versions, references = parse(text)
|
|
270
|
+
released = [v for v in versions if v.version != "Unreleased"]
|
|
271
|
+
if any(v.version == version for v in released):
|
|
272
|
+
report.error("changelog.duplicate", f"version {version} already exists", path=path.name)
|
|
273
|
+
return report
|
|
274
|
+
unreleased = next((v for v in versions if v.version == "Unreleased"), None)
|
|
275
|
+
if unreleased is None:
|
|
276
|
+
report.error("changelog.unreleased", "no [Unreleased] section to promote", path=path.name)
|
|
277
|
+
return report
|
|
278
|
+
if not unreleased.sections:
|
|
279
|
+
report.error("changelog.empty-version", "[Unreleased] has no change subsections", path=path.name)
|
|
280
|
+
return report
|
|
281
|
+
date = date or dt.date.today().isoformat()
|
|
282
|
+
try:
|
|
283
|
+
dt.date.fromisoformat(date)
|
|
284
|
+
except ValueError:
|
|
285
|
+
report.error("changelog.date-format", f"'{date}' is not an ISO date")
|
|
286
|
+
return report
|
|
287
|
+
|
|
288
|
+
lines = text.split("\n")
|
|
289
|
+
start = unreleased.line # 1-based heading line
|
|
290
|
+
end = len(lines)
|
|
291
|
+
for later in versions[versions.index(unreleased) + 1:]:
|
|
292
|
+
end = later.line - 1
|
|
293
|
+
break
|
|
294
|
+
body = lines[start:end]
|
|
295
|
+
while body and not body[-1].strip():
|
|
296
|
+
body.pop()
|
|
297
|
+
|
|
298
|
+
new_block = [f"## [{version}] - {date}"] + body[1:]
|
|
299
|
+
result_lines = lines[: start - 1] + ["## [Unreleased]", ""] + new_block + [""] + lines[end:]
|
|
300
|
+
new_text = "\n".join(result_lines)
|
|
301
|
+
new_text = _update_references(new_text, version, slug or _slug_from_references(references))
|
|
302
|
+
|
|
303
|
+
report.data.update({
|
|
304
|
+
"version": version,
|
|
305
|
+
"date": date,
|
|
306
|
+
"entries": unreleased.items,
|
|
307
|
+
"written": False,
|
|
308
|
+
})
|
|
309
|
+
if not write:
|
|
310
|
+
report.info("changelog.dry-run", f"would promote [Unreleased] ({unreleased.items} entries) to [{version}] - {date}",
|
|
311
|
+
hint="pass --write to apply")
|
|
312
|
+
return report
|
|
313
|
+
|
|
314
|
+
if not any(v.version == version for v in parse(new_text)[0]):
|
|
315
|
+
report.error("changelog.write", "the rewritten changelog does not contain the new version; refusing to write")
|
|
316
|
+
return report
|
|
317
|
+
write_text(path, new_text)
|
|
318
|
+
report.data["written"] = True
|
|
319
|
+
validation = check(path, report=Report(title="post-write check", root=str(path.parent)), slug=slug)
|
|
320
|
+
if validation.errors:
|
|
321
|
+
report.error("changelog.post-check", f"{len(validation.errors)} error(s) after writing; see findings")
|
|
322
|
+
report.findings.extend(validation.errors)
|
|
323
|
+
return report
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _slug_from_references(references: dict[str, str]) -> str:
|
|
327
|
+
for value in references.values():
|
|
328
|
+
match = re.search(r"github\.com/([^/]+/[^/]+)/", value)
|
|
329
|
+
if match:
|
|
330
|
+
return match.group(1)
|
|
331
|
+
return ""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _update_references(text: str, version: str, slug: str) -> str:
|
|
335
|
+
lines = text.split("\n")
|
|
336
|
+
if not slug:
|
|
337
|
+
return text
|
|
338
|
+
base = f"https://github.com/{slug}"
|
|
339
|
+
unreleased_index = None
|
|
340
|
+
for index, raw in enumerate(lines):
|
|
341
|
+
match = LINK_REF_RE.match(raw.strip())
|
|
342
|
+
if match and match.group("label").strip().lower() == "unreleased":
|
|
343
|
+
unreleased_index = index
|
|
344
|
+
break
|
|
345
|
+
version_line = f"[{version}]: {base}/compare/v{_previous_version(lines, version)}...v{version}"
|
|
346
|
+
unreleased_line = f"[Unreleased]: {base}/compare/v{version}...HEAD"
|
|
347
|
+
if unreleased_index is not None:
|
|
348
|
+
lines[unreleased_index] = unreleased_line
|
|
349
|
+
lines.insert(unreleased_index + 1, version_line)
|
|
350
|
+
else:
|
|
351
|
+
while lines and not lines[-1].strip():
|
|
352
|
+
lines.pop()
|
|
353
|
+
lines.extend(["", unreleased_line, version_line, ""])
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _previous_version(lines: list[str], current: str) -> str:
|
|
358
|
+
found: list[str] = []
|
|
359
|
+
for raw in lines:
|
|
360
|
+
match = VERSION_RE.match(raw[2:].strip()) if raw.startswith("## ") else None
|
|
361
|
+
if match:
|
|
362
|
+
found.append(match.group("version"))
|
|
363
|
+
if current in found:
|
|
364
|
+
index = found.index(current)
|
|
365
|
+
if index + 1 < len(found):
|
|
366
|
+
return found[index + 1]
|
|
367
|
+
return "0.0.0"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --------------------------------------------------------------------------
|
|
371
|
+
# suggest from commits
|
|
372
|
+
# --------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def suggest(root: Path, since: str, *, include_noise: bool = False, limit: int = 500,
|
|
375
|
+
report: Report | None = None) -> Report:
|
|
376
|
+
root = Path(root)
|
|
377
|
+
report = report or Report(title=f"ghagent changelog suggest --since {since}", root=str(root))
|
|
378
|
+
rc, out, err = run(["git", "log", f"{since}..HEAD", "--no-merges",
|
|
379
|
+
"--pretty=format:%H%x1f%s%x1f%b%x1e"], root)
|
|
380
|
+
if rc != 0:
|
|
381
|
+
report.error("git.log", f"git log failed: {err.strip() or 'not a git repository'}")
|
|
382
|
+
return report
|
|
383
|
+
records = [chunk for chunk in out.split("\x1e") if chunk.strip()][:limit]
|
|
384
|
+
if not records:
|
|
385
|
+
report.warn("changelog.suggest-empty", f"no commits between {since} and HEAD")
|
|
386
|
+
return report
|
|
387
|
+
|
|
388
|
+
buckets: dict[str, list[str]] = {name: [] for name in TYPES_EN}
|
|
389
|
+
skipped: list[str] = []
|
|
390
|
+
breaking: list[str] = []
|
|
391
|
+
for record in records:
|
|
392
|
+
parts = record.split("\x1f")
|
|
393
|
+
sha = parts[0].strip()[:7]
|
|
394
|
+
subject = parts[1].strip() if len(parts) > 1 else ""
|
|
395
|
+
body = parts[2].strip() if len(parts) > 2 else ""
|
|
396
|
+
match = CONVENTIONAL_RE.match(subject)
|
|
397
|
+
if not match:
|
|
398
|
+
skipped.append(subject)
|
|
399
|
+
continue
|
|
400
|
+
kind = match.group("type").lower()
|
|
401
|
+
if kind in NOISE_TYPES and not include_noise:
|
|
402
|
+
skipped.append(subject)
|
|
403
|
+
continue
|
|
404
|
+
section = TYPE_TO_SECTION.get(kind)
|
|
405
|
+
if section is None:
|
|
406
|
+
skipped.append(subject)
|
|
407
|
+
continue
|
|
408
|
+
description = match.group("description").strip()
|
|
409
|
+
scope = f"**{match.group('scope')}**: " if match.group("scope") else ""
|
|
410
|
+
entry = f"{scope}{description[:1].lower() + description[1:] if description else description}"
|
|
411
|
+
if match.group("breaking") or "BREAKING CHANGE" in body:
|
|
412
|
+
entry = "**Breaking:** " + entry
|
|
413
|
+
breaking.append(subject)
|
|
414
|
+
if not entry.rstrip().endswith("."):
|
|
415
|
+
entry = entry.rstrip() + "."
|
|
416
|
+
buckets[section].append(f"- {entry}")
|
|
417
|
+
|
|
418
|
+
lines: list[str] = []
|
|
419
|
+
for name in TYPES_EN:
|
|
420
|
+
if not buckets[name]:
|
|
421
|
+
continue
|
|
422
|
+
lines.append(f"### {name}")
|
|
423
|
+
lines.append("")
|
|
424
|
+
lines.extend(sorted(set(buckets[name])))
|
|
425
|
+
lines.append("")
|
|
426
|
+
block = "\n".join(lines).strip()
|
|
427
|
+
report.data.update({
|
|
428
|
+
"commits": len(records),
|
|
429
|
+
"entries": sum(len(v) for v in buckets.values()),
|
|
430
|
+
"skipped": len(skipped),
|
|
431
|
+
"breaking": len(breaking),
|
|
432
|
+
"markdown": block,
|
|
433
|
+
})
|
|
434
|
+
if not block:
|
|
435
|
+
report.warn("changelog.suggest-empty", "no commit matched a conventional type that belongs in a changelog",
|
|
436
|
+
hint="use `feat:`, `fix:`, `perf:`, `docs:`, `security:` prefixes")
|
|
437
|
+
else:
|
|
438
|
+
report.info("changelog.suggest", f"{sum(len(v) for v in buckets.values())} entry(ies) from {len(records)} commit(s)")
|
|
439
|
+
return report
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# --------------------------------------------------------------------------
|
|
443
|
+
# release notes body
|
|
444
|
+
# --------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def notes(path: Path, version: str, *, report: Report | None = None) -> Report:
|
|
447
|
+
path = Path(path)
|
|
448
|
+
report = report or Report(title=f"ghagent changelog notes {version}")
|
|
449
|
+
if not path.exists():
|
|
450
|
+
report.error("changelog.missing", f"{path.name} is absent")
|
|
451
|
+
return report
|
|
452
|
+
text, _ = read_text(path)
|
|
453
|
+
versions, _ = parse(text)
|
|
454
|
+
target = next((v for v in versions if v.version == version), None)
|
|
455
|
+
if target is None:
|
|
456
|
+
report.error("changelog.version", f"version {version} is not in the changelog")
|
|
457
|
+
return report
|
|
458
|
+
lines = text.split("\n")
|
|
459
|
+
end = len(lines)
|
|
460
|
+
for later in versions[versions.index(target) + 1:]:
|
|
461
|
+
end = later.line - 1
|
|
462
|
+
break
|
|
463
|
+
body = lines[target.line:end]
|
|
464
|
+
cleaned: list[str] = []
|
|
465
|
+
for raw in body:
|
|
466
|
+
if LINK_REF_RE.match(raw.strip()):
|
|
467
|
+
continue
|
|
468
|
+
cleaned.append(raw)
|
|
469
|
+
block = "\n".join(cleaned).strip()
|
|
470
|
+
report.data.update({"version": version, "date": target.date, "body": block})
|
|
471
|
+
if not block:
|
|
472
|
+
report.error("changelog.empty-version", f"version {version} has an empty body")
|
|
473
|
+
return report
|
ghalib/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Repository-local configuration: `.ghagent.json` at the repository root.
|
|
2
|
+
|
|
3
|
+
Defaults are deliberately conservative: nothing is published and nothing is deleted
|
|
4
|
+
unless a human passes the flag that says so.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONFIG_NAME = ".ghagent.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
root: Path
|
|
19
|
+
owner_repo: str = ""
|
|
20
|
+
default_branch: str = "main"
|
|
21
|
+
readme: str = "README.md"
|
|
22
|
+
agents: str = "AGENTS.md"
|
|
23
|
+
changelog: str = "CHANGELOG.md"
|
|
24
|
+
docs_dir: str = "docs"
|
|
25
|
+
wiki_dir: str = "wiki"
|
|
26
|
+
wiki_manifest: str = "wiki/wiki.json"
|
|
27
|
+
ci_workflow: str = ".github/workflows/ci.yml"
|
|
28
|
+
require_files: list[str] = field(default_factory=list)
|
|
29
|
+
ignore_rules: list[str] = field(default_factory=list)
|
|
30
|
+
topic_min: int = 8
|
|
31
|
+
topic_max: int = 20
|
|
32
|
+
description_max: int = 200
|
|
33
|
+
changelog_style: str = "keepachangelog"
|
|
34
|
+
language: str = "ru"
|
|
35
|
+
|
|
36
|
+
def path(self, relative: str) -> Path:
|
|
37
|
+
return self.root / relative
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(root: Path) -> Config:
|
|
41
|
+
root = Path(root)
|
|
42
|
+
config = Config(root=root)
|
|
43
|
+
candidate = root / CONFIG_NAME
|
|
44
|
+
if not candidate.exists():
|
|
45
|
+
return config
|
|
46
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
47
|
+
for key, value in data.items():
|
|
48
|
+
if key.startswith("_"):
|
|
49
|
+
continue
|
|
50
|
+
if hasattr(config, key):
|
|
51
|
+
setattr(config, key, value)
|
|
52
|
+
return config
|