git2report 0.1.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.
- git2report/__init__.py +2 -0
- git2report/__main__.py +4 -0
- git2report/branches.py +34 -0
- git2report/cache.py +78 -0
- git2report/charts.py +155 -0
- git2report/chunker.py +47 -0
- git2report/cli.py +173 -0
- git2report/clustering.py +56 -0
- git2report/config.py +28 -0
- git2report/eval_tools.py +123 -0
- git2report/extractor.py +37 -0
- git2report/filtering.py +34 -0
- git2report/metrics.py +111 -0
- git2report/models.py +45 -0
- git2report/pipeline.py +188 -0
- git2report/report.py +884 -0
- git2report/rules.py +71 -0
- git2report/summarizer.py +359 -0
- git2report/timeutil.py +19 -0
- git2report/web.py +1207 -0
- git2report-0.1.0.dist-info/METADATA +142 -0
- git2report-0.1.0.dist-info/RECORD +26 -0
- git2report-0.1.0.dist-info/WHEEL +5 -0
- git2report-0.1.0.dist-info/entry_points.txt +4 -0
- git2report-0.1.0.dist-info/licenses/LICENSE +21 -0
- git2report-0.1.0.dist-info/top_level.txt +1 -0
git2report/__init__.py
ADDED
git2report/__main__.py
ADDED
git2report/branches.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Open branches = work that exists but is not merged yet. Used for the 'In progress' section."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from git2report.models import BranchInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(repo: Path, *args: str) -> str:
|
|
12
|
+
r = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
13
|
+
if r.returncode != 0:
|
|
14
|
+
raise RuntimeError(r.stderr.strip())
|
|
15
|
+
return r.stdout
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def open_branches(repo: Path, since: datetime, base: str = "HEAD") -> list[BranchInfo]:
|
|
19
|
+
"""Branches not merged into `base` with activity since `since`. Never raises: returns [] on any problem."""
|
|
20
|
+
try:
|
|
21
|
+
fmt = "%(refname:short)|%(committerdate:short)|%(subject)"
|
|
22
|
+
out = _git(Path(repo), "for-each-ref", f"--no-merged={base}", f"--format={fmt}", "refs/heads", "refs/remotes")
|
|
23
|
+
found: dict[str, BranchInfo] = {}
|
|
24
|
+
for line in out.splitlines():
|
|
25
|
+
name, date, subject = line.split("|", 2)
|
|
26
|
+
short = name.split("/", 1)[1] if name.startswith("origin/") else name
|
|
27
|
+
if name == "origin" or short == "HEAD" or short in found or date < since.date().isoformat():
|
|
28
|
+
continue
|
|
29
|
+
ahead = int(_git(Path(repo), "rev-list", "--count", f"{base}..{name}").strip() or 0)
|
|
30
|
+
if ahead:
|
|
31
|
+
found[short] = BranchInfo(short, date, ahead, subject)
|
|
32
|
+
return sorted(found.values(), key=lambda b: b.last_date, reverse=True)
|
|
33
|
+
except Exception:
|
|
34
|
+
return []
|
git2report/cache.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Smart summary cache for Git2Report.
|
|
2
|
+
|
|
3
|
+
Caches AI module summaries and executive summaries by commit hashes,
|
|
4
|
+
preventing redundant LLM calls and enabling sub-second re-runs.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("git2report.cache")
|
|
15
|
+
DEFAULT_CACHE_FILE = Path(".git2report_cache.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_cache_key(prefix: str, module: str, commit_hashes: list[str], audience: str, model: str) -> str:
|
|
19
|
+
"""Create a deterministic SHA-256 cache key based on inputs."""
|
|
20
|
+
sorted_hashes = sorted(commit_hashes)
|
|
21
|
+
payload = f"{prefix}::{module}::{','.join(sorted_hashes)}::{audience}::{model}"
|
|
22
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SummaryCache:
|
|
26
|
+
def __init__(self, cache_file: Path | str = DEFAULT_CACHE_FILE, enabled: bool = True):
|
|
27
|
+
self.cache_file = Path(cache_file)
|
|
28
|
+
self.enabled = enabled
|
|
29
|
+
self._data: dict[str, dict[str, Any]] = {}
|
|
30
|
+
self._load()
|
|
31
|
+
|
|
32
|
+
def _load(self) -> None:
|
|
33
|
+
if not self.enabled or not self.cache_file.exists():
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
raw = self.cache_file.read_text(encoding="utf-8")
|
|
37
|
+
self._data = json.loads(raw)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
logger.debug(f"Failed to load cache: {exc}")
|
|
40
|
+
self._data = {}
|
|
41
|
+
|
|
42
|
+
def _save(self) -> None:
|
|
43
|
+
if not self.enabled:
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
self.cache_file.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
logger.debug(f"Failed to save cache: {exc}")
|
|
49
|
+
|
|
50
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
51
|
+
if not self.enabled:
|
|
52
|
+
return None
|
|
53
|
+
return self._data.get(key)
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, summary: str, mode: str = "ai", meta: dict[str, Any] | None = None) -> None:
|
|
56
|
+
if not self.enabled:
|
|
57
|
+
return
|
|
58
|
+
entry = {
|
|
59
|
+
"summary": summary,
|
|
60
|
+
"mode": mode,
|
|
61
|
+
"meta": meta or {},
|
|
62
|
+
}
|
|
63
|
+
self._data[key] = entry
|
|
64
|
+
self._save()
|
|
65
|
+
|
|
66
|
+
def clear(self) -> int:
|
|
67
|
+
count = len(self._data)
|
|
68
|
+
self._data = {}
|
|
69
|
+
if self.cache_file.exists():
|
|
70
|
+
try:
|
|
71
|
+
self.cache_file.unlink()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return count
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Global default cache instance
|
|
78
|
+
default_cache = SummaryCache()
|
git2report/charts.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Visual analytics and chart generators for Git2Report.
|
|
2
|
+
|
|
3
|
+
Renders vector drawings for ReportLab PDF and embedded responsive SVG/HTML charts
|
|
4
|
+
without requiring external heavy chart dependencies.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from reportlab.lib import colors
|
|
12
|
+
from reportlab.graphics.shapes import Drawing, Group, Line, Rect, String
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TYPE_COLORS = {
|
|
16
|
+
"feat": colors.HexColor("#059669"), # Emerald Green
|
|
17
|
+
"fix": colors.HexColor("#DC2626"), # Red
|
|
18
|
+
"perf": colors.HexColor("#7C3AED"), # Purple
|
|
19
|
+
"refactor": colors.HexColor("#D97706"), # Amber
|
|
20
|
+
"docs": colors.HexColor("#0284C7"), # Cyan
|
|
21
|
+
"test": colors.HexColor("#0D9488"), # Teal
|
|
22
|
+
"style": colors.HexColor("#64748B"), # Slate
|
|
23
|
+
"chore": colors.HexColor("#94A3B8"), # Light Slate
|
|
24
|
+
"other": colors.HexColor("#3B82F6"), # Blue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_type_breakdown_bar(commits_by_type: dict[str, int], width: float = 490, height: float = 38) -> Drawing:
|
|
29
|
+
"""Creates a sleek, segmented horizontal bar drawing showing commit type percentages."""
|
|
30
|
+
d = Drawing(width, height)
|
|
31
|
+
total = sum(commits_by_type.values())
|
|
32
|
+
if total <= 0:
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
bar_y = 16
|
|
36
|
+
bar_h = 14
|
|
37
|
+
cur_x = 0.0
|
|
38
|
+
|
|
39
|
+
# Draw segments
|
|
40
|
+
for ctype, count in commits_by_type.items():
|
|
41
|
+
if count <= 0:
|
|
42
|
+
continue
|
|
43
|
+
seg_w = (count / total) * width
|
|
44
|
+
color = TYPE_COLORS.get(ctype.lower(), TYPE_COLORS["other"])
|
|
45
|
+
d.add(Rect(cur_x, bar_y, seg_w, bar_h, fillColor=color, strokeColor=colors.white, strokeWidth=1))
|
|
46
|
+
|
|
47
|
+
# Label percentage inside segment if wide enough
|
|
48
|
+
if seg_w > 26:
|
|
49
|
+
pct_text = f"{int(round((count / total) * 100))}%"
|
|
50
|
+
d.add(String(cur_x + seg_w / 2, bar_y + 3.5, pct_text, textAnchor="middle",
|
|
51
|
+
fontName="Helvetica-Bold", fontSize=7.5, fillColor=colors.white))
|
|
52
|
+
cur_x += seg_w
|
|
53
|
+
|
|
54
|
+
# Draw Legend below the bar
|
|
55
|
+
leg_x = 0.0
|
|
56
|
+
for ctype, count in list(commits_by_type.items())[:6]:
|
|
57
|
+
if count <= 0:
|
|
58
|
+
continue
|
|
59
|
+
color = TYPE_COLORS.get(ctype.lower(), TYPE_COLORS["other"])
|
|
60
|
+
# Dot
|
|
61
|
+
d.add(Rect(leg_x, 3, 7, 7, fillColor=color, strokeColor=color, rx=1.5, ry=1.5))
|
|
62
|
+
label = f"{ctype.upper()} ({count})"
|
|
63
|
+
d.add(String(leg_x + 10, 3, label, fontName="Helvetica", fontSize=7, fillColor=colors.HexColor("#475569")))
|
|
64
|
+
leg_x += len(label) * 4.4 + 16
|
|
65
|
+
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_module_churn_chart(sections: list[Any], width: float = 490, height: float = 85) -> Drawing:
|
|
70
|
+
"""Creates a horizontal vector bar chart showing code churn (+added / -deleted) per module."""
|
|
71
|
+
d = Drawing(width, height)
|
|
72
|
+
if not sections:
|
|
73
|
+
return d
|
|
74
|
+
|
|
75
|
+
# Collect up to 4 top modules by line changes
|
|
76
|
+
module_data = []
|
|
77
|
+
for s in sections[:4]:
|
|
78
|
+
added = sum(getattr(cm, "added", 0) for cm in getattr(s, "commits", []))
|
|
79
|
+
deleted = sum(getattr(cm, "deleted", 0) for cm in getattr(s, "commits", []))
|
|
80
|
+
module_data.append((s.module, added, deleted))
|
|
81
|
+
|
|
82
|
+
if not module_data:
|
|
83
|
+
return d
|
|
84
|
+
|
|
85
|
+
max_val = max(max(a, d_) for _, a, d_ in module_data) or 1
|
|
86
|
+
row_h = 16
|
|
87
|
+
start_y = height - 12
|
|
88
|
+
label_w = 90
|
|
89
|
+
bar_max_w = width - label_w - 45
|
|
90
|
+
|
|
91
|
+
green = colors.HexColor("#059669")
|
|
92
|
+
red = colors.HexColor("#DC2626")
|
|
93
|
+
|
|
94
|
+
for i, (mod, added, deleted) in enumerate(module_data):
|
|
95
|
+
y = start_y - (i * (row_h + 3))
|
|
96
|
+
|
|
97
|
+
# Module Name label
|
|
98
|
+
mod_name = mod[:14] + ".." if len(mod) > 16 else mod
|
|
99
|
+
d.add(String(0, y + 2, mod_name, fontName="Helvetica-Bold", fontSize=7.5, fillColor=colors.HexColor("#1E293B")))
|
|
100
|
+
|
|
101
|
+
# Added bar (Green)
|
|
102
|
+
w_add = max(2.0, (added / max_val) * (bar_max_w / 2)) if added > 0 else 0
|
|
103
|
+
if w_add > 0:
|
|
104
|
+
d.add(Rect(label_w, y, w_add, 7, fillColor=green, strokeColor=green, rx=1, ry=1))
|
|
105
|
+
|
|
106
|
+
# Deleted bar (Red)
|
|
107
|
+
w_del = max(2.0, (deleted / max_val) * (bar_max_w / 2)) if deleted > 0 else 0
|
|
108
|
+
if w_del > 0:
|
|
109
|
+
d.add(Rect(label_w + w_add + 4, y, w_del, 7, fillColor=red, strokeColor=red, rx=1, ry=1))
|
|
110
|
+
|
|
111
|
+
# Stat text
|
|
112
|
+
stat_text = f"+{added} / -{deleted}"
|
|
113
|
+
d.add(String(label_w + w_add + w_del + 10, y + 1, stat_text, fontName="Courier", fontSize=6.5, fillColor=colors.HexColor("#64748B")))
|
|
114
|
+
|
|
115
|
+
return d
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_svg_churn_chart(sections: list[Any]) -> str:
|
|
119
|
+
"""Generates an embedded responsive SVG for the interactive HTML report dashboard."""
|
|
120
|
+
module_data = []
|
|
121
|
+
for s in sections[:5]:
|
|
122
|
+
added = sum(getattr(cm, "added", 0) for cm in getattr(s, "commits", []))
|
|
123
|
+
deleted = sum(getattr(cm, "deleted", 0) for cm in getattr(s, "commits", []))
|
|
124
|
+
module_data.append((s.module, added, deleted))
|
|
125
|
+
|
|
126
|
+
if not module_data:
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
max_val = max(max(a, d_) for _, a, d_ in module_data) or 1
|
|
130
|
+
svg_height = len(module_data) * 36 + 20
|
|
131
|
+
rows = []
|
|
132
|
+
|
|
133
|
+
for i, (mod, added, deleted) in enumerate(module_data):
|
|
134
|
+
y = 20 + i * 36
|
|
135
|
+
w_add = (added / max_val) * 220 if added > 0 else 0
|
|
136
|
+
w_del = (deleted / max_val) * 220 if deleted > 0 else 0
|
|
137
|
+
|
|
138
|
+
rows.append(f"""
|
|
139
|
+
<g class="chart-row">
|
|
140
|
+
<text x="0" y="{y + 14}" fill="currentColor" font-size="12" font-weight="600">{mod}</text>
|
|
141
|
+
<rect x="130" y="{y}" width="{w_add:.1f}" height="16" rx="3" fill="#10b981" opacity="0.9">
|
|
142
|
+
<title>Added: +{added} lines</title>
|
|
143
|
+
</rect>
|
|
144
|
+
<rect x="{135 + w_add:.1f}" y="{y}" width="{w_del:.1f}" height="16" rx="3" fill="#ef4444" opacity="0.9">
|
|
145
|
+
<title>Deleted: -{deleted} lines</title>
|
|
146
|
+
</rect>
|
|
147
|
+
<text x="{145 + w_add + w_del:.1f}" y="{y + 13}" fill="var(--text-muted)" font-size="11" font-family="monospace">+{added} / -{deleted}</text>
|
|
148
|
+
</g>
|
|
149
|
+
""")
|
|
150
|
+
|
|
151
|
+
return f"""
|
|
152
|
+
<svg viewBox="0 0 650 {svg_height}" width="100%" height="{svg_height}" xmlns="http://www.w3.org/2000/svg" style="overflow: visible;">
|
|
153
|
+
{''.join(rows)}
|
|
154
|
+
</svg>
|
|
155
|
+
"""
|
git2report/chunker.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""STEP 5: shrink a module's code changes to fit the AI's limit.
|
|
2
|
+
Drop junk files, rank files (source before tests, big before small), split into hunks,
|
|
3
|
+
keep only the added/removed lines, and stop when the token budget is full."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from git2report.config import Config
|
|
10
|
+
from git2report.models import CommitRecord
|
|
11
|
+
from git2report.rules import is_junk, is_test, tokens
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Chunk:
|
|
16
|
+
text: str
|
|
17
|
+
raw_tokens: int
|
|
18
|
+
used_tokens: int
|
|
19
|
+
hunks_dropped: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def chunk_module(commits: list[CommitRecord], cfg: Config, budget: int | None = None) -> Chunk:
|
|
23
|
+
budget = cfg.token_budget if budget is None else budget
|
|
24
|
+
all_files = [f for c in commits for f in c.files]
|
|
25
|
+
raw = sum(tokens(f.diff) for f in all_files)
|
|
26
|
+
files = [f for f in all_files if not is_junk(f.path, cfg.junk) and f.diff.strip()]
|
|
27
|
+
files.sort(key=lambda f: (is_test(f.path), -(f.added + f.deleted)))
|
|
28
|
+
parts: list[str] = []
|
|
29
|
+
used = dropped = 0
|
|
30
|
+
for f in files:
|
|
31
|
+
for hunk in re.split(r"(?m)^(?=@@)", f.diff):
|
|
32
|
+
if not hunk.strip():
|
|
33
|
+
continue
|
|
34
|
+
body = "\n".join(
|
|
35
|
+
line for line in hunk.splitlines()
|
|
36
|
+
if line[:1] in ("+", "-") and not line.startswith(("+++", "---"))
|
|
37
|
+
)
|
|
38
|
+
if not body.strip():
|
|
39
|
+
continue
|
|
40
|
+
piece = f"# {f.path}\n{body}"
|
|
41
|
+
cost = tokens(piece)
|
|
42
|
+
if used + cost <= budget:
|
|
43
|
+
parts.append(piece)
|
|
44
|
+
used += cost
|
|
45
|
+
else:
|
|
46
|
+
dropped += 1
|
|
47
|
+
return Chunk("\n".join(parts), raw, used, dropped)
|
git2report/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Command line: git2report --repo . --since 7d --audience guide --out report.pdf"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from git2report import __version__
|
|
12
|
+
from git2report.models import GitReportError
|
|
13
|
+
from git2report.pipeline import Options, run_pipeline
|
|
14
|
+
from git2report.report import render_html, render_markdown, render_pdf
|
|
15
|
+
from git2report.summarizer import get_summarizer
|
|
16
|
+
from git2report.timeutil import parse_since
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _preprocess_argv(argv: list[str]) -> list[str]:
|
|
20
|
+
result = []
|
|
21
|
+
i = 0
|
|
22
|
+
while i < len(argv):
|
|
23
|
+
arg = argv[i]
|
|
24
|
+
if arg == "--ai":
|
|
25
|
+
result.extend(["--llm", "gemini"])
|
|
26
|
+
i += 1
|
|
27
|
+
continue
|
|
28
|
+
if arg == "--ollama":
|
|
29
|
+
result.extend(["--llm", "ollama"])
|
|
30
|
+
i += 1
|
|
31
|
+
continue
|
|
32
|
+
if arg == "--llm":
|
|
33
|
+
if i + 1 < len(argv) and not argv[i + 1].startswith("-") and argv[i + 1].lower() in ("gemini", "ollama", "offline"):
|
|
34
|
+
result.extend(["--llm", argv[i + 1].lower()])
|
|
35
|
+
i += 2
|
|
36
|
+
continue
|
|
37
|
+
# When --llm is passed alone, default to gemini
|
|
38
|
+
result.extend(["--llm", "gemini"])
|
|
39
|
+
i += 1
|
|
40
|
+
continue
|
|
41
|
+
result.append(arg)
|
|
42
|
+
i += 1
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if len(sys.argv) > 1:
|
|
47
|
+
sys.argv = [sys.argv[0]] + _preprocess_argv(sys.argv[1:])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AppWrapper(typer.Typer):
|
|
51
|
+
def __call__(self, *args, **kwargs):
|
|
52
|
+
if not args and not kwargs and len(sys.argv) > 1:
|
|
53
|
+
sys.argv = [sys.argv[0]] + _preprocess_argv(sys.argv[1:])
|
|
54
|
+
return super().__call__(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
app = AppWrapper(add_completion=False, help="Turn Git history into a plain-language, evidence-linked progress report.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Audience(str, Enum):
|
|
61
|
+
guide = "guide"
|
|
62
|
+
client = "client"
|
|
63
|
+
team = "team"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Llm(str, Enum):
|
|
67
|
+
offline = "offline"
|
|
68
|
+
gemini = "gemini"
|
|
69
|
+
ollama = "ollama"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _version(value: bool):
|
|
73
|
+
if value:
|
|
74
|
+
typer.echo(f"git2report {__version__}")
|
|
75
|
+
raise typer.Exit()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def main(
|
|
80
|
+
repo: str = typer.Option(".", "--repo", "-r", help="Path to the Git repository or remote Git URL."),
|
|
81
|
+
since: str = typer.Option("7d", "--since", "-s", help="How far back: 7d, 2w, 12h or a date like 2026-10-01."),
|
|
82
|
+
audience: Audience = typer.Option(Audience.guide, "--audience", "-a", help="Who will read the report."),
|
|
83
|
+
llm: Llm = typer.Option(Llm.offline, "--llm", help="'offline' uses commit messages; 'gemini' uses Google Gemini; 'ollama' uses local Ollama."),
|
|
84
|
+
model: str = typer.Option("", "--model", help="AI model name (default: gemini-2.5-flash for gemini, llama3:8b for ollama)."),
|
|
85
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="GEMINI_API_KEY", help="API key for Gemini (or set GEMINI_API_KEY env var)."),
|
|
86
|
+
out: Path = typer.Option(Path("report.pdf"), "--out", "-o", help="Output file: .pdf, .md, or .html"),
|
|
87
|
+
both: bool = typer.Option(False, "--both", help="Write both the PDF and the Markdown file."),
|
|
88
|
+
html: bool = typer.Option(False, "--html", help="Write an interactive HTML report dashboard."),
|
|
89
|
+
all_formats: bool = typer.Option(False, "--all", help="Write PDF, Markdown, and HTML reports."),
|
|
90
|
+
planned: list[str] = typer.Option([], "--next", "-n", help="A planned goal for next period (repeat the option for several)."),
|
|
91
|
+
branches: bool = typer.Option(True, "--branches/--no-branches", help="List unmerged branches as 'in progress'."),
|
|
92
|
+
artifacts: Optional[Path] = typer.Option(None, "--artifacts", help="Folder to save every step's result (JSON/text)."),
|
|
93
|
+
ui: bool = typer.Option(False, "--ui", help="Launch the interactive Web Studio GUI in your browser."),
|
|
94
|
+
port: int = typer.Option(5000, "--port", help="Port to run the Web Studio GUI on."),
|
|
95
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Disable the smart summary cache."),
|
|
96
|
+
clear_cache: bool = typer.Option(False, "--clear-cache", help="Clear the smart summary cache."),
|
|
97
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Hide progress messages."),
|
|
98
|
+
version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version."),
|
|
99
|
+
):
|
|
100
|
+
if clear_cache:
|
|
101
|
+
from git2report.cache import default_cache
|
|
102
|
+
count = default_cache.clear()
|
|
103
|
+
typer.echo(f"Cleared {count} entries from smart summary cache.")
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
if ui:
|
|
107
|
+
from git2report.web import launch_web_server
|
|
108
|
+
launch_web_server(port=port)
|
|
109
|
+
raise typer.Exit()
|
|
110
|
+
|
|
111
|
+
if no_cache:
|
|
112
|
+
from git2report.cache import default_cache
|
|
113
|
+
default_cache.enabled = False
|
|
114
|
+
|
|
115
|
+
log = (lambda _m: None) if quiet else (lambda m: typer.echo(m, err=True))
|
|
116
|
+
try:
|
|
117
|
+
raw_repo = str(repo).strip().replace("\\", "/")
|
|
118
|
+
if raw_repo.startswith("https:/") and not raw_repo.startswith("https://"):
|
|
119
|
+
raw_repo = "https://" + raw_repo[len("https:/"):]
|
|
120
|
+
elif raw_repo.startswith("http:/") and not raw_repo.startswith("http://"):
|
|
121
|
+
raw_repo = "http://" + raw_repo[len("http:/"):]
|
|
122
|
+
|
|
123
|
+
if raw_repo.startswith(("http://", "https://", "git@")):
|
|
124
|
+
repo_name = raw_repo.rstrip("/").split("/")[-1]
|
|
125
|
+
if repo_name.endswith(".git"):
|
|
126
|
+
repo_name = repo_name[:-4]
|
|
127
|
+
local_dir = Path("cloned_repos") / repo_name
|
|
128
|
+
local_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
if not (local_dir / ".git").exists():
|
|
130
|
+
log(f"Cloning remote repository {raw_repo} into {local_dir}...")
|
|
131
|
+
import subprocess
|
|
132
|
+
r = subprocess.run(["git", "clone", raw_repo, str(local_dir)], capture_output=True, text=True)
|
|
133
|
+
if r.returncode != 0:
|
|
134
|
+
raise GitReportError(f"Failed to clone repository: {r.stderr.strip()}")
|
|
135
|
+
repo_path = local_dir
|
|
136
|
+
else:
|
|
137
|
+
repo_path = Path(repo)
|
|
138
|
+
|
|
139
|
+
chosen_model = model or ("gemini-2.5-flash" if llm == Llm.gemini else ("llama3:8b" if llm == Llm.ollama else ""))
|
|
140
|
+
opts = Options(repo=repo_path, since=parse_since(since), audience=audience.value, artifacts=artifacts,
|
|
141
|
+
planned=planned, include_branches=branches)
|
|
142
|
+
data = run_pipeline(opts, get_summarizer(llm.value, chosen_model, api_key=api_key), log=log)
|
|
143
|
+
written = []
|
|
144
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
is_html = out.suffix.lower() == ".html"
|
|
146
|
+
is_md = out.suffix.lower() == ".md"
|
|
147
|
+
is_pdf = out.suffix.lower() == ".pdf" or (not is_html and not is_md)
|
|
148
|
+
|
|
149
|
+
if all_formats or both or is_md:
|
|
150
|
+
md_path = out.with_suffix(".md")
|
|
151
|
+
md_path.write_text(render_markdown(data), encoding="utf-8")
|
|
152
|
+
written.append(md_path)
|
|
153
|
+
|
|
154
|
+
if all_formats or html or is_html:
|
|
155
|
+
html_path = out.with_suffix(".html")
|
|
156
|
+
html_path.write_text(render_html(data), encoding="utf-8")
|
|
157
|
+
written.append(html_path)
|
|
158
|
+
|
|
159
|
+
if all_formats or both or is_pdf:
|
|
160
|
+
pdf_path = out if out.suffix.lower() == ".pdf" else out.with_suffix(".pdf")
|
|
161
|
+
render_pdf(data, pdf_path)
|
|
162
|
+
written.append(pdf_path)
|
|
163
|
+
except GitReportError as exc:
|
|
164
|
+
typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
|
|
165
|
+
raise typer.Exit(code=1)
|
|
166
|
+
for p in written:
|
|
167
|
+
typer.echo(f"Wrote {p}")
|
|
168
|
+
if llm == Llm.offline:
|
|
169
|
+
typer.echo("Note: offline mode used (no AI). Add --llm gemini for AI-written plain-language sentences.", err=True)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
app()
|
git2report/clustering.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""STEP 3: group commits by module using three rules, in this order:
|
|
2
|
+
1. the scope in the message, e.g. feat(auth): ...
|
|
3
|
+
2. the folder with the most changed lines
|
|
4
|
+
3. the commit type, e.g. docs: ...
|
|
5
|
+
Every commit also records WHY it landed in its module, so each decision can be explained."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from git2report.config import Config
|
|
9
|
+
from git2report.models import CommitRecord
|
|
10
|
+
from git2report.rules import is_junk, is_test, parse_conventional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pretty(name: str, cfg: Config) -> str:
|
|
14
|
+
key = name.strip().lower()
|
|
15
|
+
if key in cfg.aliases:
|
|
16
|
+
return cfg.aliases[key]
|
|
17
|
+
return name.replace("-", " ").replace("_", " ").strip().title()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _top_folder(commit: CommitRecord, cfg: Config) -> str | None:
|
|
21
|
+
"""Folder with the most changed lines. Test files are ignored unless the commit touched nothing else,
|
|
22
|
+
otherwise a commit that changes source AND tests would be filed under 'Tests'."""
|
|
23
|
+
def totals(include_tests: bool) -> dict[str, int]:
|
|
24
|
+
out: dict[str, int] = {}
|
|
25
|
+
for f in commit.files:
|
|
26
|
+
if is_junk(f.path, cfg.junk) or (is_test(f.path) and not include_tests):
|
|
27
|
+
continue
|
|
28
|
+
parts = f.path.replace("\\", "/").split("/")[:-1] # folders only
|
|
29
|
+
parts = [p for p in parts if p.lower() not in cfg.generic_folders]
|
|
30
|
+
if parts:
|
|
31
|
+
out[parts[0]] = out.get(parts[0], 0) + f.added + f.deleted
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
t = totals(False) or totals(True)
|
|
35
|
+
return max(t, key=t.get) if t else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def module_of(commit: CommitRecord, cfg: Config) -> tuple[str, str]:
|
|
39
|
+
ctype, scope, _ = parse_conventional(commit.subject)
|
|
40
|
+
if scope:
|
|
41
|
+
return _pretty(scope, cfg), "scope in message"
|
|
42
|
+
folder = _top_folder(commit, cfg)
|
|
43
|
+
if folder:
|
|
44
|
+
return _pretty(folder, cfg), "top folder by lines"
|
|
45
|
+
if ctype and ctype in cfg.type_modules:
|
|
46
|
+
return cfg.type_modules[ctype], "commit type"
|
|
47
|
+
return "Other", "fallback"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cluster(commits: list[CommitRecord], cfg: Config) -> dict[str, list[CommitRecord]]:
|
|
51
|
+
groups: dict[str, list[CommitRecord]] = {}
|
|
52
|
+
for c in commits:
|
|
53
|
+
c.module, c.module_reason = module_of(c, cfg)
|
|
54
|
+
groups.setdefault(c.module, []).append(c)
|
|
55
|
+
# Biggest modules first; ties by name so the output is stable.
|
|
56
|
+
return dict(sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0])))
|
git2report/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tunable rules in one place. Change these instead of hunting through the code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
# Files that are generated by tools and say nothing about the developer's effort.
|
|
10
|
+
junk: tuple[str, ...] = (
|
|
11
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Pipfile.lock",
|
|
12
|
+
".min.js", ".min.css", "dist/", "build/", "node_modules/",
|
|
13
|
+
)
|
|
14
|
+
# Commits whose subject has one of these words are noise, unless they changed a lot.
|
|
15
|
+
noise_words: tuple[str, ...] = ("wip", "typo", "typos", "formatting", "whitespace")
|
|
16
|
+
keep_if_lines: int = 30 # a noisy commit with >= this many changed lines is kept
|
|
17
|
+
token_budget: int = 1500 # max tokens of code sent to the AI per module
|
|
18
|
+
# Readable module names for common commit scopes / folders.
|
|
19
|
+
aliases: dict[str, str] = field(default_factory=lambda: {
|
|
20
|
+
"ui": "UI", "db": "Database", "auth": "Authentication", "jobs": "Job Search",
|
|
21
|
+
"api": "API", "ci": "CI/CD", "docs": "Documentation", "deps": "Dependencies",
|
|
22
|
+
})
|
|
23
|
+
# Used when a commit has no scope and no useful folder: map the commit TYPE to a module.
|
|
24
|
+
type_modules: dict[str, str] = field(default_factory=lambda: {
|
|
25
|
+
"docs": "Documentation", "ci": "CI/CD", "build": "Build", "test": "Testing", "chore": "Maintenance",
|
|
26
|
+
})
|
|
27
|
+
# Folders that carry no meaning as a module name.
|
|
28
|
+
generic_folders: tuple[str, ...] = ("src", "lib", "app", "source", "packages")
|