gitglimpse 0.1.6__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.
Files changed (32) hide show
  1. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/.gitignore +3 -0
  2. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/PKG-INFO +4 -5
  3. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/PYPI_README.md +0 -1
  4. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/pyproject.toml +4 -4
  5. gitglimpse-0.1.8/src/gitglimpse/__init__.py +1 -0
  6. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/cli.py +206 -14
  7. gitglimpse-0.1.8/src/gitglimpse/commands/changelog.md +36 -0
  8. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/estimation.py +44 -9
  9. gitglimpse-0.1.8/src/gitglimpse/formatters/changelog.py +177 -0
  10. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/git.py +80 -2
  11. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/grouping.py +68 -0
  12. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/base.py +81 -0
  13. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/claude.py +11 -1
  14. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/gemini.py +11 -1
  15. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/local.py +18 -1
  16. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/openai.py +11 -1
  17. gitglimpse-0.1.6/src/gitglimpse/__init__.py +0 -1
  18. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/LICENSE +0 -0
  19. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/commands/__init__.py +0 -0
  20. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/commands/pr.md +0 -0
  21. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/commands/report.md +0 -0
  22. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/commands/standup.md +0 -0
  23. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/commands/week.md +0 -0
  24. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/config.py +0 -0
  25. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/formatters/__init__.py +0 -0
  26. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/formatters/json.py +0 -0
  27. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/formatters/markdown.py +0 -0
  28. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/formatters/pr.py +0 -0
  29. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/formatters/template.py +0 -0
  30. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/onboarding.py +0 -0
  31. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/providers/__init__.py +0 -0
  32. {gitglimpse-0.1.6 → gitglimpse-0.1.8}/src/gitglimpse/py.typed +0 -0
@@ -213,3 +213,6 @@ __marimo__/
213
213
  # Build
214
214
  *.pyc
215
215
  .venv/
216
+
217
+ .claude/
218
+ .cursor/
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitglimpse
3
- Version: 0.1.6
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.6"
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 GitError, get_branch_commits, get_commit_diff, get_commits, get_current_branch_name
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, _ in repo_pairs:
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(tasks, rp, all_commits=True)
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=True)
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 = _collect_diff_snippets(tasks, None, all_commits=True) if ctx_mode in ("diffs", "both") else None
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=True)
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:
@@ -887,7 +1070,11 @@ def _write_command_file(
887
1070
  def init(
888
1071
  cursor: Annotated[
889
1072
  bool,
890
- typer.Option("--cursor", help="Also create .cursor/commands/ files."),
1073
+ typer.Option("--cursor", help="Create .cursor/commands/ files."),
1074
+ ] = False,
1075
+ claude: Annotated[
1076
+ bool,
1077
+ typer.Option("--claude", help="Create .claude/commands/ files."),
891
1078
  ] = False,
892
1079
  force: Annotated[
893
1080
  bool,
@@ -898,19 +1085,24 @@ def init(
898
1085
  typer.Option("--repo", help="Target repository root. Defaults to current directory."),
899
1086
  ] = None,
900
1087
  ) -> None:
901
- """Initialize Claude Code (and optionally Cursor) slash-command files.
1088
+ """Initialize Claude Code and/or Cursor slash-command files.
902
1089
 
903
1090
  \b
904
1091
  Examples:
905
- glimpse init
906
- glimpse init --cursor
1092
+ glimpse init # Claude Code only (default)
1093
+ glimpse init --cursor # Cursor only
1094
+ glimpse init --claude --cursor # both
907
1095
  glimpse init --force
908
1096
  """
909
1097
  root = Path(repo) if repo else Path.cwd()
910
1098
 
911
- targets: list[tuple[Path, str]] = [
912
- (root / ".claude" / "commands", "Claude Code"),
913
- ]
1099
+ # Default to Claude Code if neither flag is specified.
1100
+ if not cursor and not claude:
1101
+ claude = True
1102
+
1103
+ targets: list[tuple[Path, str]] = []
1104
+ if claude:
1105
+ targets.append((root / ".claude" / "commands", "Claude Code"))
914
1106
  if cursor:
915
1107
  targets.append((root / ".cursor" / "commands", "Cursor"))
916
1108
 
@@ -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. If total line changes < 20 AND any gap was ≥ 2 h: floor at 30 min
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
- 5. If total line changes > 200: multiply by 1.2× (complex work).
34
- 6. Clamp to a minimum of 15 minutes.
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: debugging floor.
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 5: complexity multiplier.
62
- if total_lines > _COMPLEXITY_THRESHOLD_LINES:
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
- branch_map = _get_branch_map(git, cwd)
291
- fallback_branch = _get_current_branch(git, cwd)
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 BaseLLMProvider, _warn, validate_llm_output
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 BaseLLMProvider, _warn, validate_llm_output
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 BaseLLMProvider, _warn, validate_llm_output
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 BaseLLMProvider, _warn, validate_llm_output
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.6"
File without changes