lgit-cli 3.7.0__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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/changelog.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Changelog maintenance for staged commits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .diffing import smart_truncate_diff
|
|
15
|
+
from .errors import GitError, ValidationFailure
|
|
16
|
+
from .git import run_git
|
|
17
|
+
from .models import ChangelogCategory
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class UnreleasedSection:
|
|
22
|
+
"""Parsed bounds and entries for a changelog's Unreleased section."""
|
|
23
|
+
|
|
24
|
+
path: Path
|
|
25
|
+
header_line: int
|
|
26
|
+
end_line: int
|
|
27
|
+
entries: dict[ChangelogCategory, list[str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ChangelogBoundary:
|
|
32
|
+
"""A changelog and the staged files governed by it."""
|
|
33
|
+
|
|
34
|
+
changelog_path: Path
|
|
35
|
+
files: tuple[str, ...]
|
|
36
|
+
diff: str = ""
|
|
37
|
+
stat: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run_changelog_flow(args: Any, config: Any) -> list[ChangelogBoundary]:
|
|
41
|
+
"""Generate and stage changelog entries for currently staged files."""
|
|
42
|
+
|
|
43
|
+
repo_dir = Path(getattr(args, "dir", "."))
|
|
44
|
+
staged_files = _staged_files(repo_dir)
|
|
45
|
+
candidate_files = [path for path in staged_files if not path.lower().endswith("changelog.md")]
|
|
46
|
+
if not candidate_files:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
changelogs = _find_changelogs(repo_dir)
|
|
50
|
+
if not changelogs:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
boundaries = detect_boundaries(candidate_files, changelogs, repo_dir)
|
|
54
|
+
updated: list[ChangelogBoundary] = []
|
|
55
|
+
untracked_to_stage: list[str] = []
|
|
56
|
+
|
|
57
|
+
max_diff_length = int(getattr(config, "max_diff_length", 100_000))
|
|
58
|
+
for boundary in boundaries:
|
|
59
|
+
diff = _diff_for_files(boundary.files, repo_dir, max_diff_length)
|
|
60
|
+
if not diff.strip():
|
|
61
|
+
continue
|
|
62
|
+
if len(diff) > max_diff_length:
|
|
63
|
+
diff = smart_truncate_diff(diff, max_diff_length, config)
|
|
64
|
+
stat = _stat_for_files(boundary.files, repo_dir)
|
|
65
|
+
|
|
66
|
+
rel_path = _relative_to(boundary.changelog_path, repo_dir)
|
|
67
|
+
staged_content = _staged_changelog_content(rel_path, repo_dir)
|
|
68
|
+
worktree_content = boundary.changelog_path.read_text(encoding="utf-8")
|
|
69
|
+
is_tracked = staged_content is not None
|
|
70
|
+
changelog_content = staged_content if staged_content is not None else worktree_content
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
unreleased = parse_unreleased_section(changelog_content, boundary.changelog_path)
|
|
74
|
+
except ValidationFailure:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
worktree_unreleased: UnreleasedSection | None = None
|
|
78
|
+
if is_tracked and worktree_content != changelog_content:
|
|
79
|
+
try:
|
|
80
|
+
worktree_unreleased = parse_unreleased_section(worktree_content, boundary.changelog_path)
|
|
81
|
+
except ValidationFailure:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
entries = await generate_changelog_entries(
|
|
85
|
+
boundary.changelog_path,
|
|
86
|
+
_is_package_changelog(boundary.changelog_path, repo_dir),
|
|
87
|
+
stat,
|
|
88
|
+
diff,
|
|
89
|
+
_format_existing_entries(unreleased),
|
|
90
|
+
config,
|
|
91
|
+
)
|
|
92
|
+
if not entries:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
updated_staged = write_entries(changelog_content, unreleased, entries)
|
|
96
|
+
updated_worktree = (
|
|
97
|
+
write_entries(worktree_content, worktree_unreleased, entries)
|
|
98
|
+
if worktree_unreleased is not None
|
|
99
|
+
else updated_staged
|
|
100
|
+
)
|
|
101
|
+
boundary.changelog_path.write_text(updated_worktree, encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
if is_tracked:
|
|
104
|
+
stage_changelog_blob(rel_path, updated_staged, repo_dir)
|
|
105
|
+
else:
|
|
106
|
+
untracked_to_stage.append(rel_path)
|
|
107
|
+
updated.append(ChangelogBoundary(boundary.changelog_path, boundary.files, diff, stat))
|
|
108
|
+
|
|
109
|
+
if untracked_to_stage:
|
|
110
|
+
run_git(["add", "--", *untracked_to_stage], cwd=repo_dir)
|
|
111
|
+
return updated
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_unreleased_section(content: str, path: str | os.PathLike[str] = "CHANGELOG.md") -> UnreleasedSection:
|
|
115
|
+
"""Parse the `[Unreleased]` section boundaries and existing entries."""
|
|
116
|
+
|
|
117
|
+
lines = content.splitlines()
|
|
118
|
+
header_line: int | None = None
|
|
119
|
+
for index, line in enumerate(lines):
|
|
120
|
+
trimmed = line.strip().lower()
|
|
121
|
+
if "[unreleased]" in trimmed or trimmed == "## unreleased":
|
|
122
|
+
header_line = index
|
|
123
|
+
break
|
|
124
|
+
if header_line is None:
|
|
125
|
+
raise ValidationFailure(f"No [Unreleased] section in {path}", field="changelog")
|
|
126
|
+
|
|
127
|
+
end_line = len(lines)
|
|
128
|
+
for index in range(header_line + 1, len(lines)):
|
|
129
|
+
trimmed = lines[index].strip()
|
|
130
|
+
if (trimmed.startswith("## [") and "]" in trimmed) or (
|
|
131
|
+
trimmed.startswith("## ")
|
|
132
|
+
and len(trimmed) > 3
|
|
133
|
+
and (trimmed[3].isdigit() or (trimmed[3] in "vV" and len(trimmed) > 4 and trimmed[4].isdigit()))
|
|
134
|
+
):
|
|
135
|
+
end_line = index
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
entries: dict[ChangelogCategory, list[str]] = {}
|
|
139
|
+
current_category: ChangelogCategory | None = None
|
|
140
|
+
for line in lines[header_line + 1 : end_line]:
|
|
141
|
+
trimmed = line.strip()
|
|
142
|
+
if trimmed.startswith("### "):
|
|
143
|
+
category_name = trimmed.removeprefix("### ").strip()
|
|
144
|
+
current_category = _category_or_none(category_name)
|
|
145
|
+
elif current_category is not None and trimmed.startswith(("- ", "* ")):
|
|
146
|
+
normalized = normalize_changelog_entry(trimmed)
|
|
147
|
+
if normalized is not None:
|
|
148
|
+
entries.setdefault(current_category, []).append(normalized)
|
|
149
|
+
return UnreleasedSection(Path(path), header_line, end_line, entries)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_entries(
|
|
153
|
+
content: str,
|
|
154
|
+
unreleased: UnreleasedSection,
|
|
155
|
+
new_entries: Mapping[ChangelogCategory | str, Sequence[str]],
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Insert new entries at the top of the Unreleased section."""
|
|
158
|
+
|
|
159
|
+
normalized_new = _coerce_entries(new_entries)
|
|
160
|
+
if not normalized_new:
|
|
161
|
+
return content
|
|
162
|
+
|
|
163
|
+
lines = content.split("\n")
|
|
164
|
+
result: list[str] = list(lines[: unreleased.header_line + 1])
|
|
165
|
+
result.append("")
|
|
166
|
+
|
|
167
|
+
existing = unreleased.entries
|
|
168
|
+
for category in ChangelogCategory.render_order():
|
|
169
|
+
fresh = normalized_new.get(category, [])
|
|
170
|
+
old = existing.get(category, [])
|
|
171
|
+
if not fresh and not old:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
result.append(f"### {category.value}")
|
|
175
|
+
result.append("")
|
|
176
|
+
result.extend(fresh)
|
|
177
|
+
if fresh and old:
|
|
178
|
+
result.append("")
|
|
179
|
+
result.extend(old)
|
|
180
|
+
result.append("")
|
|
181
|
+
|
|
182
|
+
while result and result[-1] == "":
|
|
183
|
+
result.pop()
|
|
184
|
+
result.append("")
|
|
185
|
+
if unreleased.end_line < len(lines):
|
|
186
|
+
result.extend(lines[unreleased.end_line :])
|
|
187
|
+
|
|
188
|
+
return "\n".join(result)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def stage_changelog_blob(
|
|
192
|
+
rel_path: str | os.PathLike[str],
|
|
193
|
+
content: str,
|
|
194
|
+
dir: str | os.PathLike[str] = ".",
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Stage exact changelog content as an index blob without git-adding the worktree copy."""
|
|
197
|
+
|
|
198
|
+
path_text = os.fspath(rel_path)
|
|
199
|
+
oid = run_git(["hash-object", "-w", "--stdin"], cwd=dir, input_text=content).stdout.strip()
|
|
200
|
+
if not oid:
|
|
201
|
+
raise GitError(f"git hash-object returned no oid for {path_text}")
|
|
202
|
+
mode = _staged_changelog_mode(path_text, dir)
|
|
203
|
+
run_git(["update-index", "--add", "--cacheinfo", f"{mode},{oid},{path_text}"], cwd=dir)
|
|
204
|
+
return oid
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def detect_boundaries(
|
|
208
|
+
files: Sequence[str],
|
|
209
|
+
changelogs: Sequence[str | os.PathLike[str]],
|
|
210
|
+
dir: str | os.PathLike[str] = ".",
|
|
211
|
+
) -> list[ChangelogBoundary]:
|
|
212
|
+
"""Group staged files under the nearest ancestor CHANGELOG.md."""
|
|
213
|
+
|
|
214
|
+
repo_dir = Path(dir)
|
|
215
|
+
dir_to_changelog: dict[str, Path] = {}
|
|
216
|
+
root_changelog: Path | None = None
|
|
217
|
+
for changelog in changelogs:
|
|
218
|
+
path = Path(changelog)
|
|
219
|
+
rel = _relative_to(path, repo_dir)
|
|
220
|
+
parent = str(Path(rel).parent).replace("\\", "/")
|
|
221
|
+
if parent in ("", "."):
|
|
222
|
+
root_changelog = path
|
|
223
|
+
else:
|
|
224
|
+
dir_to_changelog[parent] = path
|
|
225
|
+
|
|
226
|
+
grouped: dict[Path, list[str]] = {}
|
|
227
|
+
for file in files:
|
|
228
|
+
current = Path(file).parent
|
|
229
|
+
selected: Path | None = None
|
|
230
|
+
while True:
|
|
231
|
+
key = "" if str(current) == "." else str(current).replace("\\", "/")
|
|
232
|
+
if key in dir_to_changelog:
|
|
233
|
+
selected = dir_to_changelog[key]
|
|
234
|
+
break
|
|
235
|
+
if key == "":
|
|
236
|
+
break
|
|
237
|
+
parent = current.parent
|
|
238
|
+
if parent == current:
|
|
239
|
+
break
|
|
240
|
+
current = parent
|
|
241
|
+
if selected is None:
|
|
242
|
+
selected = root_changelog
|
|
243
|
+
if selected is not None:
|
|
244
|
+
grouped.setdefault(selected, []).append(file)
|
|
245
|
+
|
|
246
|
+
return [
|
|
247
|
+
ChangelogBoundary(path, tuple(paths)) for path, paths in sorted(grouped.items(), key=lambda item: str(item[0]))
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def generate_changelog_entries(
|
|
252
|
+
changelog_path: str | os.PathLike[str],
|
|
253
|
+
is_package_changelog: bool,
|
|
254
|
+
stat: str,
|
|
255
|
+
diff: str,
|
|
256
|
+
existing_entries: str | None,
|
|
257
|
+
config: Any,
|
|
258
|
+
) -> dict[ChangelogCategory, list[str]]:
|
|
259
|
+
"""Ask the configured model for Keep a Changelog entries."""
|
|
260
|
+
|
|
261
|
+
variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
|
|
262
|
+
system_prompt, user_prompt = _render_prompt(
|
|
263
|
+
"changelog",
|
|
264
|
+
variant,
|
|
265
|
+
{
|
|
266
|
+
"changelog_path": os.fspath(changelog_path),
|
|
267
|
+
"is_package_changelog": is_package_changelog,
|
|
268
|
+
"stat": stat,
|
|
269
|
+
"diff": diff,
|
|
270
|
+
"existing_entries": existing_entries or "",
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
schema = {
|
|
274
|
+
"type": "object",
|
|
275
|
+
"properties": {
|
|
276
|
+
"entries": {
|
|
277
|
+
"type": "object",
|
|
278
|
+
"description": "Changelog entries grouped by category",
|
|
279
|
+
"properties": {
|
|
280
|
+
"Added": {
|
|
281
|
+
"type": "array",
|
|
282
|
+
"items": {"type": "string"},
|
|
283
|
+
"description": "New features or capabilities",
|
|
284
|
+
},
|
|
285
|
+
"Changed": {
|
|
286
|
+
"type": "array",
|
|
287
|
+
"items": {"type": "string"},
|
|
288
|
+
"description": "Changes to existing functionality",
|
|
289
|
+
},
|
|
290
|
+
"Fixed": {
|
|
291
|
+
"type": "array",
|
|
292
|
+
"items": {"type": "string"},
|
|
293
|
+
"description": "Bug fixes",
|
|
294
|
+
},
|
|
295
|
+
"Deprecated": {
|
|
296
|
+
"type": "array",
|
|
297
|
+
"items": {"type": "string"},
|
|
298
|
+
"description": "Features marked for removal",
|
|
299
|
+
},
|
|
300
|
+
"Removed": {
|
|
301
|
+
"type": "array",
|
|
302
|
+
"items": {"type": "string"},
|
|
303
|
+
"description": "Removed features",
|
|
304
|
+
},
|
|
305
|
+
"Security": {
|
|
306
|
+
"type": "array",
|
|
307
|
+
"items": {"type": "string"},
|
|
308
|
+
"description": "Security-related changes",
|
|
309
|
+
},
|
|
310
|
+
"Breaking Changes": {
|
|
311
|
+
"type": "array",
|
|
312
|
+
"items": {"type": "string"},
|
|
313
|
+
"description": "Breaking API or behavior changes",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
"additionalProperties": False,
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
"required": ["entries"],
|
|
320
|
+
"additionalProperties": False,
|
|
321
|
+
}
|
|
322
|
+
output = await _call_run_oneshot(
|
|
323
|
+
config,
|
|
324
|
+
{
|
|
325
|
+
"operation": "changelog",
|
|
326
|
+
"model": getattr(config, "analysis_model", getattr(config, "model", None)),
|
|
327
|
+
"prompt_family": "changelog",
|
|
328
|
+
"prompt_variant": variant,
|
|
329
|
+
"system_prompt": system_prompt,
|
|
330
|
+
"user_prompt": user_prompt,
|
|
331
|
+
"tool_name": "create_changelog_entries",
|
|
332
|
+
"tool_description": "Generate changelog entries grouped by category",
|
|
333
|
+
"schema": schema,
|
|
334
|
+
"progress_label": "changelog",
|
|
335
|
+
"debug": None,
|
|
336
|
+
"cacheable": True,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
payload = _parse_jsonish(output)
|
|
340
|
+
entries = payload.get("entries", payload) if isinstance(payload, dict) else {}
|
|
341
|
+
return _coerce_entries(entries if isinstance(entries, Mapping) else {})
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def normalize_changelog_entry(entry: str) -> str | None:
|
|
345
|
+
"""Normalize one model-emitted changelog line to a markdown bullet."""
|
|
346
|
+
|
|
347
|
+
stripped = entry.strip()
|
|
348
|
+
if stripped.startswith(("- ", "* ")):
|
|
349
|
+
stripped = stripped[2:].strip()
|
|
350
|
+
return f"- {stripped}" if stripped else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _staged_files(dir: Path) -> list[str]:
|
|
354
|
+
stdout = run_git(["diff", "--cached", "--name-only"], cwd=dir).stdout
|
|
355
|
+
return [line for line in stdout.splitlines() if line]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _find_changelogs(dir: Path) -> list[Path]:
|
|
359
|
+
paths = {
|
|
360
|
+
dir / path
|
|
361
|
+
for path in run_git(["ls-files", "--", "CHANGELOG.md", "**/CHANGELOG.md"], cwd=dir).stdout.splitlines()
|
|
362
|
+
}
|
|
363
|
+
for path in dir.rglob("CHANGELOG.md"):
|
|
364
|
+
if ".git" not in path.parts:
|
|
365
|
+
paths.add(path)
|
|
366
|
+
return sorted(paths, key=lambda path: _relative_to(path, dir))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _staged_changelog_content(rel_path: str, dir: str | os.PathLike[str]) -> str | None:
|
|
370
|
+
result = run_git(["show", f":{rel_path}"], cwd=dir, check=False, allow_exit_codes=(128,))
|
|
371
|
+
return result.stdout if result.returncode == 0 else None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _staged_changelog_mode(rel_path: str, dir: str | os.PathLike[str]) -> str:
|
|
375
|
+
result = run_git(["ls-files", "-s", "--", rel_path], cwd=dir).stdout.strip()
|
|
376
|
+
return result.split(maxsplit=1)[0] if result else "100644"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _diff_for_files(files: Sequence[str], dir: Path, max_len: int) -> str:
|
|
380
|
+
if not files:
|
|
381
|
+
return ""
|
|
382
|
+
diff = run_git(["diff", "--cached", "--", *files], cwd=dir).stdout
|
|
383
|
+
if len(diff) > max_len:
|
|
384
|
+
return run_git(["diff", "--cached", "-U1", "--", *files], cwd=dir).stdout
|
|
385
|
+
return diff
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _stat_for_files(files: Sequence[str], dir: Path) -> str:
|
|
389
|
+
if not files:
|
|
390
|
+
return ""
|
|
391
|
+
return run_git(["diff", "--cached", "--stat", "--", *files], cwd=dir).stdout
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _category_or_none(name: str) -> ChangelogCategory | None:
|
|
395
|
+
normalized = name.strip().lower()
|
|
396
|
+
valid = {category.value.lower() for category in ChangelogCategory} | {
|
|
397
|
+
category.name.lower() for category in ChangelogCategory
|
|
398
|
+
}
|
|
399
|
+
if normalized == "breaking":
|
|
400
|
+
return ChangelogCategory.BREAKING
|
|
401
|
+
return ChangelogCategory.from_name(name) if normalized in valid else None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _coerce_entries(entries: Mapping[ChangelogCategory | str, Sequence[str]]) -> dict[ChangelogCategory, list[str]]:
|
|
405
|
+
coerced: dict[ChangelogCategory, list[str]] = {}
|
|
406
|
+
for key, values in entries.items():
|
|
407
|
+
category = key if isinstance(key, ChangelogCategory) else ChangelogCategory.from_name(str(key))
|
|
408
|
+
iterable = [values] if isinstance(values, str) else values
|
|
409
|
+
normalized = [line for value in iterable if (line := normalize_changelog_entry(str(value))) is not None]
|
|
410
|
+
if normalized:
|
|
411
|
+
coerced.setdefault(category, []).extend(normalized)
|
|
412
|
+
return coerced
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _format_existing_entries(unreleased: UnreleasedSection) -> str | None:
|
|
416
|
+
lines: list[str] = []
|
|
417
|
+
for category in ChangelogCategory.render_order():
|
|
418
|
+
entries = unreleased.entries.get(category, [])
|
|
419
|
+
if not entries:
|
|
420
|
+
continue
|
|
421
|
+
lines.append(f"### {category.value}")
|
|
422
|
+
lines.extend(entries)
|
|
423
|
+
lines.append("")
|
|
424
|
+
return "\n".join(lines).strip() or None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _relative_to(path: Path, dir: Path | str | os.PathLike[str]) -> str:
|
|
428
|
+
repo_dir = Path(dir)
|
|
429
|
+
try:
|
|
430
|
+
return path.relative_to(repo_dir).as_posix()
|
|
431
|
+
except ValueError:
|
|
432
|
+
return path.as_posix()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _is_package_changelog(path: Path, dir: Path) -> bool:
|
|
436
|
+
parent = path.parent
|
|
437
|
+
try:
|
|
438
|
+
return parent.resolve() != dir.resolve()
|
|
439
|
+
except OSError:
|
|
440
|
+
return parent != dir
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _render_prompt(family: str, variant: str, values: Mapping[str, Any]) -> tuple[str, str]:
|
|
444
|
+
text = (resources.files("lgit.resources") / "prompts" / family / f"{variant}.md").read_text(encoding="utf-8")
|
|
445
|
+
try:
|
|
446
|
+
from jinja2 import Template
|
|
447
|
+
|
|
448
|
+
text = Template(text).render(**values)
|
|
449
|
+
except Exception:
|
|
450
|
+
for key, value in values.items():
|
|
451
|
+
text = text.replace("{{ " + key + " }}", str(value))
|
|
452
|
+
text = text.replace("{{" + key + "}}", str(value))
|
|
453
|
+
marker = "======USER======"
|
|
454
|
+
if marker in text:
|
|
455
|
+
system, user = text.split(marker, 1)
|
|
456
|
+
else:
|
|
457
|
+
system, user = text, ""
|
|
458
|
+
return system.strip(), user.strip()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _call_run_oneshot(config: Any, spec_values: Mapping[str, Any]) -> Any:
|
|
462
|
+
from .api import run_oneshot
|
|
463
|
+
|
|
464
|
+
prompt = str(spec_values.get("user_prompt", ""))
|
|
465
|
+
cacheable = bool(spec_values.get("cacheable", True))
|
|
466
|
+
kwargs = {
|
|
467
|
+
"system_prompt": spec_values.get("system_prompt"),
|
|
468
|
+
"model": spec_values.get("model"),
|
|
469
|
+
"schema": spec_values.get("schema"),
|
|
470
|
+
"schema_name": spec_values.get("tool_name") or "response",
|
|
471
|
+
"tool_name": spec_values.get("tool_name"),
|
|
472
|
+
"tool_description": spec_values.get("tool_description"),
|
|
473
|
+
"operation": spec_values.get("operation"),
|
|
474
|
+
"prompt_family": spec_values.get("prompt_family", "custom"),
|
|
475
|
+
"prompt_variant": spec_values.get("prompt_variant", "default"),
|
|
476
|
+
"debug_label": spec_values.get("operation"),
|
|
477
|
+
"markdown_output": getattr(config, "markdown_output", None),
|
|
478
|
+
"cache": cacheable,
|
|
479
|
+
"cacheable": cacheable,
|
|
480
|
+
}
|
|
481
|
+
try:
|
|
482
|
+
result = run_oneshot(config, prompt, **kwargs)
|
|
483
|
+
except TypeError:
|
|
484
|
+
try:
|
|
485
|
+
result = run_oneshot(config=config, prompt=prompt, **kwargs)
|
|
486
|
+
except TypeError:
|
|
487
|
+
spec: Any = dict(spec_values)
|
|
488
|
+
try:
|
|
489
|
+
from .api import OneShotSpec
|
|
490
|
+
|
|
491
|
+
spec = OneShotSpec(**spec_values)
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
result = run_oneshot(config, spec)
|
|
495
|
+
if inspect.isawaitable(result):
|
|
496
|
+
result = await result
|
|
497
|
+
return getattr(result, "output", result)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _parse_jsonish(value: Any) -> Any:
|
|
501
|
+
if isinstance(value, str):
|
|
502
|
+
text = value.strip()
|
|
503
|
+
if text.startswith("```"):
|
|
504
|
+
text = text.strip("`").removeprefix("json").strip()
|
|
505
|
+
return json.loads(text)
|
|
506
|
+
if hasattr(value, "model_dump"):
|
|
507
|
+
return value.model_dump()
|
|
508
|
+
if hasattr(value, "__dict__"):
|
|
509
|
+
return vars(value)
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
__all__ = [
|
|
514
|
+
"ChangelogBoundary",
|
|
515
|
+
"UnreleasedSection",
|
|
516
|
+
"detect_boundaries",
|
|
517
|
+
"generate_changelog_entries",
|
|
518
|
+
"normalize_changelog_entry",
|
|
519
|
+
"parse_unreleased_section",
|
|
520
|
+
"run_changelog_flow",
|
|
521
|
+
"stage_changelog_blob",
|
|
522
|
+
"write_entries",
|
|
523
|
+
]
|