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.
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