gitglimpse 0.1.7__tar.gz → 0.1.8__tar.gz
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.
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/.gitignore +1 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/PKG-INFO +4 -5
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/PYPI_README.md +0 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/pyproject.toml +4 -4
- gitglimpse-0.1.8/src/gitglimpse/__init__.py +1 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/cli.py +190 -7
- gitglimpse-0.1.8/src/gitglimpse/commands/changelog.md +36 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/estimation.py +44 -9
- gitglimpse-0.1.8/src/gitglimpse/formatters/changelog.py +177 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/git.py +80 -2
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/grouping.py +68 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/base.py +81 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/claude.py +11 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/gemini.py +11 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/local.py +18 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/openai.py +11 -1
- gitglimpse-0.1.7/src/gitglimpse/__init__.py +0 -1
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/LICENSE +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/commands/__init__.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/commands/pr.md +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/commands/report.md +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/commands/standup.md +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/commands/week.md +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/config.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/formatters/__init__.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/formatters/json.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/formatters/markdown.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/formatters/pr.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/formatters/template.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/onboarding.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/providers/__init__.py +0 -0
- {gitglimpse-0.1.7 → gitglimpse-0.1.8}/src/gitglimpse/py.typed +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitglimpse
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Extract structured context from your git history — PR descriptions, standups, weekly reports, and LLM-ready JSON.
|
|
5
|
-
Project-URL: Homepage, https://github.com/dino/gitglimpse
|
|
6
|
-
Project-URL: Repository, https://github.com/dino/gitglimpse
|
|
7
|
-
Project-URL: Bug Tracker, https://github.com/dino/gitglimpse/issues
|
|
5
|
+
Project-URL: Homepage, https://github.com/dino-zecevic/gitglimpse
|
|
6
|
+
Project-URL: Repository, https://github.com/dino-zecevic/gitglimpse
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/dino-zecevic/gitglimpse/issues
|
|
8
8
|
Author: Dino
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
@@ -43,7 +43,6 @@ gitglimpse reads your git log, groups commits into logical tasks, filters noise,
|
|
|
43
43
|
glimpse pr # PR summary from current branch
|
|
44
44
|
glimpse standup # daily context from recent commits
|
|
45
45
|
glimpse week # weekly summary grouped by day
|
|
46
|
-
glimpse report # markdown report with file details
|
|
47
46
|
glimpse init # generate Claude Code / Cursor slash commands
|
|
48
47
|
glimpse config setup # interactive configuration
|
|
49
48
|
```
|
|
@@ -18,7 +18,6 @@ gitglimpse reads your git log, groups commits into logical tasks, filters noise,
|
|
|
18
18
|
glimpse pr # PR summary from current branch
|
|
19
19
|
glimpse standup # daily context from recent commits
|
|
20
20
|
glimpse week # weekly summary grouped by day
|
|
21
|
-
glimpse report # markdown report with file details
|
|
22
21
|
glimpse init # generate Claude Code / Cursor slash commands
|
|
23
22
|
glimpse config setup # interactive configuration
|
|
24
23
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitglimpse"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "Extract structured context from your git history — PR descriptions, standups, weekly reports, and LLM-ready JSON."
|
|
9
9
|
readme = "PYPI_README.md"
|
|
10
10
|
authors = [{ name = "Dino" }]
|
|
@@ -29,9 +29,9 @@ dependencies = [
|
|
|
29
29
|
llm = ["httpx>=0.27"]
|
|
30
30
|
|
|
31
31
|
[project.urls]
|
|
32
|
-
Homepage = "https://github.com/dino/gitglimpse"
|
|
33
|
-
Repository = "https://github.com/dino/gitglimpse"
|
|
34
|
-
"Bug Tracker" = "https://github.com/dino/gitglimpse/issues"
|
|
32
|
+
Homepage = "https://github.com/dino-zecevic/gitglimpse"
|
|
33
|
+
Repository = "https://github.com/dino-zecevic/gitglimpse"
|
|
34
|
+
"Bug Tracker" = "https://github.com/dino-zecevic/gitglimpse/issues"
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
[project.scripts]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.8"
|
|
@@ -11,11 +11,24 @@ from rich.table import Table
|
|
|
11
11
|
|
|
12
12
|
from gitglimpse import __version__
|
|
13
13
|
from gitglimpse.config import Config, is_first_run, load_config
|
|
14
|
+
from gitglimpse.formatters.changelog import (
|
|
15
|
+
format_changelog_json,
|
|
16
|
+
format_changelog_markdown,
|
|
17
|
+
format_changelog_template,
|
|
18
|
+
)
|
|
14
19
|
from gitglimpse.formatters.json import format_standup_json, format_week_json
|
|
15
20
|
from gitglimpse.formatters.markdown import format_report
|
|
16
21
|
from gitglimpse.formatters.pr import format_pr_json, format_pr_template
|
|
17
22
|
from gitglimpse.formatters.template import format_standup, format_week_template
|
|
18
|
-
from gitglimpse.git import
|
|
23
|
+
from gitglimpse.git import (
|
|
24
|
+
GitError,
|
|
25
|
+
get_branch_commits,
|
|
26
|
+
get_commit_diff,
|
|
27
|
+
get_commits,
|
|
28
|
+
get_commits_in_range,
|
|
29
|
+
get_current_branch_name,
|
|
30
|
+
get_latest_tag,
|
|
31
|
+
)
|
|
19
32
|
from gitglimpse.grouping import filter_noise_commits, group_commits_into_tasks, is_vague_message
|
|
20
33
|
from gitglimpse.providers import get_provider
|
|
21
34
|
|
|
@@ -462,15 +475,18 @@ def standup(
|
|
|
462
475
|
|
|
463
476
|
diff_snippets: dict[str, str] | None = None
|
|
464
477
|
if ctx_mode in ("diffs", "both"):
|
|
478
|
+
# "diffs" shows every commit's diff; "both" only shows vague-message ones.
|
|
479
|
+
collect_all = ctx_mode == "diffs"
|
|
465
480
|
if multi:
|
|
466
481
|
diff_snippets = {}
|
|
467
|
-
for rp,
|
|
482
|
+
for rp, project_name in repo_pairs:
|
|
483
|
+
repo_tasks = [t for t in tasks if t.project == project_name]
|
|
468
484
|
diff_snippets.update(
|
|
469
|
-
_collect_diff_snippets(
|
|
485
|
+
_collect_diff_snippets(repo_tasks, rp, all_commits=collect_all)
|
|
470
486
|
)
|
|
471
487
|
else:
|
|
472
488
|
rp = repo_pairs[0][0] if repo_pairs[0][1] else (Path(repo) if repo else None)
|
|
473
|
-
diff_snippets = _collect_diff_snippets(tasks, rp, all_commits=
|
|
489
|
+
diff_snippets = _collect_diff_snippets(tasks, rp, all_commits=collect_all)
|
|
474
490
|
|
|
475
491
|
if as_json:
|
|
476
492
|
since_date = _parse_date_bound(effective, 1)
|
|
@@ -625,7 +641,20 @@ def week(
|
|
|
625
641
|
start_date = _parse_date_bound(since, 7)
|
|
626
642
|
end_date = _parse_date_bound(until, 0) # 0 days ago = today
|
|
627
643
|
|
|
628
|
-
diff_snippets
|
|
644
|
+
diff_snippets: dict[str, str] | None = None
|
|
645
|
+
if ctx_mode in ("diffs", "both"):
|
|
646
|
+
# "diffs" shows every commit's diff; "both" only shows vague-message ones.
|
|
647
|
+
collect_all = ctx_mode == "diffs"
|
|
648
|
+
if multi:
|
|
649
|
+
diff_snippets = {}
|
|
650
|
+
for rp, project_name in repo_pairs:
|
|
651
|
+
repo_tasks = [t for t in tasks if t.project == project_name]
|
|
652
|
+
diff_snippets.update(
|
|
653
|
+
_collect_diff_snippets(repo_tasks, rp, all_commits=collect_all)
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
rp = repo_pairs[0][0] if repo_pairs[0][1] else (Path(repo) if repo else None)
|
|
657
|
+
diff_snippets = _collect_diff_snippets(tasks, rp, all_commits=collect_all)
|
|
629
658
|
|
|
630
659
|
if as_json:
|
|
631
660
|
json_str = format_week_json(tasks, start_date, end_date, diff_snippets=diff_snippets, context_mode=ctx_mode)
|
|
@@ -758,9 +787,10 @@ def pr(
|
|
|
758
787
|
ticket = extract_ticket_id(current_branch)
|
|
759
788
|
|
|
760
789
|
# Collect diff snippets if needed.
|
|
790
|
+
# "diffs" shows every commit's diff; "both" only shows vague-message ones.
|
|
761
791
|
diff_snippets: dict[str, str] | None = None
|
|
762
792
|
if ctx_mode in ("diffs", "both"):
|
|
763
|
-
diff_snippets = _collect_diff_snippets(tasks, repo_path, all_commits=
|
|
793
|
+
diff_snippets = _collect_diff_snippets(tasks, repo_path, all_commits=ctx_mode == "diffs")
|
|
764
794
|
|
|
765
795
|
# JSON output.
|
|
766
796
|
if as_json:
|
|
@@ -811,6 +841,159 @@ def pr(
|
|
|
811
841
|
)
|
|
812
842
|
|
|
813
843
|
|
|
844
|
+
# ---------------------------------------------------------------------------
|
|
845
|
+
# changelog
|
|
846
|
+
# ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
@app.command()
|
|
849
|
+
def changelog(
|
|
850
|
+
from_ref: Annotated[
|
|
851
|
+
Optional[str],
|
|
852
|
+
typer.Option("--from", help="Start ref (tag/commit). Defaults to the latest tag."),
|
|
853
|
+
] = None,
|
|
854
|
+
to_ref: Annotated[
|
|
855
|
+
str,
|
|
856
|
+
typer.Option("--to", help="End ref (tag/commit/branch)."),
|
|
857
|
+
] = "HEAD",
|
|
858
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
859
|
+
no_llm: Annotated[bool, typer.Option("--no-llm", help="Skip LLM, use template formatter.")] = False,
|
|
860
|
+
local_llm: Annotated[bool, typer.Option("--local-llm", help="Use local LLM (Ollama).")] = False,
|
|
861
|
+
local_llm_url: Annotated[
|
|
862
|
+
Optional[str],
|
|
863
|
+
typer.Option("--local-llm-url", help="Override local LLM base URL."),
|
|
864
|
+
] = None,
|
|
865
|
+
model: Annotated[
|
|
866
|
+
Optional[str],
|
|
867
|
+
typer.Option("--model", help="LLM model to use."),
|
|
868
|
+
] = None,
|
|
869
|
+
repo: Annotated[
|
|
870
|
+
Optional[str],
|
|
871
|
+
typer.Option("--repo", help="Path to git repository. Defaults to current directory."),
|
|
872
|
+
] = None,
|
|
873
|
+
context: Annotated[
|
|
874
|
+
Optional[str],
|
|
875
|
+
typer.Option("--context", help="LLM context: 'commits', 'diffs', or 'both'."),
|
|
876
|
+
] = None,
|
|
877
|
+
filter_noise: Annotated[
|
|
878
|
+
Optional[bool],
|
|
879
|
+
typer.Option("--filter-noise/--no-filter-noise",
|
|
880
|
+
help="Filter out noise commits (merges, formatting, lock files)."),
|
|
881
|
+
] = None,
|
|
882
|
+
fmt: Annotated[
|
|
883
|
+
Optional[str],
|
|
884
|
+
typer.Option("--format", help="Output format: 'default' (Rich) or 'markdown'."),
|
|
885
|
+
] = None,
|
|
886
|
+
output: Annotated[
|
|
887
|
+
Optional[str],
|
|
888
|
+
typer.Option("--output", "-o", help="Save output to file instead of printing."),
|
|
889
|
+
] = None,
|
|
890
|
+
provider: Annotated[
|
|
891
|
+
Optional[str],
|
|
892
|
+
typer.Option("--provider", help="LLM provider override: openai, anthropic, gemini, local.", hidden=True),
|
|
893
|
+
] = None,
|
|
894
|
+
skip_setup: Annotated[
|
|
895
|
+
bool,
|
|
896
|
+
typer.Option("--skip-setup", help="Skip first-run onboarding.", hidden=True),
|
|
897
|
+
] = False,
|
|
898
|
+
) -> None:
|
|
899
|
+
"""Generate a changelog from commits in a tag/ref range.
|
|
900
|
+
|
|
901
|
+
\b
|
|
902
|
+
Examples:
|
|
903
|
+
glimpse changelog # since the latest tag → HEAD
|
|
904
|
+
glimpse changelog --from v1.2.0 --to v1.3.0
|
|
905
|
+
glimpse changelog --json
|
|
906
|
+
glimpse changelog --format markdown -o CHANGELOG_NEXT.md
|
|
907
|
+
"""
|
|
908
|
+
cfg = _load_or_onboard(skip_setup)
|
|
909
|
+
_apply_provider_override(cfg, provider, model)
|
|
910
|
+
ctx_mode = context or cfg.context_mode
|
|
911
|
+
do_filter = filter_noise if filter_noise is not None else cfg.filter_noise
|
|
912
|
+
|
|
913
|
+
repo_path = Path(repo).resolve() if repo else None
|
|
914
|
+
|
|
915
|
+
# Resolve the start ref: explicit --from, else the latest tag, else full history.
|
|
916
|
+
effective_from = from_ref or get_latest_tag(repo_path)
|
|
917
|
+
rev_range = f"{effective_from}..{to_ref}" if effective_from else to_ref
|
|
918
|
+
|
|
919
|
+
try:
|
|
920
|
+
commits = get_commits_in_range(repo_path=repo_path, rev_range=rev_range)
|
|
921
|
+
except GitError as exc:
|
|
922
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
923
|
+
raise typer.Exit(1)
|
|
924
|
+
|
|
925
|
+
filtered_count = 0
|
|
926
|
+
if do_filter:
|
|
927
|
+
original_count = len(commits)
|
|
928
|
+
commits = filter_noise_commits(commits)
|
|
929
|
+
filtered_count = original_count - len(commits)
|
|
930
|
+
|
|
931
|
+
if not commits:
|
|
932
|
+
console.print(f"No changes found in [bold]{rev_range}[/bold].")
|
|
933
|
+
raise typer.Exit(0)
|
|
934
|
+
|
|
935
|
+
# Collect diffs only if an LLM may use them. "diffs" → all; "both" → vague only.
|
|
936
|
+
diff_snippets: dict[str, str] | None = None
|
|
937
|
+
if not no_llm and ctx_mode in ("diffs", "both"):
|
|
938
|
+
from gitglimpse.grouping import changelog_subject
|
|
939
|
+
collect_all = ctx_mode == "diffs"
|
|
940
|
+
diff_snippets = {}
|
|
941
|
+
for commit in commits:
|
|
942
|
+
if commit.is_merge:
|
|
943
|
+
continue
|
|
944
|
+
if collect_all or is_vague_message(changelog_subject(commit.message)):
|
|
945
|
+
try:
|
|
946
|
+
diff_snippets[commit.hash] = get_commit_diff(repo_path, commit.hash)
|
|
947
|
+
except GitError:
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
if as_json:
|
|
951
|
+
print(format_changelog_json(commits, effective_from, to_ref, filtered_count=filtered_count))
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
use_markdown = fmt and fmt.lower() == "markdown"
|
|
955
|
+
|
|
956
|
+
if filtered_count > 0:
|
|
957
|
+
console.print(f"[dim]Filtered {filtered_count} noise commits (merges, formatting, dependencies)[/dim]",
|
|
958
|
+
highlight=False)
|
|
959
|
+
|
|
960
|
+
active_provider: object | None = None
|
|
961
|
+
llm_output: str | None = None
|
|
962
|
+
if not no_llm:
|
|
963
|
+
from gitglimpse.providers.local import LocalProvider
|
|
964
|
+
provider = _resolve_provider(cfg, local_llm, local_llm_url, model, context_mode=ctx_mode)
|
|
965
|
+
if provider is not None:
|
|
966
|
+
if isinstance(provider, LocalProvider) and not provider.is_available():
|
|
967
|
+
if local_llm:
|
|
968
|
+
console.print(
|
|
969
|
+
"[yellow]⚠ Local LLM not reachable — falling back to template.[/yellow]"
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
active_provider = provider
|
|
973
|
+
llm_output = provider.summarize_changelog(commits, effective_from, to_ref, diff_snippets)
|
|
974
|
+
|
|
975
|
+
_print_status_line(None, active_provider, ctx_mode)
|
|
976
|
+
|
|
977
|
+
if use_markdown or llm_output:
|
|
978
|
+
md = llm_output if llm_output else format_changelog_markdown(commits, effective_from, to_ref, filtered_count)
|
|
979
|
+
if output:
|
|
980
|
+
Path(output).write_text(md, encoding="utf-8")
|
|
981
|
+
console.print(f"Changelog saved to [bold]{output}[/bold]")
|
|
982
|
+
else:
|
|
983
|
+
console.print(md, markup=False, highlight=False)
|
|
984
|
+
else:
|
|
985
|
+
rendered = format_changelog_template(commits, effective_from, to_ref, filtered_count)
|
|
986
|
+
if output:
|
|
987
|
+
# Strip Rich markup for file output by using the Markdown formatter instead.
|
|
988
|
+
Path(output).write_text(
|
|
989
|
+
format_changelog_markdown(commits, effective_from, to_ref, filtered_count),
|
|
990
|
+
encoding="utf-8",
|
|
991
|
+
)
|
|
992
|
+
console.print(f"Changelog saved to [bold]{output}[/bold]")
|
|
993
|
+
else:
|
|
994
|
+
console.print(rendered, highlight=False)
|
|
995
|
+
|
|
996
|
+
|
|
814
997
|
# ---------------------------------------------------------------------------
|
|
815
998
|
# config show
|
|
816
999
|
# ---------------------------------------------------------------------------
|
|
@@ -854,7 +1037,7 @@ def config_setup() -> None:
|
|
|
854
1037
|
# init
|
|
855
1038
|
# ---------------------------------------------------------------------------
|
|
856
1039
|
|
|
857
|
-
_COMMAND_TEMPLATES = ("standup.md", "report.md", "week.md", "pr.md")
|
|
1040
|
+
_COMMAND_TEMPLATES = ("standup.md", "report.md", "week.md", "pr.md", "changelog.md")
|
|
858
1041
|
|
|
859
1042
|
|
|
860
1043
|
def _read_template(name: str) -> str:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Generate a release changelog from your git history.
|
|
2
|
+
|
|
3
|
+
Run the following shell command and capture its output:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
glimpse changelog --json --context both
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This compares the latest tag to HEAD by default. To target a specific range, pass refs:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
glimpse changelog --json --from v1.2.0 --to v1.3.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then format the JSON result into a **changelog** using this structure:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
# Changelog — [range]
|
|
19
|
+
|
|
20
|
+
[If "breaking_changes" is non-empty:]
|
|
21
|
+
## ⚠ Breaking Changes
|
|
22
|
+
- [subject][" (TICKET, hash)" using the entry's ticket/hash if present]
|
|
23
|
+
|
|
24
|
+
[For each entry in "sections" (already ordered):]
|
|
25
|
+
## [heading]
|
|
26
|
+
- [subject][" (TICKET, hash)" using the entry's ticket/hash if present]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
- Preserve the section order from the "sections" array; skip any section with no entries.
|
|
31
|
+
- Rewrite each entry's "subject" as a clear, user-facing change — but do not invent or drop entries.
|
|
32
|
+
- Include the entry "ticket" and short "hash" in parentheses when present.
|
|
33
|
+
- List breaking changes first under the "⚠ Breaking Changes" heading.
|
|
34
|
+
- If the JSON includes a "filtered_commits" count (> 0), note it briefly at the bottom: "X noise commits filtered".
|
|
35
|
+
- If "sections" is empty, write "No changes found in this range."
|
|
36
|
+
- No commentary, suggestions, or next steps — only the changelog.
|
|
@@ -12,12 +12,39 @@ if TYPE_CHECKING:
|
|
|
12
12
|
_BREAK_THRESHOLD = timedelta(hours=2)
|
|
13
13
|
_BREAK_CAP_MINUTES = 45
|
|
14
14
|
_PRIOR_WORK_MINUTES = 30
|
|
15
|
-
_COMPLEXITY_THRESHOLD_LINES = 200
|
|
16
|
-
_COMPLEXITY_MULTIPLIER = 1.2
|
|
17
15
|
_SMALL_CHANGE_LINES = 20
|
|
18
16
|
_SMALL_CHANGE_FLOOR_MINUTES = 30
|
|
19
17
|
_MINIMUM_MINUTES = 15
|
|
20
18
|
|
|
19
|
+
# Size signal for single-commit tasks (no gaps to infer effort from).
|
|
20
|
+
_SIZE_SIGNAL_THRESHOLD_LINES = 50
|
|
21
|
+
_MINUTES_PER_LINE = 0.4
|
|
22
|
+
_MINUTES_PER_EXTRA_FILE = 5
|
|
23
|
+
_SIZE_SIGNAL_CAP_MINUTES = 120
|
|
24
|
+
|
|
25
|
+
# Complexity multiplier: continuous ramp above the threshold, capped.
|
|
26
|
+
_COMPLEXITY_THRESHOLD_LINES = 200
|
|
27
|
+
_COMPLEXITY_SCALE = 0.2
|
|
28
|
+
_COMPLEXITY_MAX_MULTIPLIER = 1.5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _complexity_multiplier(total_lines: int) -> float:
|
|
32
|
+
"""Return a continuous complexity multiplier in [1.0, _COMPLEXITY_MAX_MULTIPLIER]."""
|
|
33
|
+
if total_lines <= _COMPLEXITY_THRESHOLD_LINES:
|
|
34
|
+
return 1.0
|
|
35
|
+
doublings = math.log2(total_lines / _COMPLEXITY_THRESHOLD_LINES)
|
|
36
|
+
return min(_COMPLEXITY_MAX_MULTIPLIER, 1.0 + _COMPLEXITY_SCALE * doublings)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _distinct_file_count(task: Task) -> int:
|
|
40
|
+
"""Number of distinct files touched across the task's non-merge commits."""
|
|
41
|
+
return len({
|
|
42
|
+
fc.path
|
|
43
|
+
for c in task.commits
|
|
44
|
+
if not c.is_merge
|
|
45
|
+
for fc in c.files
|
|
46
|
+
})
|
|
47
|
+
|
|
21
48
|
|
|
22
49
|
def estimate_task_duration(task: Task) -> int:
|
|
23
50
|
"""Estimate how long the task took in minutes.
|
|
@@ -28,10 +55,13 @@ def estimate_task_duration(task: Task) -> int:
|
|
|
28
55
|
3. Gaps between consecutive non-merge commits:
|
|
29
56
|
- gap < 2 h → add actual gap in minutes
|
|
30
57
|
- gap ≥ 2 h → add capped 45 min (developer likely took a break)
|
|
31
|
-
4.
|
|
58
|
+
4. Weak timing (single non-merge commit, no gaps to infer from): add a
|
|
59
|
+
size-based signal for changes over ~50 lines, plus a little per extra file,
|
|
60
|
+
so squashed large commits aren't under-counted. Small commits are untouched.
|
|
61
|
+
5. If total line changes < 20 AND any gap was ≥ 2 h: floor at 30 min
|
|
32
62
|
(captures debugging sessions with little visible output).
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
6. Complexity multiplier: a continuous ramp above 200 lines (≤ 1.5×).
|
|
64
|
+
7. Clamp to a minimum of 15 minutes.
|
|
35
65
|
"""
|
|
36
66
|
non_merge = [c for c in task.commits if not c.is_merge]
|
|
37
67
|
|
|
@@ -54,13 +84,18 @@ def estimate_task_duration(task: Task) -> int:
|
|
|
54
84
|
|
|
55
85
|
total_lines = task.insertions + task.deletions
|
|
56
86
|
|
|
57
|
-
# Rule 4:
|
|
87
|
+
# Rule 4: weak-timing size signal (single-commit tasks only).
|
|
88
|
+
if len(ordered) == 1 and total_lines > _SIZE_SIGNAL_THRESHOLD_LINES:
|
|
89
|
+
size_minutes = (total_lines - _SIZE_SIGNAL_THRESHOLD_LINES) * _MINUTES_PER_LINE
|
|
90
|
+
size_minutes += max(0, _distinct_file_count(task) - 1) * _MINUTES_PER_EXTRA_FILE
|
|
91
|
+
total_minutes += min(size_minutes, _SIZE_SIGNAL_CAP_MINUTES)
|
|
92
|
+
|
|
93
|
+
# Rule 5: debugging floor.
|
|
58
94
|
if total_lines < _SMALL_CHANGE_LINES and had_long_gap:
|
|
59
95
|
total_minutes = max(total_minutes, _SMALL_CHANGE_FLOOR_MINUTES)
|
|
60
96
|
|
|
61
|
-
# Rule
|
|
62
|
-
|
|
63
|
-
total_minutes *= _COMPLEXITY_MULTIPLIER
|
|
97
|
+
# Rule 6: complexity multiplier (continuous).
|
|
98
|
+
total_minutes *= _complexity_multiplier(total_lines)
|
|
64
99
|
|
|
65
100
|
return max(_MINIMUM_MINUTES, round(total_minutes))
|
|
66
101
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Changelog formatting (template, Markdown, and JSON)."""
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape as _escape
|
|
6
|
+
|
|
7
|
+
from gitglimpse.git import Commit
|
|
8
|
+
from gitglimpse.grouping import (
|
|
9
|
+
CHANGELOG_SECTIONS,
|
|
10
|
+
changelog_subject,
|
|
11
|
+
classify_commit_type,
|
|
12
|
+
extract_ticket_id,
|
|
13
|
+
is_breaking_change,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _range_label(from_ref: str | None, to_ref: str) -> str:
|
|
18
|
+
if from_ref:
|
|
19
|
+
return f"{from_ref}..{to_ref}"
|
|
20
|
+
return to_ref
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _entry(commit: Commit) -> dict:
|
|
24
|
+
"""Build a single changelog entry from a commit."""
|
|
25
|
+
return {
|
|
26
|
+
"subject": changelog_subject(commit.message),
|
|
27
|
+
"ticket": extract_ticket_id(commit.message),
|
|
28
|
+
"hash": commit.hash[:7],
|
|
29
|
+
"breaking": is_breaking_change(commit.message),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_sections(commits: list[Commit]) -> list[tuple[str, str, list[dict]]]:
|
|
34
|
+
"""Group commits into ordered (type_key, heading, entries) sections.
|
|
35
|
+
|
|
36
|
+
Merge commits are skipped. Duplicate subjects within a section are collapsed.
|
|
37
|
+
Only sections with at least one entry are returned, in CHANGELOG_SECTIONS order.
|
|
38
|
+
"""
|
|
39
|
+
buckets: dict[str, list[dict]] = {key: [] for key, _ in CHANGELOG_SECTIONS}
|
|
40
|
+
seen: dict[str, set[str]] = {key: set() for key, _ in CHANGELOG_SECTIONS}
|
|
41
|
+
|
|
42
|
+
for commit in commits:
|
|
43
|
+
if commit.is_merge:
|
|
44
|
+
continue
|
|
45
|
+
key = classify_commit_type(commit.message)
|
|
46
|
+
entry = _entry(commit)
|
|
47
|
+
subject_key = entry["subject"].lower()
|
|
48
|
+
if subject_key in seen[key]:
|
|
49
|
+
continue
|
|
50
|
+
seen[key].add(subject_key)
|
|
51
|
+
buckets[key].append(entry)
|
|
52
|
+
|
|
53
|
+
sections: list[tuple[str, str, list[dict]]] = []
|
|
54
|
+
for key, heading in CHANGELOG_SECTIONS:
|
|
55
|
+
if buckets[key]:
|
|
56
|
+
sections.append((key, heading, buckets[key]))
|
|
57
|
+
return sections
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _breaking_entries(sections: list[tuple[str, str, list[dict]]]) -> list[dict]:
|
|
61
|
+
return [e for _, _, entries in sections for e in entries if e["breaking"]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _entry_suffix(entry: dict) -> str:
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
if entry["ticket"]:
|
|
67
|
+
parts.append(entry["ticket"])
|
|
68
|
+
parts.append(entry["hash"])
|
|
69
|
+
return f" ({', '.join(parts)})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Template (Rich markup)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def format_changelog_template(
|
|
77
|
+
commits: list[Commit],
|
|
78
|
+
from_ref: str | None,
|
|
79
|
+
to_ref: str,
|
|
80
|
+
filtered_count: int = 0,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Render a changelog using Rich markup."""
|
|
83
|
+
sections = build_sections(commits)
|
|
84
|
+
label = _range_label(from_ref, to_ref)
|
|
85
|
+
lines: list[str] = [f"[bold yellow]Changelog — {_escape(label)}[/bold yellow]", ""]
|
|
86
|
+
|
|
87
|
+
if not sections:
|
|
88
|
+
lines.append("[dim](no changes found in this range)[/dim]")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
breaking = _breaking_entries(sections)
|
|
92
|
+
if breaking:
|
|
93
|
+
lines.append("[bold red]⚠ Breaking Changes[/bold red]")
|
|
94
|
+
for e in breaking:
|
|
95
|
+
lines.append(f" [red]•[/red] {_escape(e['subject'])}[dim]{_escape(_entry_suffix(e))}[/dim]")
|
|
96
|
+
lines.append("")
|
|
97
|
+
|
|
98
|
+
for _key, heading, entries in sections:
|
|
99
|
+
lines.append(f"[bold]{heading}[/bold]")
|
|
100
|
+
for e in entries:
|
|
101
|
+
lines.append(
|
|
102
|
+
f" [yellow]•[/yellow] {_escape(e['subject'])}"
|
|
103
|
+
f"[dim]{_escape(_entry_suffix(e))}[/dim]"
|
|
104
|
+
)
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
total = sum(len(entries) for _, _, entries in sections)
|
|
108
|
+
summary = f"[dim]{total} change{'s' if total != 1 else ''}[/dim]"
|
|
109
|
+
if filtered_count > 0:
|
|
110
|
+
summary += f" [dim]· {filtered_count} noise commits filtered[/dim]"
|
|
111
|
+
lines.append(summary)
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Markdown
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def format_changelog_markdown(
|
|
120
|
+
commits: list[Commit],
|
|
121
|
+
from_ref: str | None,
|
|
122
|
+
to_ref: str,
|
|
123
|
+
filtered_count: int = 0,
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Render a changelog as Markdown."""
|
|
126
|
+
sections = build_sections(commits)
|
|
127
|
+
label = _range_label(from_ref, to_ref)
|
|
128
|
+
lines: list[str] = [f"# Changelog — {label}", ""]
|
|
129
|
+
|
|
130
|
+
if not sections:
|
|
131
|
+
lines.append("No changes found in this range.")
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
breaking = _breaking_entries(sections)
|
|
135
|
+
if breaking:
|
|
136
|
+
lines.append("## ⚠ Breaking Changes")
|
|
137
|
+
lines.append("")
|
|
138
|
+
for e in breaking:
|
|
139
|
+
lines.append(f"- {e['subject']}{_entry_suffix(e)}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
for _key, heading, entries in sections:
|
|
143
|
+
lines.append(f"## {heading}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
for e in entries:
|
|
146
|
+
lines.append(f"- {e['subject']}{_entry_suffix(e)}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
|
|
149
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# JSON
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def format_changelog_json(
|
|
157
|
+
commits: list[Commit],
|
|
158
|
+
from_ref: str | None,
|
|
159
|
+
to_ref: str,
|
|
160
|
+
filtered_count: int = 0,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Render a changelog as a JSON string."""
|
|
163
|
+
sections = build_sections(commits)
|
|
164
|
+
data: dict = {
|
|
165
|
+
"from": from_ref,
|
|
166
|
+
"to": to_ref,
|
|
167
|
+
"range": _range_label(from_ref, to_ref),
|
|
168
|
+
"total_changes": sum(len(entries) for _, _, entries in sections),
|
|
169
|
+
"breaking_changes": _breaking_entries(sections),
|
|
170
|
+
"sections": [
|
|
171
|
+
{"type": key, "heading": heading, "entries": entries}
|
|
172
|
+
for key, heading, entries in sections
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
if filtered_count > 0:
|
|
176
|
+
data["filtered_commits"] = filtered_count
|
|
177
|
+
return _json.dumps(data, indent=2)
|
|
@@ -287,8 +287,11 @@ def get_commits(
|
|
|
287
287
|
commits = _parse_raw_output(raw)
|
|
288
288
|
|
|
289
289
|
# Enrich commits that have no branch decoration with source-ref data.
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
# The source-ref map walks the entire repo history, so only build it when
|
|
291
|
+
# some commit in this window actually lacks a branch label.
|
|
292
|
+
needs_branch_map = any(not c.branches for c in commits)
|
|
293
|
+
branch_map = _get_branch_map(git, cwd) if needs_branch_map else {}
|
|
294
|
+
fallback_branch = _get_current_branch(git, cwd) if needs_branch_map else ""
|
|
292
295
|
enriched: list[Commit] = []
|
|
293
296
|
for c in commits:
|
|
294
297
|
if not c.branches:
|
|
@@ -402,6 +405,81 @@ def get_branch_commits(
|
|
|
402
405
|
return enriched
|
|
403
406
|
|
|
404
407
|
|
|
408
|
+
def get_latest_tag(repo_path: Path | None = None) -> str | None:
|
|
409
|
+
"""Return the most recent tag reachable from HEAD, or None if there are none."""
|
|
410
|
+
git = _git_bin()
|
|
411
|
+
cwd = Path(repo_path) if repo_path is not None else Path.cwd()
|
|
412
|
+
result = subprocess.run(
|
|
413
|
+
[git, "describe", "--tags", "--abbrev=0"],
|
|
414
|
+
cwd=cwd, capture_output=True, text=True,
|
|
415
|
+
)
|
|
416
|
+
if result.returncode != 0:
|
|
417
|
+
return None
|
|
418
|
+
tag = result.stdout.strip()
|
|
419
|
+
return tag or None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_commits_in_range(
|
|
423
|
+
repo_path: Path | None = None,
|
|
424
|
+
rev_range: str = "HEAD",
|
|
425
|
+
) -> list[Commit]:
|
|
426
|
+
"""Return commits in *rev_range* (e.g. ``v1.2.0..HEAD`` or ``HEAD``), newest first.
|
|
427
|
+
|
|
428
|
+
Raises GitError if the repo or any ref in the range is invalid.
|
|
429
|
+
"""
|
|
430
|
+
git = _git_bin()
|
|
431
|
+
cwd = Path(repo_path) if repo_path is not None else Path.cwd()
|
|
432
|
+
|
|
433
|
+
if not cwd.exists():
|
|
434
|
+
raise GitError(f"Path does not exist: {cwd}")
|
|
435
|
+
|
|
436
|
+
verify = subprocess.run(
|
|
437
|
+
[git, "rev-parse", "--git-dir"],
|
|
438
|
+
cwd=cwd, capture_output=True, text=True,
|
|
439
|
+
)
|
|
440
|
+
if verify.returncode != 0:
|
|
441
|
+
raise GitError(f"Not a git repository: {cwd}")
|
|
442
|
+
|
|
443
|
+
# Empty repo (no commits yet).
|
|
444
|
+
head_check = subprocess.run(
|
|
445
|
+
[git, "rev-parse", "HEAD"],
|
|
446
|
+
cwd=cwd, capture_output=True, text=True,
|
|
447
|
+
)
|
|
448
|
+
if head_check.returncode != 0:
|
|
449
|
+
return []
|
|
450
|
+
|
|
451
|
+
cmd = [
|
|
452
|
+
git, "log",
|
|
453
|
+
f"--pretty=format:{_LOG_FORMAT}",
|
|
454
|
+
"--numstat",
|
|
455
|
+
rev_range,
|
|
456
|
+
]
|
|
457
|
+
raw = _run(cmd, cwd=cwd)
|
|
458
|
+
commits = _parse_raw_output(raw)
|
|
459
|
+
|
|
460
|
+
# Enrich branch info, only walking history if a commit needs it.
|
|
461
|
+
needs_branch_map = any(not c.branches for c in commits)
|
|
462
|
+
branch_map = _get_branch_map(git, cwd) if needs_branch_map else {}
|
|
463
|
+
fallback_branch = _get_current_branch(git, cwd) if needs_branch_map else ""
|
|
464
|
+
enriched: list[Commit] = []
|
|
465
|
+
for c in commits:
|
|
466
|
+
if not c.branches:
|
|
467
|
+
branch = branch_map.get(c.hash, fallback_branch)
|
|
468
|
+
c = Commit(
|
|
469
|
+
hash=c.hash,
|
|
470
|
+
author_email=c.author_email,
|
|
471
|
+
message=c.message,
|
|
472
|
+
timestamp=c.timestamp,
|
|
473
|
+
branches=[branch],
|
|
474
|
+
files=c.files,
|
|
475
|
+
is_merge=c.is_merge,
|
|
476
|
+
)
|
|
477
|
+
enriched.append(c)
|
|
478
|
+
|
|
479
|
+
enriched.sort(key=lambda c: c.timestamp, reverse=True)
|
|
480
|
+
return enriched
|
|
481
|
+
|
|
482
|
+
|
|
405
483
|
def get_current_author_email(repo_path: Path | None = None) -> str:
|
|
406
484
|
"""Return the configured git user.email for the repo.
|
|
407
485
|
|
|
@@ -220,6 +220,74 @@ def extract_ticket_id(branch: str) -> str | None:
|
|
|
220
220
|
return None
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Conventional-commit classification (used by the changelog command)
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
# Ordered: how sections appear in a generated changelog. Only non-empty
|
|
228
|
+
# sections are rendered.
|
|
229
|
+
CHANGELOG_SECTIONS: list[tuple[str, str]] = [
|
|
230
|
+
("feat", "Features"),
|
|
231
|
+
("fix", "Bug Fixes"),
|
|
232
|
+
("perf", "Performance"),
|
|
233
|
+
("refactor", "Refactoring"),
|
|
234
|
+
("docs", "Documentation"),
|
|
235
|
+
("test", "Tests"),
|
|
236
|
+
("build", "Build System"),
|
|
237
|
+
("ci", "CI"),
|
|
238
|
+
("style", "Styles"),
|
|
239
|
+
("revert", "Reverts"),
|
|
240
|
+
("chore", "Chores"),
|
|
241
|
+
("other", "Other Changes"),
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
_CONVENTIONAL_TYPES = frozenset(k for k, _ in CHANGELOG_SECTIONS if k != "other")
|
|
245
|
+
|
|
246
|
+
# type(optional-scope)!: subject
|
|
247
|
+
_CONVENTIONAL_RE = re.compile(
|
|
248
|
+
r"^(?P<type>[a-z]+)(?:\([^)]+\))?(?P<breaking>!)?:\s*(?P<subject>.+)$",
|
|
249
|
+
re.IGNORECASE,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def classify_commit_type(message: str) -> str:
|
|
254
|
+
"""Return the changelog section key for a commit message.
|
|
255
|
+
|
|
256
|
+
Recognises Conventional Commits prefixes (``feat:``, ``fix(scope):``, …).
|
|
257
|
+
Unknown or prefix-less messages fall back to ``"other"``.
|
|
258
|
+
"""
|
|
259
|
+
m = _CONVENTIONAL_RE.match(message.strip())
|
|
260
|
+
if m:
|
|
261
|
+
ctype = m.group("type").lower()
|
|
262
|
+
if ctype in _CONVENTIONAL_TYPES:
|
|
263
|
+
return ctype
|
|
264
|
+
return "other"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_breaking_change(message: str) -> bool:
|
|
268
|
+
"""Return True if the commit marks a breaking change (``feat!:`` or footer)."""
|
|
269
|
+
first_line = message.strip().splitlines()[0] if message.strip() else ""
|
|
270
|
+
m = _CONVENTIONAL_RE.match(first_line)
|
|
271
|
+
if m and m.group("breaking"):
|
|
272
|
+
return True
|
|
273
|
+
return "BREAKING CHANGE" in message or "BREAKING-CHANGE" in message
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def changelog_subject(message: str) -> str:
|
|
277
|
+
"""Return the commit's first line with a recognised Conventional Commits prefix stripped.
|
|
278
|
+
|
|
279
|
+
Only known types (``feat``, ``fix``, …) are stripped, so an arbitrary
|
|
280
|
+
``word:`` prefix (e.g. ``gitglimpse:``) is left intact.
|
|
281
|
+
"""
|
|
282
|
+
first_line = message.strip().splitlines()[0] if message.strip() else ""
|
|
283
|
+
m = _CONVENTIONAL_RE.match(first_line)
|
|
284
|
+
if m and m.group("type").lower() in _CONVENTIONAL_TYPES:
|
|
285
|
+
subject = m.group("subject").strip()
|
|
286
|
+
if subject:
|
|
287
|
+
return subject
|
|
288
|
+
return first_line
|
|
289
|
+
|
|
290
|
+
|
|
223
291
|
def _branch_key(commit: Commit) -> str:
|
|
224
292
|
"""Return the primary branch for a commit, or 'main' as fallback."""
|
|
225
293
|
return commit.branches[0] if commit.branches else "main"
|
|
@@ -43,6 +43,20 @@ def validate_llm_output(response: str) -> bool:
|
|
|
43
43
|
return True
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
_MAX_CHANGELOG_LEN = 8000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_changelog_output(response: str) -> bool:
|
|
50
|
+
"""Relaxed validation for changelog output (multi-heading Markdown is expected)."""
|
|
51
|
+
if not response or not response.strip():
|
|
52
|
+
return False
|
|
53
|
+
if len(response) > _MAX_CHANGELOG_LEN:
|
|
54
|
+
return False
|
|
55
|
+
if _GARBAGE_PHRASES.search(response):
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
46
60
|
class BaseLLMProvider(ABC):
|
|
47
61
|
"""Common interface all LLM providers must implement."""
|
|
48
62
|
|
|
@@ -84,6 +98,16 @@ class BaseLLMProvider(ABC):
|
|
|
84
98
|
) -> str | None:
|
|
85
99
|
"""Return a formatted PR summary, or None on failure."""
|
|
86
100
|
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def summarize_changelog(
|
|
103
|
+
self,
|
|
104
|
+
commits: list,
|
|
105
|
+
from_ref: str | None,
|
|
106
|
+
to_ref: str,
|
|
107
|
+
diff_snippets: dict[str, str] | None = None,
|
|
108
|
+
) -> str | None:
|
|
109
|
+
"""Return a formatted changelog (Markdown), or None on failure."""
|
|
110
|
+
|
|
87
111
|
# ------------------------------------------------------------------
|
|
88
112
|
# Shared helpers
|
|
89
113
|
# ------------------------------------------------------------------
|
|
@@ -150,6 +174,63 @@ class BaseLLMProvider(ABC):
|
|
|
150
174
|
"Keep the total output under 300 words."
|
|
151
175
|
)
|
|
152
176
|
|
|
177
|
+
_CHANGELOG_PROMPT = (
|
|
178
|
+
"Generate a release changelog in Markdown from the structured changes below.\n\n"
|
|
179
|
+
"Write:\n"
|
|
180
|
+
"1. A short ## heading per change category that has entries "
|
|
181
|
+
"(Features, Bug Fixes, etc.), in the order given.\n"
|
|
182
|
+
"2. One bullet per entry, rewritten as a clear, user-facing change. "
|
|
183
|
+
"Keep ticket IDs and short hashes in parentheses if present.\n"
|
|
184
|
+
"3. If there are breaking changes, put them first under a "
|
|
185
|
+
"'## ⚠ Breaking Changes' heading.\n\n"
|
|
186
|
+
"Stay faithful to the provided entries — do not invent changes, do not "
|
|
187
|
+
"drop entries, and do not add commentary, suggestions, or next steps. "
|
|
188
|
+
"Output only the changelog."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def get_changelog_system_prompt(cls, context_mode: str = "commits") -> str:
|
|
193
|
+
"""Return system prompt for changelog generation."""
|
|
194
|
+
return cls._CHANGELOG_PROMPT + cls._context_addendum(context_mode)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _format_changelog_context(
|
|
198
|
+
commits: list,
|
|
199
|
+
from_ref: str | None,
|
|
200
|
+
to_ref: str,
|
|
201
|
+
diff_snippets: dict[str, str] | None = None,
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Serialise changelog entries grouped by category for the LLM prompt."""
|
|
204
|
+
from gitglimpse.formatters.changelog import build_sections
|
|
205
|
+
|
|
206
|
+
sections = build_sections(commits)
|
|
207
|
+
range_label = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
208
|
+
lines = [
|
|
209
|
+
f"Range: {range_label}",
|
|
210
|
+
f"Total changes: {sum(len(e) for _, _, e in sections)}",
|
|
211
|
+
"",
|
|
212
|
+
]
|
|
213
|
+
for _key, heading, entries in sections:
|
|
214
|
+
lines.append(f"{heading}:")
|
|
215
|
+
for e in entries:
|
|
216
|
+
suffix_parts = [p for p in (e["ticket"], e["hash"]) if p]
|
|
217
|
+
suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else ""
|
|
218
|
+
breaking = " [BREAKING]" if e["breaking"] else ""
|
|
219
|
+
lines.append(f" - {e['subject']}{suffix}{breaking}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
if diff_snippets:
|
|
223
|
+
shown = [c for c in commits if not c.is_merge and c.hash in diff_snippets]
|
|
224
|
+
if shown:
|
|
225
|
+
lines.append("Selected diffs:")
|
|
226
|
+
for commit in shown:
|
|
227
|
+
lines.append(f" Diff ({commit.hash[:7]}):")
|
|
228
|
+
for dl in diff_snippets[commit.hash].splitlines():
|
|
229
|
+
lines.append(f" {dl}")
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
return "\n".join(lines)
|
|
233
|
+
|
|
153
234
|
@classmethod
|
|
154
235
|
def _context_addendum(cls, context_mode: str) -> str:
|
|
155
236
|
if context_mode == "diffs":
|
|
@@ -5,7 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import date
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from gitglimpse.providers.base import
|
|
8
|
+
from gitglimpse.providers.base import (
|
|
9
|
+
BaseLLMProvider,
|
|
10
|
+
_warn,
|
|
11
|
+
validate_changelog_output,
|
|
12
|
+
validate_llm_output,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
if TYPE_CHECKING:
|
|
11
16
|
from gitglimpse.grouping import Task
|
|
@@ -100,3 +105,8 @@ class ClaudeProvider(BaseLLMProvider):
|
|
|
100
105
|
def summarize_pr(self, tasks: list[Task], branch: str, base: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
101
106
|
context = self._format_pr_context(tasks, branch, base, diff_snippets)
|
|
102
107
|
return self._validated(self._chat(context, system_prompt=self.get_pr_system_prompt(self.context_mode)))
|
|
108
|
+
|
|
109
|
+
def summarize_changelog(self, commits: list, from_ref: str | None, to_ref: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
110
|
+
context = self._format_changelog_context(commits, from_ref, to_ref, diff_snippets)
|
|
111
|
+
result = self._chat(context, system_prompt=self.get_changelog_system_prompt(self.context_mode))
|
|
112
|
+
return result if result is not None and validate_changelog_output(result) else None
|
|
@@ -5,7 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import date
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from gitglimpse.providers.base import
|
|
8
|
+
from gitglimpse.providers.base import (
|
|
9
|
+
BaseLLMProvider,
|
|
10
|
+
_warn,
|
|
11
|
+
validate_changelog_output,
|
|
12
|
+
validate_llm_output,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
if TYPE_CHECKING:
|
|
11
16
|
from gitglimpse.grouping import Task
|
|
@@ -96,3 +101,8 @@ class GeminiProvider(BaseLLMProvider):
|
|
|
96
101
|
def summarize_pr(self, tasks: list[Task], branch: str, base: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
97
102
|
context = self._format_pr_context(tasks, branch, base, diff_snippets)
|
|
98
103
|
return self._validated(self._chat(context, system_prompt=self.get_pr_system_prompt(self.context_mode)))
|
|
104
|
+
|
|
105
|
+
def summarize_changelog(self, commits: list, from_ref: str | None, to_ref: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
106
|
+
context = self._format_changelog_context(commits, from_ref, to_ref, diff_snippets)
|
|
107
|
+
result = self._chat(context, system_prompt=self.get_changelog_system_prompt(self.context_mode))
|
|
108
|
+
return result if result is not None and validate_changelog_output(result) else None
|
|
@@ -5,7 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import date
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from gitglimpse.providers.base import
|
|
8
|
+
from gitglimpse.providers.base import (
|
|
9
|
+
BaseLLMProvider,
|
|
10
|
+
_warn,
|
|
11
|
+
validate_changelog_output,
|
|
12
|
+
validate_llm_output,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
if TYPE_CHECKING:
|
|
11
16
|
from gitglimpse.grouping import Task
|
|
@@ -167,3 +172,15 @@ class LocalProvider(BaseLLMProvider):
|
|
|
167
172
|
context = self._format_pr_context(tasks, branch, base, diff_snippets)
|
|
168
173
|
prompt = self.get_pr_system_prompt(context_mode=self.context_mode)
|
|
169
174
|
return self._validated(self._chat(context, system_prompt=prompt))
|
|
175
|
+
|
|
176
|
+
def summarize_changelog(
|
|
177
|
+
self,
|
|
178
|
+
commits: list,
|
|
179
|
+
from_ref: str | None,
|
|
180
|
+
to_ref: str,
|
|
181
|
+
diff_snippets: dict[str, str] | None = None,
|
|
182
|
+
) -> str | None:
|
|
183
|
+
context = self._format_changelog_context(commits, from_ref, to_ref, diff_snippets)
|
|
184
|
+
prompt = self.get_changelog_system_prompt(context_mode=self.context_mode)
|
|
185
|
+
result = self._chat(context, system_prompt=prompt)
|
|
186
|
+
return result if result is not None and validate_changelog_output(result) else None
|
|
@@ -5,7 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import date
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from gitglimpse.providers.base import
|
|
8
|
+
from gitglimpse.providers.base import (
|
|
9
|
+
BaseLLMProvider,
|
|
10
|
+
_warn,
|
|
11
|
+
validate_changelog_output,
|
|
12
|
+
validate_llm_output,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
if TYPE_CHECKING:
|
|
11
16
|
from gitglimpse.grouping import Task
|
|
@@ -98,3 +103,8 @@ class OpenAIProvider(BaseLLMProvider):
|
|
|
98
103
|
def summarize_pr(self, tasks: list[Task], branch: str, base: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
99
104
|
context = self._format_pr_context(tasks, branch, base, diff_snippets)
|
|
100
105
|
return self._validated(self._chat(context, system_prompt=self.get_pr_system_prompt(self.context_mode)))
|
|
106
|
+
|
|
107
|
+
def summarize_changelog(self, commits: list, from_ref: str | None, to_ref: str, diff_snippets: dict[str, str] | None = None) -> str | None:
|
|
108
|
+
context = self._format_changelog_context(commits, from_ref, to_ref, diff_snippets)
|
|
109
|
+
result = self._chat(context, system_prompt=self.get_changelog_system_prompt(self.context_mode))
|
|
110
|
+
return result if result is not None and validate_changelog_output(result) else None
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.7"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|