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/eval_tools.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Evaluation helpers: make a sheet to fill in by hand, then score it.
|
|
2
|
+
|
|
3
|
+
git2report-eval sheet --repo ./proj --since 45d --n 25 --out eval.csv
|
|
4
|
+
(fill in the empty columns in Excel: manual summary + 1-5 scores)
|
|
5
|
+
git2report-eval score eval.csv
|
|
6
|
+
|
|
7
|
+
Per sampled commit the sheet holds: the TOOL sentence (our pipeline) and a BASELINE sentence
|
|
8
|
+
(what you get by pasting the raw commit to an AI with no pipeline; offline mode: the raw commit message)."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from git2report.chunker import chunk_module
|
|
18
|
+
from git2report.clustering import cluster
|
|
19
|
+
from git2report.config import Config
|
|
20
|
+
from git2report.extractor import extract
|
|
21
|
+
from git2report.filtering import filter_commits
|
|
22
|
+
from git2report.models import GitReportError
|
|
23
|
+
from git2report.summarizer import SummaryRequest, get_summarizer
|
|
24
|
+
from git2report.timeutil import parse_since
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(add_completion=False, help="Build and score the manual-vs-tool evaluation sheet.")
|
|
27
|
+
|
|
28
|
+
COLUMNS = ["hash", "message", "module", "tool_sentence", "tool_mode", "baseline_sentence",
|
|
29
|
+
"manual_summary", "tool_accuracy_1to5", "tool_readability_1to5", "tool_unsupported_claims",
|
|
30
|
+
"baseline_accuracy_1to5", "baseline_readability_1to5", "baseline_unsupported_claims", "notes"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pick_evenly(items: list, n: int) -> list:
|
|
34
|
+
"""Deterministic, evenly spread sample (so the sheet can be rebuilt identically)."""
|
|
35
|
+
if n >= len(items):
|
|
36
|
+
return list(items)
|
|
37
|
+
step = len(items) / n
|
|
38
|
+
return [items[int(i * step)] for i in range(n)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def baseline_sentence(commit, summarizer) -> str:
|
|
42
|
+
if hasattr(summarizer, "ask"):
|
|
43
|
+
diff = "\n".join(f.diff for f in commit.files)[:1500]
|
|
44
|
+
prompt = f"Explain this git commit to a non-technical person.\nMessage: {commit.subject}\nChanges:\n{diff}"
|
|
45
|
+
try:
|
|
46
|
+
return " ".join(summarizer.ask(prompt).split())
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
return f"[AI unavailable: {type(exc).__name__}] {commit.subject}"
|
|
49
|
+
return commit.subject # offline baseline = the raw commit message
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def sheet(
|
|
54
|
+
repo: Path = typer.Option(Path("."), "--repo", "-r"),
|
|
55
|
+
since: str = typer.Option("45d", "--since", "-s"),
|
|
56
|
+
n: int = typer.Option(25, "--n", help="How many commits to sample."),
|
|
57
|
+
audience: str = typer.Option("guide", "--audience", "-a"),
|
|
58
|
+
llm: str = typer.Option("offline", "--llm", help="offline or ollama"),
|
|
59
|
+
model: str = typer.Option("llama3:8b", "--model"),
|
|
60
|
+
out: Path = typer.Option(Path("eval.csv"), "--out", "-o"),
|
|
61
|
+
):
|
|
62
|
+
cfg = Config()
|
|
63
|
+
try:
|
|
64
|
+
commits = extract(repo, parse_since(since))
|
|
65
|
+
kept, _ = filter_commits(commits, cfg)
|
|
66
|
+
if not kept:
|
|
67
|
+
raise GitReportError("No usable commits in that window; try a longer --since.")
|
|
68
|
+
cluster(kept, cfg)
|
|
69
|
+
except GitReportError as exc:
|
|
70
|
+
typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
summarizer = get_summarizer(llm, model)
|
|
73
|
+
rows = []
|
|
74
|
+
for c in pick_evenly(kept, n):
|
|
75
|
+
res = summarizer.summarize(SummaryRequest(c.module, [c], chunk_module([c], cfg).text, audience))
|
|
76
|
+
rows.append({"hash": c.hash, "message": c.subject, "module": c.module, "tool_sentence": res.text,
|
|
77
|
+
"tool_mode": res.mode, "baseline_sentence": baseline_sentence(c, summarizer)})
|
|
78
|
+
with open(out, "w", newline="", encoding="utf-8-sig") as f: # utf-8-sig so Excel opens it correctly
|
|
79
|
+
w = csv.DictWriter(f, fieldnames=COLUMNS)
|
|
80
|
+
w.writeheader()
|
|
81
|
+
for r in rows:
|
|
82
|
+
w.writerow({k: r.get(k, "") for k in COLUMNS})
|
|
83
|
+
typer.echo(f"Wrote {out} with {len(rows)} commits. Fill in the empty columns, then run: git2report-eval score {out}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _nums(rows: list[dict], col: str) -> list[float]:
|
|
87
|
+
out = []
|
|
88
|
+
for r in rows:
|
|
89
|
+
try:
|
|
90
|
+
out.append(float((r.get(col) or "").strip()))
|
|
91
|
+
except ValueError:
|
|
92
|
+
continue
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _avg(xs: list[float]) -> str:
|
|
97
|
+
return f"{sum(xs) / len(xs):.2f}" if xs else "n/a"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def summarize_scores(rows: list[dict]) -> str:
|
|
101
|
+
lines = ["| Measure | Tool (our pipeline) | Baseline |", "|---|---|---|"]
|
|
102
|
+
for label, t, b in (("Accuracy (1-5, higher is better)", "tool_accuracy_1to5", "baseline_accuracy_1to5"),
|
|
103
|
+
("Readability (1-5, higher is better)", "tool_readability_1to5", "baseline_readability_1to5")):
|
|
104
|
+
tv, bv = _nums(rows, t), _nums(rows, b)
|
|
105
|
+
lines.append(f"| {label} | {_avg(tv)} (n={len(tv)}) | {_avg(bv)} (n={len(bv)}) |")
|
|
106
|
+
tu, bu = _nums(rows, "tool_unsupported_claims"), _nums(rows, "baseline_unsupported_claims")
|
|
107
|
+
lines.append(f"| Unsupported claims (total, lower is better) | {int(sum(tu))} over {len(tu)} rows | {int(sum(bu))} over {len(bu)} rows |")
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def score(sheet_path: Path = typer.Argument(..., help="The filled-in CSV"),
|
|
113
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Also save the table as Markdown")):
|
|
114
|
+
with open(sheet_path, newline="", encoding="utf-8-sig") as f:
|
|
115
|
+
rows = list(csv.DictReader(f))
|
|
116
|
+
table = summarize_scores(rows)
|
|
117
|
+
typer.echo(f"{len(rows)} rows read\n\n{table}")
|
|
118
|
+
if out:
|
|
119
|
+
out.write_text(table + "\n", encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
app()
|
git2report/extractor.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""STEP 1: read commits from a Git repository into plain records."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydriller import Repository
|
|
8
|
+
|
|
9
|
+
from git2report.models import CommitRecord, FileChange, GitReportError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract(repo_path: Path, since: datetime) -> list[CommitRecord]:
|
|
13
|
+
repo_path = Path(repo_path)
|
|
14
|
+
if not (repo_path / ".git").exists():
|
|
15
|
+
raise GitReportError(f"'{repo_path}' is not a Git repository (no .git folder found).")
|
|
16
|
+
records: list[CommitRecord] = []
|
|
17
|
+
try:
|
|
18
|
+
for c in Repository(str(repo_path), since=since).traverse_commits():
|
|
19
|
+
records.append(CommitRecord(
|
|
20
|
+
hash=c.hash[:7],
|
|
21
|
+
message=c.msg.strip(),
|
|
22
|
+
date=c.committer_date.date().isoformat(),
|
|
23
|
+
author=c.author.name,
|
|
24
|
+
is_merge=c.merge,
|
|
25
|
+
files=[
|
|
26
|
+
FileChange(
|
|
27
|
+
path=f.new_path or f.old_path or "",
|
|
28
|
+
added=f.added_lines,
|
|
29
|
+
deleted=f.deleted_lines,
|
|
30
|
+
diff=f.diff or "",
|
|
31
|
+
)
|
|
32
|
+
for f in c.modified_files
|
|
33
|
+
],
|
|
34
|
+
))
|
|
35
|
+
except Exception as exc: # PyDriller/GitPython errors
|
|
36
|
+
raise GitReportError(f"Could not read the repository: {exc}") from exc
|
|
37
|
+
return records
|
git2report/filtering.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""STEP 2: drop commits a reader does not care about (merges, typos, unfinished work)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from git2report.config import Config
|
|
8
|
+
from git2report.models import CommitRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Decision:
|
|
13
|
+
hash: str
|
|
14
|
+
subject: str
|
|
15
|
+
kept: bool
|
|
16
|
+
reason: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decide(commit: CommitRecord, cfg: Config) -> Decision:
|
|
20
|
+
subject = commit.subject
|
|
21
|
+
if commit.is_merge or subject.lower().startswith("merge "):
|
|
22
|
+
return Decision(commit.hash, subject, False, "merge commit")
|
|
23
|
+
words = "|".join(re.escape(w) for w in cfg.noise_words)
|
|
24
|
+
if re.search(rf"\b({words})\b", subject.lower()):
|
|
25
|
+
if commit.lines >= cfg.keep_if_lines:
|
|
26
|
+
return Decision(commit.hash, subject, True, "noise word, but big change: kept")
|
|
27
|
+
return Decision(commit.hash, subject, False, "noise word and small change")
|
|
28
|
+
return Decision(commit.hash, subject, True, "ok")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def filter_commits(commits: list[CommitRecord], cfg: Config) -> tuple[list[CommitRecord], list[Decision]]:
|
|
32
|
+
decisions = [decide(c, cfg) for c in commits]
|
|
33
|
+
kept = [c for c, d in zip(commits, decisions) if d.kept]
|
|
34
|
+
return kept, decisions
|
git2report/metrics.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""STEP 4: numbers, counted by code. The AI never produces or touches these."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from git2report.config import Config
|
|
5
|
+
from git2report.models import CommitRecord
|
|
6
|
+
from git2report.rules import commit_type, is_junk, is_test
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_metrics(commits: list[CommitRecord], groups: dict[str, list[CommitRecord]], cfg: Config,
|
|
10
|
+
total_commits_seen: int | None = None) -> dict:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
files = [f for c in commits for f in c.files if not is_junk(f.path, cfg.junk)]
|
|
14
|
+
test_lines = sum(f.added + f.deleted for f in files if is_test(f.path))
|
|
15
|
+
code_lines = sum(f.added + f.deleted for f in files if not is_test(f.path))
|
|
16
|
+
lines_added = sum(f.added for f in files)
|
|
17
|
+
lines_deleted = sum(f.deleted for f in files)
|
|
18
|
+
|
|
19
|
+
by_type: dict[str, int] = {}
|
|
20
|
+
for c in commits:
|
|
21
|
+
key = commit_type(c.subject) or "other"
|
|
22
|
+
by_type[key] = by_type.get(key, 0) + 1
|
|
23
|
+
top = max(groups, key=lambda g: (len(groups[g]), g)) if groups else ""
|
|
24
|
+
|
|
25
|
+
author_stats: dict[str, dict] = {}
|
|
26
|
+
for c in commits:
|
|
27
|
+
a = c.author or "Unknown"
|
|
28
|
+
if a not in author_stats:
|
|
29
|
+
author_stats[a] = {"name": a, "commits": 0, "added": 0, "deleted": 0, "modules": {}}
|
|
30
|
+
author_stats[a]["commits"] += 1
|
|
31
|
+
for f in c.files:
|
|
32
|
+
if not is_junk(f.path, cfg.junk):
|
|
33
|
+
author_stats[a]["added"] += f.added
|
|
34
|
+
author_stats[a]["deleted"] += f.deleted
|
|
35
|
+
if c.module:
|
|
36
|
+
author_stats[a]["modules"][c.module] = author_stats[a]["modules"].get(c.module, 0) + 1
|
|
37
|
+
|
|
38
|
+
contributors = []
|
|
39
|
+
for a, s in author_stats.items():
|
|
40
|
+
top_mod = max(s["modules"], key=s["modules"].get) if s["modules"] else top
|
|
41
|
+
contributors.append({
|
|
42
|
+
"name": a,
|
|
43
|
+
"commits": s["commits"],
|
|
44
|
+
"added": s["added"],
|
|
45
|
+
"deleted": s["deleted"],
|
|
46
|
+
"lines_changed": s["added"] + s["deleted"],
|
|
47
|
+
"top_module": top_mod,
|
|
48
|
+
})
|
|
49
|
+
contributors.sort(key=lambda x: (x["commits"], x["lines_changed"]), reverse=True)
|
|
50
|
+
|
|
51
|
+
total_lines = max(test_lines + code_lines, 1)
|
|
52
|
+
mod_lines: dict[str, int] = {}
|
|
53
|
+
for mod, cs in groups.items():
|
|
54
|
+
mod_files = [f for c in cs for f in c.files if not is_junk(f.path, cfg.junk)]
|
|
55
|
+
mod_lines[mod] = sum(f.added + f.deleted for f in mod_files)
|
|
56
|
+
|
|
57
|
+
module_distribution = {
|
|
58
|
+
mod: round((lines / total_lines) * 100, 1)
|
|
59
|
+
for mod, lines in sorted(mod_lines.items(), key=lambda kv: kv[1], reverse=True)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ext_stats: dict[str, dict] = {}
|
|
63
|
+
TECH_MAP = {
|
|
64
|
+
".jsx": "React (JSX)", ".tsx": "React (TSX)", ".js": "JavaScript", ".ts": "TypeScript",
|
|
65
|
+
".py": "Python", ".html": "HTML", ".css": "CSS", ".scss": "SCSS", ".json": "JSON / Config",
|
|
66
|
+
".md": "Documentation", ".sql": "SQL / Database", ".yml": "YAML / CI", ".yaml": "YAML / CI",
|
|
67
|
+
".go": "Go", ".rs": "Rust", ".java": "Java", ".sh": "Shell Script"
|
|
68
|
+
}
|
|
69
|
+
for f in files:
|
|
70
|
+
ext = Path(f.path).suffix.lower() or "other"
|
|
71
|
+
name = TECH_MAP.get(ext, ext.upper() if ext.startswith(".") else "Other")
|
|
72
|
+
if name not in ext_stats:
|
|
73
|
+
ext_stats[name] = {"tech": name, "count": 0, "lines": 0}
|
|
74
|
+
ext_stats[name]["count"] += 1
|
|
75
|
+
ext_stats[name]["lines"] += (f.added + f.deleted)
|
|
76
|
+
|
|
77
|
+
tech_stack = []
|
|
78
|
+
for t, s in sorted(ext_stats.items(), key=lambda x: x[1]["lines"], reverse=True)[:5]:
|
|
79
|
+
pct = round((s["lines"] / total_lines) * 100, 1)
|
|
80
|
+
if pct > 0:
|
|
81
|
+
tech_stack.append({"tech": t, "percentage": pct, "lines": s["lines"]})
|
|
82
|
+
|
|
83
|
+
# Codebase Hotspots: files touched by most commits
|
|
84
|
+
file_freq: dict[str, dict] = {}
|
|
85
|
+
for c in commits:
|
|
86
|
+
for f in c.files:
|
|
87
|
+
if not is_junk(f.path, cfg.junk) and f.path:
|
|
88
|
+
if f.path not in file_freq:
|
|
89
|
+
file_freq[f.path] = {"path": f.path, "commits": 0, "lines": 0}
|
|
90
|
+
file_freq[f.path]["commits"] += 1
|
|
91
|
+
file_freq[f.path]["lines"] += (f.added + f.deleted)
|
|
92
|
+
hotspots = sorted(file_freq.values(), key=lambda x: (x["commits"], x["lines"]), reverse=True)[:5]
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"commits": len(commits),
|
|
96
|
+
"commits_seen_before_filter": total_commits_seen if total_commits_seen is not None else len(commits),
|
|
97
|
+
"authors": len({c.author for c in commits}),
|
|
98
|
+
"lines_changed": test_lines + code_lines, # junk files excluded
|
|
99
|
+
"lines_changed_incl_junk": sum(c.lines for c in commits),
|
|
100
|
+
"lines_added": lines_added,
|
|
101
|
+
"lines_deleted": lines_deleted,
|
|
102
|
+
"net_lines": lines_added - lines_deleted,
|
|
103
|
+
"top_module": top,
|
|
104
|
+
"modules": len(groups),
|
|
105
|
+
"test_to_code_ratio": round(test_lines / max(code_lines, 1), 2),
|
|
106
|
+
"commits_by_type": dict(sorted(by_type.items())),
|
|
107
|
+
"contributors": contributors,
|
|
108
|
+
"module_distribution": module_distribution,
|
|
109
|
+
"tech_stack": tech_stack,
|
|
110
|
+
"hotspots": hotspots,
|
|
111
|
+
}
|
git2report/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Plain data containers shared by every pipeline step."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitReportError(Exception):
|
|
8
|
+
"""A problem the user can fix (bad path, no commits, ...). Shown without a traceback."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FileChange:
|
|
13
|
+
path: str
|
|
14
|
+
added: int
|
|
15
|
+
deleted: int
|
|
16
|
+
diff: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CommitRecord:
|
|
21
|
+
hash: str
|
|
22
|
+
message: str
|
|
23
|
+
date: str
|
|
24
|
+
author: str
|
|
25
|
+
is_merge: bool
|
|
26
|
+
files: list[FileChange] = field(default_factory=list)
|
|
27
|
+
module: str = ""
|
|
28
|
+
module_reason: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def subject(self) -> str:
|
|
32
|
+
text = self.message.strip()
|
|
33
|
+
return text.splitlines()[0] if text else ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def lines(self) -> int:
|
|
37
|
+
return sum(f.added + f.deleted for f in self.files)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BranchInfo:
|
|
42
|
+
name: str
|
|
43
|
+
last_date: str
|
|
44
|
+
ahead: int # commits on the branch that are not yet in the current branch
|
|
45
|
+
subject: str
|
git2report/pipeline.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Glue: run the 7 steps in order and (optionally) save every step's result as a file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from git2report.branches import open_branches
|
|
11
|
+
from git2report.chunker import chunk_module
|
|
12
|
+
from git2report.clustering import cluster
|
|
13
|
+
from git2report.config import Config
|
|
14
|
+
from git2report.extractor import extract
|
|
15
|
+
from git2report.filtering import filter_commits
|
|
16
|
+
from git2report.metrics import compute_metrics
|
|
17
|
+
from git2report.models import GitReportError
|
|
18
|
+
from git2report.report import CategorizedCommit, ReportData, Section
|
|
19
|
+
from git2report.rules import commit_type, is_junk
|
|
20
|
+
from git2report.summarizer import SummaryRequest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_remote_url(repo: Path) -> str | None:
|
|
24
|
+
import subprocess
|
|
25
|
+
try:
|
|
26
|
+
r = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=repo, capture_output=True, text=True)
|
|
27
|
+
url = r.stdout.strip()
|
|
28
|
+
if not url:
|
|
29
|
+
return None
|
|
30
|
+
if url.startswith("git@github.com:"):
|
|
31
|
+
url = "https://github.com/" + url[len("git@github.com:"):]
|
|
32
|
+
elif url.startswith("git@gitlab.com:"):
|
|
33
|
+
url = "https://gitlab.com/" + url[len("git@gitlab.com:"):]
|
|
34
|
+
if url.endswith(".git"):
|
|
35
|
+
url = url[:-4]
|
|
36
|
+
return url if url.startswith("http") else None
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Options:
|
|
43
|
+
repo: Path
|
|
44
|
+
since: datetime
|
|
45
|
+
audience: str = "guide"
|
|
46
|
+
artifacts: Path | None = None # if set, every step's output is saved here
|
|
47
|
+
planned: list[str] = field(default_factory=list) # goals written by the developer (never invented)
|
|
48
|
+
include_branches: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def glance_text(by_type: dict[str, int]) -> str:
|
|
52
|
+
"""'3 features, 2 fixes, 1 improvement': counted from commit types, never written by the AI."""
|
|
53
|
+
feats, fixes = by_type.get("feat", 0), by_type.get("fix", 0)
|
|
54
|
+
imps = by_type.get("perf", 0) + by_type.get("refactor", 0)
|
|
55
|
+
rest = sum(by_type.values()) - feats - fixes - imps
|
|
56
|
+
parts = []
|
|
57
|
+
for n, one, many in ((feats, "feature", "features"), (fixes, "fix", "fixes"),
|
|
58
|
+
(imps, "improvement", "improvements"), (rest, "other change", "other changes")):
|
|
59
|
+
if n:
|
|
60
|
+
parts.append(f"{n} {one if n == 1 else many}")
|
|
61
|
+
return ", ".join(parts) or "no categorised changes"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_pipeline(opts: Options, summarizer, cfg: Config | None = None,
|
|
65
|
+
log: Callable[[str], None] = lambda _m: None) -> ReportData:
|
|
66
|
+
cfg = cfg or Config()
|
|
67
|
+
|
|
68
|
+
log("Step 1/7 extracting commits")
|
|
69
|
+
commits = extract(opts.repo, opts.since)
|
|
70
|
+
if not commits:
|
|
71
|
+
raise GitReportError("No commits found in that time window. Try a longer --since (e.g. 14d).")
|
|
72
|
+
log(f" {len(commits)} commits found")
|
|
73
|
+
|
|
74
|
+
log("Step 2/7 filtering noise")
|
|
75
|
+
kept, decisions = filter_commits(commits, cfg)
|
|
76
|
+
if not kept:
|
|
77
|
+
raise GitReportError("Every commit in the window was filtered out as noise (merges, typos, wip).")
|
|
78
|
+
log(f" {len(commits)} -> {len(kept)} commits")
|
|
79
|
+
|
|
80
|
+
log("Step 3/7 clustering by module")
|
|
81
|
+
groups = cluster(kept, cfg)
|
|
82
|
+
log(f" {len(groups)} modules: {', '.join(groups)}")
|
|
83
|
+
|
|
84
|
+
log("Step 4/7 counting metrics")
|
|
85
|
+
metrics = compute_metrics(kept, groups, cfg, total_commits_seen=len(commits))
|
|
86
|
+
|
|
87
|
+
sections: list[Section] = []
|
|
88
|
+
chunks, prompts, summaries = {}, {}, {}
|
|
89
|
+
for i, (module, cs) in enumerate(groups.items(), 1):
|
|
90
|
+
log(f"Step 5-6 {module} ({i}/{len(groups)}): chunking + summarising")
|
|
91
|
+
ch = chunk_module(cs, cfg)
|
|
92
|
+
req = SummaryRequest(module, cs, ch.text, opts.audience)
|
|
93
|
+
result = summarizer.summarize(req)
|
|
94
|
+
hashes = [c.hash for c in cs]
|
|
95
|
+
cat_commits = [
|
|
96
|
+
CategorizedCommit(
|
|
97
|
+
hash=c.hash,
|
|
98
|
+
subject=c.subject,
|
|
99
|
+
author=c.author,
|
|
100
|
+
date=c.date,
|
|
101
|
+
type=commit_type(c.subject) or "other",
|
|
102
|
+
added=sum(f.added for f in c.files if not is_junk(f.path, cfg.junk)),
|
|
103
|
+
deleted=sum(f.deleted for f in c.files if not is_junk(f.path, cfg.junk)),
|
|
104
|
+
)
|
|
105
|
+
for c in cs
|
|
106
|
+
]
|
|
107
|
+
sections.append(Section(module, result.text, hashes, result.mode, result.note, commits=cat_commits))
|
|
108
|
+
chunks[module], prompts[module] = ch, req.prompt
|
|
109
|
+
summaries[module] = {"summary": result.text, "mode": result.mode, "flagged": result.flagged,
|
|
110
|
+
"note": result.note, "commit_hashes": hashes}
|
|
111
|
+
|
|
112
|
+
log("Step 7/7 building the report")
|
|
113
|
+
modes = {s.mode for s in sections}
|
|
114
|
+
n_off = sum(1 for s in sections if s.mode == "offline")
|
|
115
|
+
base = getattr(summarizer, "label", "unknown")
|
|
116
|
+
if modes == {"ai"} or not hasattr(summarizer, "model"):
|
|
117
|
+
mode_label = base
|
|
118
|
+
elif modes == {"offline"} and hasattr(summarizer, "model"):
|
|
119
|
+
mode_label = f"{base}; the AI step failed for every module, so offline wording was used"
|
|
120
|
+
else:
|
|
121
|
+
mode_label = f"{base}; offline wording used for {n_off} module(s)"
|
|
122
|
+
in_progress = open_branches(opts.repo, opts.since) if opts.include_branches else []
|
|
123
|
+
remote_url = get_remote_url(opts.repo)
|
|
124
|
+
exec_summary = make_executive_summary(Path(opts.repo).resolve().name, metrics, sections, summarizer)
|
|
125
|
+
data = ReportData(
|
|
126
|
+
project=Path(opts.repo).resolve().name,
|
|
127
|
+
start=opts.since.date().isoformat(), end=datetime.now().date().isoformat(),
|
|
128
|
+
audience=opts.audience, metrics=metrics, sections=sections, mode_label=mode_label,
|
|
129
|
+
glance_text=glance_text(metrics["commits_by_type"]), in_progress=in_progress, planned=opts.planned,
|
|
130
|
+
remote_url=remote_url, executive_summary=exec_summary,
|
|
131
|
+
)
|
|
132
|
+
if opts.artifacts:
|
|
133
|
+
_save_artifacts(opts.artifacts, commits, decisions, groups, metrics, chunks, prompts, summaries)
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def make_executive_summary(project: str, metrics: dict, sections: list[Section], summarizer) -> str:
|
|
138
|
+
if hasattr(summarizer, "ask") and getattr(summarizer, "mode", "") == "ai":
|
|
139
|
+
from git2report.cache import compute_cache_key, default_cache
|
|
140
|
+
all_hashes = [h for s in sections for h in s.hashes]
|
|
141
|
+
cache_key = compute_cache_key("exec_summary", project, all_hashes, getattr(summarizer, "model", "ai"), str(metrics.get("lines_changed", 0)))
|
|
142
|
+
cached = default_cache.get(cache_key)
|
|
143
|
+
if cached:
|
|
144
|
+
return cached["summary"]
|
|
145
|
+
|
|
146
|
+
mod_summaries = "\n".join(f"- {s.module}: {s.text}" for s in sections[:5])
|
|
147
|
+
prompt = (
|
|
148
|
+
f"You are a technology leader summarizing software progress for project '{project}'.\n"
|
|
149
|
+
f"Key metrics: {metrics['commits']} commits, {metrics['lines_changed']} lines changed, primary focus on {metrics['top_module']}.\n"
|
|
150
|
+
f"Module deliverables:\n{mod_summaries}\n\n"
|
|
151
|
+
"Task: Write 2 clear, inspiring, plain-language sentences summarizing the major business progress and impact for non-technical leadership.\n"
|
|
152
|
+
"Rules: Do not write file names or numbers. Use plain, positive business English.\n"
|
|
153
|
+
'Return ONLY JSON: {"summary": "..."}'
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
raw = summarizer.ask(prompt)
|
|
157
|
+
from git2report.summarizer import parse_model_json
|
|
158
|
+
text = parse_model_json(raw)
|
|
159
|
+
if text:
|
|
160
|
+
default_cache.set(cache_key, text, "ai")
|
|
161
|
+
return text
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
commits = metrics["commits"]
|
|
165
|
+
modules = metrics["modules"]
|
|
166
|
+
top = metrics["top_module"]
|
|
167
|
+
added, deleted = metrics.get("lines_added", 0), metrics.get("lines_deleted", 0)
|
|
168
|
+
return (
|
|
169
|
+
f"During this delivery cycle, engineering completed {commits} verified commits across {modules} module(s), "
|
|
170
|
+
f"expanding the codebase with +{added} lines and pruning -{deleted} lines. "
|
|
171
|
+
f"Development concentrated primarily on {top}, advancing key capabilities while reinforcing overall system stability."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _save_artifacts(root: Path, commits, decisions, groups, metrics, chunks, prompts, summaries) -> None:
|
|
176
|
+
root = Path(root)
|
|
177
|
+
(root / "chunks").mkdir(parents=True, exist_ok=True)
|
|
178
|
+
(root / "prompts").mkdir(parents=True, exist_ok=True)
|
|
179
|
+
dump = lambda name, obj: (root / name).write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
|
180
|
+
dump("commits.json", [asdict(c) for c in commits])
|
|
181
|
+
dump("filter_decisions.json", [asdict(d) for d in decisions])
|
|
182
|
+
dump("clusters.json", {m: [c.hash for c in cs] for m, cs in groups.items()})
|
|
183
|
+
dump("metrics.json", metrics)
|
|
184
|
+
dump("summaries.json", summaries)
|
|
185
|
+
for m, ch in chunks.items():
|
|
186
|
+
safe = "".join(ch_ if ch_.isalnum() or ch_ in "-_" else "_" for ch_ in m)
|
|
187
|
+
(root / "chunks" / f"{safe}.txt").write_text(ch.text, encoding="utf-8")
|
|
188
|
+
(root / "prompts" / f"{safe}.txt").write_text(prompts[m], encoding="utf-8")
|