codepulse-cli 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.
- codepulse/__init__.py +3 -0
- codepulse/__main__.py +3 -0
- codepulse/analyzers.py +468 -0
- codepulse/charts.py +126 -0
- codepulse/cli.py +250 -0
- codepulse/complexity.py +39 -0
- codepulse/git_data.py +121 -0
- codepulse/report.py +275 -0
- codepulse_cli-0.1.0.dist-info/METADATA +227 -0
- codepulse_cli-0.1.0.dist-info/RECORD +13 -0
- codepulse_cli-0.1.0.dist-info/WHEEL +4 -0
- codepulse_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
codepulse/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Command-line entry point for CodePulse.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
* **Report** (default) — full HTML health dashboard for a repo.
|
|
6
|
+
* **Risk gate** (``--changed <ref>``) — score only the files changed since a
|
|
7
|
+
ref, emit JSON or text, and set an exit code so CI can warn or block.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import webbrowser
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from . import analyzers
|
|
21
|
+
from .git_data import get_changed_files, get_commits, is_git_repo
|
|
22
|
+
from .report import render
|
|
23
|
+
|
|
24
|
+
_LEVELS = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="codepulse",
|
|
30
|
+
description="Reveal a codebase's health from its git history.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("repo", nargs="?", default=".", help="path to a git repository (default: current dir)")
|
|
33
|
+
parser.add_argument("-o", "--output", default="codepulse-report.html", help="HTML report output path")
|
|
34
|
+
parser.add_argument("--since", help='only commits after this date, e.g. "6 months ago"')
|
|
35
|
+
parser.add_argument("--max-count", type=int, help="limit to the N most recent commits")
|
|
36
|
+
parser.add_argument("--top", type=int, default=20, help="rows per table (default: 20)")
|
|
37
|
+
parser.add_argument("--open", action="store_true", help="open the HTML report in a browser when done")
|
|
38
|
+
parser.add_argument("--ignore", action="append", metavar="GLOB", default=[],
|
|
39
|
+
help="exclude paths matching this glob (repeatable), e.g. '*.sql'")
|
|
40
|
+
parser.add_argument("--no-default-ignore", action="store_true",
|
|
41
|
+
help="don't exclude the built-in generated/data file patterns")
|
|
42
|
+
parser.add_argument("--who", metavar="PATH",
|
|
43
|
+
help="show who knows a file best (from git history) and exit")
|
|
44
|
+
parser.add_argument("--badge", action="store_true",
|
|
45
|
+
help="print a shields.io health-badge snippet and exit")
|
|
46
|
+
|
|
47
|
+
gate = parser.add_argument_group("risk gate (CI)")
|
|
48
|
+
gate.add_argument("--changed", metavar="REF",
|
|
49
|
+
help='score files changed since REF (e.g. "origin/main" or "HEAD~10..HEAD")')
|
|
50
|
+
gate.add_argument("--format", choices=["text", "json", "markdown"], default="text",
|
|
51
|
+
help="risk-gate output format (default: text)")
|
|
52
|
+
gate.add_argument("--fail-on", choices=["LOW", "MEDIUM", "HIGH"],
|
|
53
|
+
help="exit with code 1 if release risk reaches this level")
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ignore_patterns(args: argparse.Namespace) -> list[str]:
|
|
58
|
+
base = [] if args.no_default_ignore else list(analyzers.DEFAULT_IGNORE)
|
|
59
|
+
return base + list(args.ignore)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_report(repo: str, args: argparse.Namespace) -> int:
|
|
63
|
+
print(f"Reading git history from {repo} ...")
|
|
64
|
+
commits = get_commits(repo, since=args.since, max_count=args.max_count)
|
|
65
|
+
if not commits:
|
|
66
|
+
print("error: no commits found for the given filters.", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
commits = analyzers.filter_commits(commits, _ignore_patterns(args))
|
|
69
|
+
print(f" {len(commits)} commits")
|
|
70
|
+
|
|
71
|
+
stats = analyzers.build_file_stats(commits, repo)
|
|
72
|
+
summary = analyzers.summarize(commits, stats)
|
|
73
|
+
hot = analyzers.hotspots(stats, limit=args.top)
|
|
74
|
+
hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
|
|
75
|
+
html = render(
|
|
76
|
+
repo_name=os.path.basename(repo.rstrip("/\\")) or repo,
|
|
77
|
+
summary=summary,
|
|
78
|
+
hotspots=hot,
|
|
79
|
+
bus_risks=analyzers.bus_factor_risks(stats, limit=args.top),
|
|
80
|
+
coupling=analyzers.coupling(commits, stats, limit=args.top),
|
|
81
|
+
trend=analyzers.monthly_trend(commits, hotspot_paths),
|
|
82
|
+
stale=analyzers.stale_risks(stats, summary.last_timestamp, limit=args.top),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
out = os.path.abspath(args.output)
|
|
86
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
87
|
+
fh.write(html)
|
|
88
|
+
print(f"\nReport written to {out}")
|
|
89
|
+
if args.open:
|
|
90
|
+
webbrowser.open(f"file://{out}")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_gate(repo: str, args: argparse.Namespace) -> int:
|
|
95
|
+
commits = get_commits(repo, since=args.since, max_count=args.max_count)
|
|
96
|
+
if not commits:
|
|
97
|
+
print("error: no commits found to build a risk map.", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
patterns = _ignore_patterns(args)
|
|
100
|
+
commits = analyzers.filter_commits(commits, patterns)
|
|
101
|
+
stats = analyzers.build_file_stats(commits, repo)
|
|
102
|
+
changed = [p for p in get_changed_files(repo, args.changed) if not analyzers.is_ignored(p, patterns)]
|
|
103
|
+
risk = analyzers.release_risk(changed, stats)
|
|
104
|
+
known_count, reviewers = analyzers.suggested_reviewers(changed, stats)
|
|
105
|
+
|
|
106
|
+
def _reviewer_label(r):
|
|
107
|
+
return f"{r.name} ({r.files_known}/{known_count} fichiers)"
|
|
108
|
+
|
|
109
|
+
icons = {"HIGH": "🔴", "MEDIUM": "🟠", "LOW": "🟢"}
|
|
110
|
+
|
|
111
|
+
if args.format == "json":
|
|
112
|
+
payload = {
|
|
113
|
+
"level": risk.level,
|
|
114
|
+
"score": risk.score,
|
|
115
|
+
"high_risk_count": risk.high_risk_count,
|
|
116
|
+
"changed_files": len(risk.files),
|
|
117
|
+
"files": [
|
|
118
|
+
{"path": f.path, "risk": f.risk, "commits": f.commits, "loc": f.loc, "reasons": f.reasons}
|
|
119
|
+
for f in risk.files
|
|
120
|
+
],
|
|
121
|
+
"suggested_reviewers": [
|
|
122
|
+
{"name": r.name, "files_known": r.files_known, "commits": r.commits} for r in reviewers
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
126
|
+
elif args.format == "markdown":
|
|
127
|
+
# A sticky PR comment. The marker lets a CI step find and update it.
|
|
128
|
+
lines = [
|
|
129
|
+
"<!-- codepulse -->",
|
|
130
|
+
f"## 🩺 CodePulse — Risque : {icons[risk.level]} **{risk.level}** ({risk.score}/100)",
|
|
131
|
+
"",
|
|
132
|
+
f"**{len(risk.files)} fichiers modifiés · {risk.high_risk_count} à risque élevé**",
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
scored = [f for f in risk.files if f.risk > 0]
|
|
136
|
+
if scored:
|
|
137
|
+
lines += ["| Risque | Fichier | Pourquoi |", "|---:|---|---|"]
|
|
138
|
+
for f in scored[:12]:
|
|
139
|
+
lines.append(f"| {f.risk} | `{f.path}` | {', '.join(f.reasons)} |")
|
|
140
|
+
else:
|
|
141
|
+
lines.append("_Aucun fichier à risque touché._ ✅")
|
|
142
|
+
if reviewers:
|
|
143
|
+
lines += ["", "**👥 Relecteurs suggérés** (d'après l'historique) : "
|
|
144
|
+
+ ", ".join(_reviewer_label(r) for r in reviewers)]
|
|
145
|
+
lines += [
|
|
146
|
+
"",
|
|
147
|
+
"<sub>Score basé sur l'historique du dépôt. Risque élevé = touche un hotspot connu ou un fichier à auteur unique.</sub>",
|
|
148
|
+
]
|
|
149
|
+
print("\n".join(lines))
|
|
150
|
+
else:
|
|
151
|
+
print(f"{icons[risk.level]} Release risk: {risk.level} ({risk.score}/100)")
|
|
152
|
+
print(f" {len(risk.files)} changed files · {risk.high_risk_count} high-risk")
|
|
153
|
+
for f in risk.files:
|
|
154
|
+
if f.risk > 0:
|
|
155
|
+
print(f" [{f.risk:3}] {f.path} ({', '.join(f.reasons)})")
|
|
156
|
+
if reviewers:
|
|
157
|
+
print(" 👥 Relecteurs suggérés: " + ", ".join(_reviewer_label(r) for r in reviewers))
|
|
158
|
+
|
|
159
|
+
if args.fail_on and _LEVELS[risk.level] >= _LEVELS[args.fail_on]:
|
|
160
|
+
print(f"\nRisk level {risk.level} >= threshold {args.fail_on} — failing.", file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load_stats(repo: str, args: argparse.Namespace):
|
|
166
|
+
commits = get_commits(repo, since=args.since, max_count=args.max_count)
|
|
167
|
+
if not commits:
|
|
168
|
+
return None, None
|
|
169
|
+
commits = analyzers.filter_commits(commits, _ignore_patterns(args))
|
|
170
|
+
return commits, analyzers.build_file_stats(commits, repo)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_who(repo: str, args: argparse.Namespace) -> int:
|
|
174
|
+
commits, stats = _load_stats(repo, args)
|
|
175
|
+
if not commits:
|
|
176
|
+
print("error: no commits found.", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
matches = analyzers.find_files(stats, args.who)
|
|
179
|
+
if not matches:
|
|
180
|
+
print(f"Aucun fichier suivi ne correspond à '{args.who}'.", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
if len(matches) > 1:
|
|
183
|
+
print(f"Plusieurs fichiers correspondent à '{args.who}' — précise le chemin :")
|
|
184
|
+
for m in matches[:20]:
|
|
185
|
+
print(f" {m}")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
s = stats[matches[0]]
|
|
189
|
+
total = sum(s.authors.values())
|
|
190
|
+
last = datetime.datetime.fromtimestamp(s.last_timestamp).strftime("%Y-%m-%d")
|
|
191
|
+
print(f"{s.path} — {s.commits} commits, {s.num_authors} auteur(s), dernier changement {last}")
|
|
192
|
+
for author, n in s.authors.most_common(8):
|
|
193
|
+
print(f" {round(n / total * 100):>3}% {n:>3} commits {author}")
|
|
194
|
+
if s.num_authors == 1:
|
|
195
|
+
print(" ⚠️ Un seul auteur — risque de bus factor.")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _run_badge(repo: str, args: argparse.Namespace) -> int:
|
|
200
|
+
commits, stats = _load_stats(repo, args)
|
|
201
|
+
if not commits:
|
|
202
|
+
print("error: no commits found.", file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
summary = analyzers.summarize(commits, stats)
|
|
205
|
+
hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
|
|
206
|
+
trend = analyzers.monthly_trend(commits, hotspot_paths)
|
|
207
|
+
health = analyzers.health_grade(stats, summary, trend)
|
|
208
|
+
|
|
209
|
+
message = f"{health.grade} ({health.score})"
|
|
210
|
+
static = f"https://img.shields.io/badge/{quote('code health')}-{quote(message)}-{health.color}"
|
|
211
|
+
print(f"Grade: {health.grade} ({health.score}/100)")
|
|
212
|
+
for r in health.reasons:
|
|
213
|
+
print(f" {r}")
|
|
214
|
+
print("\nMarkdown (copie dans ton README) :")
|
|
215
|
+
print(f"")
|
|
216
|
+
print("\nJSON (endpoint shields.io, si tu héberges ce fichier) :")
|
|
217
|
+
print(json.dumps({"schemaVersion": 1, "label": "code health", "message": message, "color": health.color},
|
|
218
|
+
ensure_ascii=False))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _force_utf8() -> None:
|
|
223
|
+
# Windows consoles default to cp1252 and choke on emoji/box-drawing output.
|
|
224
|
+
for stream in (sys.stdout, sys.stderr):
|
|
225
|
+
try:
|
|
226
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
227
|
+
except (AttributeError, ValueError):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def main(argv: list[str] | None = None) -> int:
|
|
232
|
+
_force_utf8()
|
|
233
|
+
args = build_parser().parse_args(argv)
|
|
234
|
+
repo = os.path.abspath(args.repo)
|
|
235
|
+
|
|
236
|
+
if not is_git_repo(repo):
|
|
237
|
+
print(f"error: '{repo}' is not a git repository.", file=sys.stderr)
|
|
238
|
+
return 2
|
|
239
|
+
|
|
240
|
+
if args.who:
|
|
241
|
+
return _run_who(repo, args)
|
|
242
|
+
if args.badge:
|
|
243
|
+
return _run_badge(repo, args)
|
|
244
|
+
if args.changed:
|
|
245
|
+
return _run_gate(repo, args)
|
|
246
|
+
return _run_report(repo, args)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
raise SystemExit(main())
|
codepulse/complexity.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Language-agnostic complexity estimate.
|
|
2
|
+
|
|
3
|
+
A proper per-language parser is overkill here; what correlates with bug risk is
|
|
4
|
+
the number of *decision points* in a file (McCabe's cyclomatic complexity is
|
|
5
|
+
essentially ``1 + decisions``). We approximate it by counting branch keywords
|
|
6
|
+
and boolean operators that exist across almost every language.
|
|
7
|
+
|
|
8
|
+
It is a proxy, not a compiler: it may miscount tokens inside strings or
|
|
9
|
+
comments. That's fine — it's applied consistently to every file, so the
|
|
10
|
+
*ranking* it produces is what matters, and it beats raw line count handily.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
# Branch keywords shared across C/Java/JS/Python/Rust/Go/Ruby/... plus the
|
|
18
|
+
# boolean operators that add a path. Deliberately excludes the lone "?" (Rust's
|
|
19
|
+
# error-propagation operator would wildly inflate the count) and "=>" (arrow
|
|
20
|
+
# functions in JS are not branches).
|
|
21
|
+
_DECISION = re.compile(
|
|
22
|
+
r"\b(if|elif|else\s+if|for|foreach|while|case|when|match|switch|catch|rescue|except)\b"
|
|
23
|
+
r"|&&|\|\|",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def measure(data: bytes) -> tuple[int, int]:
|
|
28
|
+
"""Return (lines, complexity) for a file's raw bytes.
|
|
29
|
+
|
|
30
|
+
Binary files get complexity == lines (a neutral value) so they never look
|
|
31
|
+
artificially hot or cold.
|
|
32
|
+
"""
|
|
33
|
+
if not data:
|
|
34
|
+
return 0, 1
|
|
35
|
+
lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
|
|
36
|
+
if b"\x00" in data[:4096]: # looks binary — has no meaningful complexity
|
|
37
|
+
return lines, 1
|
|
38
|
+
text = data.decode("utf-8", "ignore")
|
|
39
|
+
return lines, 1 + len(_DECISION.findall(text))
|
codepulse/git_data.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Extract commit history from a git repository.
|
|
2
|
+
|
|
3
|
+
This module shells out to ``git log`` and parses its output into plain
|
|
4
|
+
dataclasses. It has no third-party dependencies on purpose: anyone with git
|
|
5
|
+
and Python can run CodePulse without installing anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
# Delimiters chosen to be extremely unlikely to appear in commit metadata.
|
|
14
|
+
_UNIT = "\x1f" # separates fields inside a commit header line
|
|
15
|
+
_REC = "--CP--" # marks the start of a new commit record
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FileChange:
|
|
20
|
+
"""A single file touched by a commit."""
|
|
21
|
+
|
|
22
|
+
path: str
|
|
23
|
+
added: int
|
|
24
|
+
deleted: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Commit:
|
|
29
|
+
"""One commit and the files it changed."""
|
|
30
|
+
|
|
31
|
+
hash: str
|
|
32
|
+
timestamp: int # unix seconds (author date)
|
|
33
|
+
author: str
|
|
34
|
+
files: list[FileChange] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_git(repo: str, args: list[str]) -> str:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["git", "-C", repo, *args],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
errors="replace",
|
|
44
|
+
)
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
|
|
47
|
+
return result.stdout
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_git_repo(repo: str) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
return _run_git(repo, ["rev-parse", "--is-inside-work-tree"]).strip() == "true"
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_path(path: str) -> str:
|
|
58
|
+
"""Resolve git rename notation to the current path.
|
|
59
|
+
|
|
60
|
+
git numstat can emit renames as ``old => new`` or the compact
|
|
61
|
+
``dir/{old => new}/file`` form. We keep the *new* path so history
|
|
62
|
+
accumulates onto the file as it exists today.
|
|
63
|
+
"""
|
|
64
|
+
if "=>" in path:
|
|
65
|
+
if "{" in path and "}" in path:
|
|
66
|
+
prefix, rest = path.split("{", 1)
|
|
67
|
+
inner, suffix = rest.split("}", 1)
|
|
68
|
+
new = inner.split("=>", 1)[1].strip()
|
|
69
|
+
path = f"{prefix}{new}{suffix}".replace("//", "/")
|
|
70
|
+
else:
|
|
71
|
+
path = path.split("=>", 1)[1].strip()
|
|
72
|
+
return path.strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_changed_files(repo: str, ref_range: str) -> list[str]:
|
|
76
|
+
"""Return files changed for a diff spec.
|
|
77
|
+
|
|
78
|
+
Accepts either a range ("main..HEAD", "main...HEAD") or a single ref
|
|
79
|
+
("origin/main", "HEAD~5"). A single ref is compared against the current
|
|
80
|
+
working tree, so uncommitted changes count too — which is what a CI job or
|
|
81
|
+
a pre-commit check usually wants.
|
|
82
|
+
"""
|
|
83
|
+
out = _run_git(repo, ["diff", "--name-only", ref_range])
|
|
84
|
+
return [normalize_path(line) for line in out.splitlines() if line.strip()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_commits(repo: str, since: str | None = None, max_count: int | None = None) -> list[Commit]:
|
|
88
|
+
"""Return commits (newest first), excluding merges.
|
|
89
|
+
|
|
90
|
+
``since`` is passed straight to ``git --since`` (e.g. "6 months ago").
|
|
91
|
+
"""
|
|
92
|
+
fmt = f"{_REC}%H{_UNIT}%at{_UNIT}%aN"
|
|
93
|
+
args = ["log", "--no-merges", "--numstat", f"--pretty=format:{fmt}"]
|
|
94
|
+
if since:
|
|
95
|
+
args.append(f"--since={since}")
|
|
96
|
+
if max_count:
|
|
97
|
+
args.append(f"--max-count={max_count}")
|
|
98
|
+
|
|
99
|
+
out = _run_git(repo, args)
|
|
100
|
+
commits: list[Commit] = []
|
|
101
|
+
current: Commit | None = None
|
|
102
|
+
|
|
103
|
+
for line in out.splitlines():
|
|
104
|
+
if line.startswith(_REC):
|
|
105
|
+
parts = line[len(_REC):].split(_UNIT)
|
|
106
|
+
if len(parts) == 3:
|
|
107
|
+
current = Commit(hash=parts[0], timestamp=int(parts[1]), author=parts[2])
|
|
108
|
+
commits.append(current)
|
|
109
|
+
continue
|
|
110
|
+
if not line.strip() or current is None:
|
|
111
|
+
continue
|
|
112
|
+
cols = line.split("\t")
|
|
113
|
+
if len(cols) != 3:
|
|
114
|
+
continue
|
|
115
|
+
added_s, deleted_s, path = cols
|
|
116
|
+
# Binary files report "-" instead of a count.
|
|
117
|
+
added = int(added_s) if added_s.isdigit() else 0
|
|
118
|
+
deleted = int(deleted_s) if deleted_s.isdigit() else 0
|
|
119
|
+
current.files.append(FileChange(path=normalize_path(path), added=added, deleted=deleted))
|
|
120
|
+
|
|
121
|
+
return commits
|
codepulse/report.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Render analysis results as a single self-contained HTML dashboard.
|
|
2
|
+
|
|
3
|
+
No build step, no CDN: the CSS is inlined so the report opens anywhere and
|
|
4
|
+
survives being emailed around. Colours adapt to light/dark via tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import html
|
|
11
|
+
|
|
12
|
+
from . import charts
|
|
13
|
+
from .analyzers import CouplingPair, FileStat, StaleFile, Summary, Trend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _esc(value: object) -> str:
|
|
17
|
+
return html.escape(str(value), quote=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _date(ts: int) -> str:
|
|
21
|
+
if not ts:
|
|
22
|
+
return "—"
|
|
23
|
+
return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bar(value: float, maximum: float) -> str:
|
|
27
|
+
pct = 0 if maximum <= 0 else max(2, round(value / maximum * 100))
|
|
28
|
+
return f'<span class="bar" style="width:{pct}%"></span>'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _risk_badge(share: float) -> str:
|
|
32
|
+
if share >= 0.9:
|
|
33
|
+
cls, label = "high", "élevé"
|
|
34
|
+
elif share >= 0.7:
|
|
35
|
+
cls, label = "mid", "moyen"
|
|
36
|
+
else:
|
|
37
|
+
cls, label = "low", "faible"
|
|
38
|
+
return f'<span class="badge {cls}">{label}</span>'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _trend_section(trend: Trend) -> str:
|
|
42
|
+
if not trend or not trend.buckets:
|
|
43
|
+
return ""
|
|
44
|
+
commits = [(b.label, b.commits) for b in trend.buckets]
|
|
45
|
+
size = [(b.label, b.cum_net) for b in trend.buckets]
|
|
46
|
+
pressure = [(b.label, b.hotspot_pct) for b in trend.buckets]
|
|
47
|
+
grid = (
|
|
48
|
+
charts.bars(f"Commits par {trend.granularity}", commits, "accent", charts.fmt_int)
|
|
49
|
+
+ charts.line("Taille du code (lignes cumulées)", size, "accent", charts.fmt_int)
|
|
50
|
+
+ charts.line("Pression hotspots (% commits)", pressure, "hot", charts.fmt_pct)
|
|
51
|
+
)
|
|
52
|
+
return f"""<section>
|
|
53
|
+
<h2>📈 Tendance</h2>
|
|
54
|
+
<p class="hint">L'évolution dans le temps — l'activité, la croissance du code, et surtout si les changements se concentrent sur les fichiers à risque.</p>
|
|
55
|
+
<p class="verdict">{_esc(trend.verdict)}</p>
|
|
56
|
+
<div class="trend-grid">{grid}</div>
|
|
57
|
+
</section>"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _stale_section(stale: list[StaleFile]) -> str:
|
|
61
|
+
if not stale:
|
|
62
|
+
return ""
|
|
63
|
+
rows = "\n".join(
|
|
64
|
+
f"""<tr>
|
|
65
|
+
<td class="path">{_esc(s.path)}</td>
|
|
66
|
+
<td class="num">{s.complexity:,}</td>
|
|
67
|
+
<td class="num">{s.days_stale} j</td>
|
|
68
|
+
<td class="owner">{_esc(s.main_owner[0] or '—')}</td>
|
|
69
|
+
</tr>"""
|
|
70
|
+
for s in stale
|
|
71
|
+
)
|
|
72
|
+
return f"""<section>
|
|
73
|
+
<h2>🕸️ Code orphelin</h2>
|
|
74
|
+
<p class="hint">Fichiers complexes que plus personne n'a touchés depuis longtemps — de la logique qui vieillit et que plus grand monde ne comprend.</p>
|
|
75
|
+
<table>
|
|
76
|
+
<thead><tr><th>Fichier</th><th class="num">Complexité</th><th class="num">Sans changement</th><th>Dernier auteur</th></tr></thead>
|
|
77
|
+
<tbody>{rows}</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</section>"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render(
|
|
83
|
+
repo_name: str,
|
|
84
|
+
summary: Summary,
|
|
85
|
+
hotspots: list[FileStat],
|
|
86
|
+
bus_risks: list[FileStat],
|
|
87
|
+
coupling: list[CouplingPair],
|
|
88
|
+
trend: Trend | None = None,
|
|
89
|
+
stale: list[StaleFile] | None = None,
|
|
90
|
+
) -> str:
|
|
91
|
+
generated = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
92
|
+
span = f"{_date(summary.first_timestamp)} → {_date(summary.last_timestamp)}"
|
|
93
|
+
|
|
94
|
+
max_hot = max((h.hotspot_score for h in hotspots), default=1)
|
|
95
|
+
hot_rows = "\n".join(
|
|
96
|
+
f"""<tr>
|
|
97
|
+
<td class="rank">{i}</td>
|
|
98
|
+
<td class="path">{_esc(h.path)}</td>
|
|
99
|
+
<td class="num">{h.commits}</td>
|
|
100
|
+
<td class="num">{h.complexity:,}</td>
|
|
101
|
+
<td class="score">{_bar(h.hotspot_score, max_hot)}<span class="score-val">{h.hotspot_score:,}</span></td>
|
|
102
|
+
</tr>"""
|
|
103
|
+
for i, h in enumerate(hotspots, 1)
|
|
104
|
+
) or '<tr><td colspan="5" class="empty">Aucun hotspot détecté.</td></tr>'
|
|
105
|
+
|
|
106
|
+
bus_rows = "\n".join(
|
|
107
|
+
f"""<tr>
|
|
108
|
+
<td class="path">{_esc(s.path)}</td>
|
|
109
|
+
<td class="owner">{_esc(s.main_owner[0] or '—')}</td>
|
|
110
|
+
<td class="num">{s.commits}</td>
|
|
111
|
+
<td>{_risk_badge(s.main_owner[1])}</td>
|
|
112
|
+
</tr>"""
|
|
113
|
+
for s in bus_risks
|
|
114
|
+
) or '<tr><td colspan="4" class="empty">Aucun risque de bus factor. 🎉</td></tr>'
|
|
115
|
+
|
|
116
|
+
coupling_rows = "\n".join(
|
|
117
|
+
f"""<tr>
|
|
118
|
+
<td class="path">{_esc(p.a)}</td>
|
|
119
|
+
<td class="path">{_esc(p.b)}</td>
|
|
120
|
+
<td class="num">{p.shared}</td>
|
|
121
|
+
<td class="num">{round(p.degree * 100)}%</td>
|
|
122
|
+
</tr>"""
|
|
123
|
+
for p in coupling
|
|
124
|
+
) or '<tr><td colspan="4" class="empty">Aucun couplage significatif.</td></tr>'
|
|
125
|
+
|
|
126
|
+
max_contrib = max((n for _, n in summary.top_contributors), default=1)
|
|
127
|
+
contrib_rows = "\n".join(
|
|
128
|
+
f"""<li><span class="c-name">{_esc(name)}</span>
|
|
129
|
+
<span class="c-bar">{_bar(n, max_contrib)}</span>
|
|
130
|
+
<span class="c-num">{n}</span></li>"""
|
|
131
|
+
for name, n in summary.top_contributors
|
|
132
|
+
) or '<li class="empty">—</li>'
|
|
133
|
+
|
|
134
|
+
return _TEMPLATE.format(
|
|
135
|
+
repo_name=_esc(repo_name),
|
|
136
|
+
generated=generated,
|
|
137
|
+
span=span,
|
|
138
|
+
chart_css=charts.CHART_CSS,
|
|
139
|
+
trend_section=_trend_section(trend),
|
|
140
|
+
stale_section=_stale_section(stale or []),
|
|
141
|
+
total_commits=f"{summary.total_commits:,}",
|
|
142
|
+
active_files=f"{summary.active_files:,}",
|
|
143
|
+
tracked_files=f"{summary.tracked_files:,}",
|
|
144
|
+
contributors=summary.contributors,
|
|
145
|
+
hot_rows=hot_rows,
|
|
146
|
+
bus_rows=bus_rows,
|
|
147
|
+
coupling_rows=coupling_rows,
|
|
148
|
+
contrib_rows=contrib_rows,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_TEMPLATE = """<!doctype html>
|
|
153
|
+
<html lang="fr">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8">
|
|
156
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
157
|
+
<title>CodePulse — {repo_name}</title>
|
|
158
|
+
<style>
|
|
159
|
+
:root {{
|
|
160
|
+
--bg: #f6f7f9; --panel: #ffffff; --ink: #1a1d21; --muted: #6b7280;
|
|
161
|
+
--line: #e5e7eb; --accent: #2563eb; --hot: #ef4444; --hot-soft: #fee2e2;
|
|
162
|
+
--high: #dc2626; --mid: #d97706; --low: #16a34a;
|
|
163
|
+
}}
|
|
164
|
+
@media (prefers-color-scheme: dark) {{
|
|
165
|
+
:root:not([data-theme="light"]) {{
|
|
166
|
+
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
|
|
167
|
+
--line: #30363d; --accent: #58a6ff; --hot: #f85149; --hot-soft: #3d1a1a;
|
|
168
|
+
--high: #f85149; --mid: #e3b341; --low: #3fb950;
|
|
169
|
+
}}
|
|
170
|
+
}}
|
|
171
|
+
:root[data-theme="dark"] {{
|
|
172
|
+
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
|
|
173
|
+
--line: #30363d; --accent: #58a6ff; --hot: #f85149; --hot-soft: #3d1a1a;
|
|
174
|
+
--high: #f85149; --mid: #e3b341; --low: #3fb950;
|
|
175
|
+
}}
|
|
176
|
+
* {{ box-sizing: border-box; }}
|
|
177
|
+
body {{
|
|
178
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
179
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
180
|
+
}}
|
|
181
|
+
.wrap {{ max-width: 960px; margin: 0 auto; padding: 32px 16px 64px; }}
|
|
182
|
+
header {{ margin-bottom: 28px; }}
|
|
183
|
+
.logo {{ font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 700; }}
|
|
184
|
+
h1 {{ margin: 6px 0 4px; font-size: 26px; }}
|
|
185
|
+
.sub {{ color: var(--muted); font-size: 13px; }}
|
|
186
|
+
.cards {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0 36px; }}
|
|
187
|
+
@media (max-width: 640px) {{ .cards {{ grid-template-columns: repeat(2, 1fr); }} }}
|
|
188
|
+
.card {{ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }}
|
|
189
|
+
.card .k {{ font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }}
|
|
190
|
+
.card .v {{ font-size: 26px; font-weight: 700; margin-top: 4px; }}
|
|
191
|
+
section {{ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 20px; margin-bottom: 24px; }}
|
|
192
|
+
section h2 {{ margin: 0 0 4px; font-size: 18px; }}
|
|
193
|
+
section .hint {{ color: var(--muted); font-size: 13px; margin: 0 0 16px; }}
|
|
194
|
+
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
|
195
|
+
th {{ text-align: left; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; padding: 6px 8px; border-bottom: 1px solid var(--line); }}
|
|
196
|
+
td {{ padding: 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }}
|
|
197
|
+
tr:last-child td {{ border-bottom: none; }}
|
|
198
|
+
.rank {{ color: var(--muted); width: 28px; }}
|
|
199
|
+
.path {{ font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; word-break: break-all; }}
|
|
200
|
+
.num {{ text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }}
|
|
201
|
+
.score {{ position: relative; min-width: 160px; }}
|
|
202
|
+
.bar {{ display: inline-block; height: 8px; border-radius: 4px; background: var(--hot); vertical-align: middle; }}
|
|
203
|
+
.score-val {{ font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; margin-left: 8px; }}
|
|
204
|
+
.owner {{ color: var(--ink); }}
|
|
205
|
+
.badge {{ display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; }}
|
|
206
|
+
.badge.high {{ background: var(--high); }}
|
|
207
|
+
.badge.mid {{ background: var(--mid); }}
|
|
208
|
+
.badge.low {{ background: var(--low); }}
|
|
209
|
+
.empty {{ color: var(--muted); text-align: center; padding: 20px; }}
|
|
210
|
+
ul.contrib {{ list-style: none; margin: 0; padding: 0; }}
|
|
211
|
+
ul.contrib li {{ display: grid; grid-template-columns: 180px 1fr 40px; gap: 10px; align-items: center; padding: 5px 0; }}
|
|
212
|
+
ul.contrib .c-name {{ font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
213
|
+
ul.contrib .c-bar .bar {{ background: var(--accent); }}
|
|
214
|
+
ul.contrib .c-num {{ text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }}
|
|
215
|
+
footer {{ text-align: center; color: var(--muted); font-size: 12px; margin-top: 32px; }}
|
|
216
|
+
footer a {{ color: var(--accent); text-decoration: none; }}
|
|
217
|
+
{chart_css}
|
|
218
|
+
</style>
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
<div class="wrap">
|
|
222
|
+
<header>
|
|
223
|
+
<div class="logo">🩺 CodePulse</div>
|
|
224
|
+
<h1>{repo_name}</h1>
|
|
225
|
+
<div class="sub">Analyse générée le {generated} · historique {span}</div>
|
|
226
|
+
</header>
|
|
227
|
+
|
|
228
|
+
<div class="cards">
|
|
229
|
+
<div class="card"><div class="k">Commits</div><div class="v">{total_commits}</div></div>
|
|
230
|
+
<div class="card"><div class="k">Fichiers actifs</div><div class="v">{active_files}</div></div>
|
|
231
|
+
<div class="card"><div class="k">Fichiers suivis</div><div class="v">{tracked_files}</div></div>
|
|
232
|
+
<div class="card"><div class="k">Contributeurs</div><div class="v">{contributors}</div></div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{trend_section}
|
|
236
|
+
|
|
237
|
+
<section>
|
|
238
|
+
<h2>🔥 Hotspots</h2>
|
|
239
|
+
<p class="hint">Fichiers à la fois complexes (beaucoup de chemins de décision) et souvent modifiés — là où les bugs naissent et où refactorer rapporte le plus.</p>
|
|
240
|
+
<table>
|
|
241
|
+
<thead><tr><th></th><th>Fichier</th><th class="num">Commits</th><th class="num">Complexité</th><th>Score (commits × complexité)</th></tr></thead>
|
|
242
|
+
<tbody>{hot_rows}</tbody>
|
|
243
|
+
</table>
|
|
244
|
+
</section>
|
|
245
|
+
|
|
246
|
+
<section>
|
|
247
|
+
<h2>👥 Risque de bus factor</h2>
|
|
248
|
+
<p class="hint">Fichiers activement maintenus par une seule personne. Si elle part, ce savoir part avec elle.</p>
|
|
249
|
+
<table>
|
|
250
|
+
<thead><tr><th>Fichier</th><th>Unique auteur</th><th class="num">Commits</th><th>Risque</th></tr></thead>
|
|
251
|
+
<tbody>{bus_rows}</tbody>
|
|
252
|
+
</table>
|
|
253
|
+
</section>
|
|
254
|
+
|
|
255
|
+
<section>
|
|
256
|
+
<h2>🔗 Couplage caché</h2>
|
|
257
|
+
<p class="hint">Fichiers qui changent presque toujours ensemble — une dépendance que le code ne montre pas.</p>
|
|
258
|
+
<table>
|
|
259
|
+
<thead><tr><th>Fichier A</th><th>Fichier B</th><th class="num">Ensemble</th><th class="num">Degré</th></tr></thead>
|
|
260
|
+
<tbody>{coupling_rows}</tbody>
|
|
261
|
+
</table>
|
|
262
|
+
</section>
|
|
263
|
+
|
|
264
|
+
{stale_section}
|
|
265
|
+
|
|
266
|
+
<section>
|
|
267
|
+
<h2>🏆 Top contributeurs</h2>
|
|
268
|
+
<p class="hint">Par nombre de commits sur la période analysée.</p>
|
|
269
|
+
<ul class="contrib">{contrib_rows}</ul>
|
|
270
|
+
</section>
|
|
271
|
+
|
|
272
|
+
<footer>Généré par <a href="https://github.com/">CodePulse</a> — la santé de votre code, révélée par son historique.</footer>
|
|
273
|
+
</div>
|
|
274
|
+
</body>
|
|
275
|
+
</html>"""
|