codepulse-cli 0.1.0__tar.gz → 0.1.3__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.
- codepulse_cli-0.1.3/.github/workflows/publish.yml +32 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/PKG-INFO +18 -20
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/README.md +17 -19
- codepulse_cli-0.1.3/docs/demo.gif +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/pyproject.toml +1 -1
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/__init__.py +1 -1
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/analyzers.py +80 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/cli.py +16 -4
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/report.py +48 -1
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_analyzers.py +15 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.github/workflows/codepulse.yml +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.github/workflows/tests.yml +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.gitignore +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/LICENSE +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/action.yml +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/demo.tape +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/__main__.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/charts.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/complexity.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/git_data.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_complexity.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_e2e.py +0 -0
- {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_git_data.py +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Builds and publishes to PyPI whenever a GitHub release is published.
|
|
4
|
+
# Uses PyPI Trusted Publishing (OIDC) — no API token stored anywhere.
|
|
5
|
+
# Remember to bump `version` in pyproject.toml before creating the release.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
pypi:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
id-token: write # required for trusted publishing
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
|
|
26
|
+
- name: Build distributions
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade build
|
|
29
|
+
python -m build
|
|
30
|
+
|
|
31
|
+
- name: Publish to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codepulse-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Reveal a codebase's health from its git history — hotspots, bus factor, hidden coupling, and a CI risk gate.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Mehouelley/codepulse
|
|
6
6
|
Project-URL: Repository, https://github.com/Mehouelley/codepulse
|
|
@@ -45,31 +45,22 @@ with **zero dependencies** beyond git and Python.
|
|
|
45
45
|
|
|
46
46
|
## Demo
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
 -->
|
|
48
|
+

|
|
50
49
|
|
|
51
50
|
```console
|
|
52
51
|
$ codepulse . --badge
|
|
53
52
|
Grade: B (83/100)
|
|
54
|
-
-2 bus factor (9 fichiers à auteur unique)
|
|
55
|
-
-15 pression hotspots en hausse
|
|
56
|
-
-green)
|
|
57
53
|
|
|
58
54
|
$ codepulse . --changed origin/main
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
[ 70] crates/layout/src/para.rs (zone active, auteur unique)
|
|
63
|
-
👥 Relecteurs suggérés: Valentin Klein (29/37 fichiers), Dmitrii Khlapov (22/37)
|
|
55
|
+
Release risk: HIGH (100/100)
|
|
56
|
+
[100] crates/ui-egui/src/lib.rs (major hotspot)
|
|
57
|
+
Reviewers: Valentin Klein (29/37), Dmitrii Khlapov (22/37)
|
|
64
58
|
|
|
65
59
|
$ codepulse . --who crates/ui-egui/src/canvas.rs
|
|
66
|
-
|
|
67
|
-
45% 29 commits Valentin Klein
|
|
68
|
-
11% 7 commits Dmitrii Khlapov
|
|
60
|
+
canvas.rs — 64 commits, 22 authors — 45% Valentin Klein
|
|
69
61
|
```
|
|
70
62
|
|
|
71
|
-
>
|
|
72
|
-
> generates an animated `docs/demo.gif` of the above.
|
|
63
|
+
> The GIF is generated from [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)).
|
|
73
64
|
|
|
74
65
|
---
|
|
75
66
|
|
|
@@ -87,18 +78,24 @@ or email to your team.
|
|
|
87
78
|
|
|
88
79
|
---
|
|
89
80
|
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install codepulse-cli # installs the `codepulse` command
|
|
85
|
+
```
|
|
86
|
+
|
|
90
87
|
## Quick start
|
|
91
88
|
|
|
92
89
|
```bash
|
|
93
90
|
# From a git repository:
|
|
94
|
-
|
|
91
|
+
codepulse /path/to/repo --open
|
|
95
92
|
|
|
96
93
|
# Common options:
|
|
97
|
-
|
|
94
|
+
codepulse . --since "6 months ago" --top 30 -o health.html
|
|
98
95
|
```
|
|
99
96
|
|
|
100
|
-
> Running from
|
|
101
|
-
> `pip install -e
|
|
97
|
+
> Running from a source checkout instead? Use `python -m codepulse` with
|
|
98
|
+
> `PYTHONPATH=src`, or `pip install -e .`.
|
|
102
99
|
|
|
103
100
|
### Options
|
|
104
101
|
|
|
@@ -211,6 +208,7 @@ simple enough to run in one command.
|
|
|
211
208
|
- [x] `--who <file>` — who knows this code best
|
|
212
209
|
- [x] `--badge` — shareable A–F health badge for your README
|
|
213
210
|
- [x] Orphaned-code section — complex files nobody touches anymore
|
|
211
|
+
- [x] **Recommendations** — prioritized, concrete actions, not just findings
|
|
214
212
|
|
|
215
213
|
## Development
|
|
216
214
|
|
|
@@ -19,31 +19,22 @@ with **zero dependencies** beyond git and Python.
|
|
|
19
19
|
|
|
20
20
|
## Demo
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
 -->
|
|
22
|
+

|
|
24
23
|
|
|
25
24
|
```console
|
|
26
25
|
$ codepulse . --badge
|
|
27
26
|
Grade: B (83/100)
|
|
28
|
-
-2 bus factor (9 fichiers à auteur unique)
|
|
29
|
-
-15 pression hotspots en hausse
|
|
30
|
-
-green)
|
|
31
27
|
|
|
32
28
|
$ codepulse . --changed origin/main
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
[ 70] crates/layout/src/para.rs (zone active, auteur unique)
|
|
37
|
-
👥 Relecteurs suggérés: Valentin Klein (29/37 fichiers), Dmitrii Khlapov (22/37)
|
|
29
|
+
Release risk: HIGH (100/100)
|
|
30
|
+
[100] crates/ui-egui/src/lib.rs (major hotspot)
|
|
31
|
+
Reviewers: Valentin Klein (29/37), Dmitrii Khlapov (22/37)
|
|
38
32
|
|
|
39
33
|
$ codepulse . --who crates/ui-egui/src/canvas.rs
|
|
40
|
-
|
|
41
|
-
45% 29 commits Valentin Klein
|
|
42
|
-
11% 7 commits Dmitrii Khlapov
|
|
34
|
+
canvas.rs — 64 commits, 22 authors — 45% Valentin Klein
|
|
43
35
|
```
|
|
44
36
|
|
|
45
|
-
>
|
|
46
|
-
> generates an animated `docs/demo.gif` of the above.
|
|
37
|
+
> The GIF is generated from [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)).
|
|
47
38
|
|
|
48
39
|
---
|
|
49
40
|
|
|
@@ -61,18 +52,24 @@ or email to your team.
|
|
|
61
52
|
|
|
62
53
|
---
|
|
63
54
|
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install codepulse-cli # installs the `codepulse` command
|
|
59
|
+
```
|
|
60
|
+
|
|
64
61
|
## Quick start
|
|
65
62
|
|
|
66
63
|
```bash
|
|
67
64
|
# From a git repository:
|
|
68
|
-
|
|
65
|
+
codepulse /path/to/repo --open
|
|
69
66
|
|
|
70
67
|
# Common options:
|
|
71
|
-
|
|
68
|
+
codepulse . --since "6 months ago" --top 30 -o health.html
|
|
72
69
|
```
|
|
73
70
|
|
|
74
|
-
> Running from
|
|
75
|
-
> `pip install -e
|
|
71
|
+
> Running from a source checkout instead? Use `python -m codepulse` with
|
|
72
|
+
> `PYTHONPATH=src`, or `pip install -e .`.
|
|
76
73
|
|
|
77
74
|
### Options
|
|
78
75
|
|
|
@@ -185,6 +182,7 @@ simple enough to run in one command.
|
|
|
185
182
|
- [x] `--who <file>` — who knows this code best
|
|
186
183
|
- [x] `--badge` — shareable A–F health badge for your README
|
|
187
184
|
- [x] Orphaned-code section — complex files nobody touches anymore
|
|
185
|
+
- [x] **Recommendations** — prioritized, concrete actions, not just findings
|
|
188
186
|
|
|
189
187
|
## Development
|
|
190
188
|
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codepulse-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Reveal a codebase's health from its git history — hotspots, bus factor, hidden coupling, and a CI risk gate."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -314,6 +314,86 @@ def stale_risks(stats: dict[str, FileStat], now_ts: int, limit: int = 15) -> lis
|
|
|
314
314
|
return out[:limit]
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
@dataclass
|
|
318
|
+
class Recommendation:
|
|
319
|
+
priority: int # 1 = most urgent
|
|
320
|
+
icon: str
|
|
321
|
+
title: str
|
|
322
|
+
action: str
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _duplicate_authors(commits: list[Commit]) -> list[tuple[str, ...]]:
|
|
326
|
+
"""Author names that differ only by case/spacing — likely one person."""
|
|
327
|
+
groups: dict[str, set] = defaultdict(set)
|
|
328
|
+
for c in commits:
|
|
329
|
+
groups[c.author.strip().lower()].add(c.author.strip())
|
|
330
|
+
return [tuple(sorted(v)) for v in groups.values() if len(v) > 1]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def recommendations(
|
|
334
|
+
commits: list[Commit],
|
|
335
|
+
hotspots: list[FileStat],
|
|
336
|
+
bus_risks: list[FileStat],
|
|
337
|
+
coupling: list[CouplingPair],
|
|
338
|
+
trend,
|
|
339
|
+
stale: list[StaleFile],
|
|
340
|
+
limit: int = 8,
|
|
341
|
+
) -> list[Recommendation]:
|
|
342
|
+
"""Turn the findings into concrete, prioritized actions to take."""
|
|
343
|
+
recs: list[Recommendation] = []
|
|
344
|
+
|
|
345
|
+
for h in hotspots[:2]:
|
|
346
|
+
recs.append(Recommendation(
|
|
347
|
+
1, "🔥", f"Refactorer `{h.path}`",
|
|
348
|
+
f"{h.commits} commits et une complexité de {h.complexity} : c'est un point chaud majeur, "
|
|
349
|
+
f"là où les bugs naissent. Découpez-le en unités plus petites et couvrez-le de tests avant d'y retoucher.",
|
|
350
|
+
))
|
|
351
|
+
|
|
352
|
+
if trend and trend.direction == "up":
|
|
353
|
+
recs.append(Recommendation(
|
|
354
|
+
1, "📈", "Freiner la dette technique",
|
|
355
|
+
"La part des commits touchant les hotspots augmente. Gelez les nouvelles fonctionnalités sur ces "
|
|
356
|
+
"fichiers et refactorez-les d'abord, sinon les bugs vont s'accumuler.",
|
|
357
|
+
))
|
|
358
|
+
|
|
359
|
+
if bus_risks:
|
|
360
|
+
top = bus_risks[0]
|
|
361
|
+
owner = top.main_owner[0] or "une seule personne"
|
|
362
|
+
recs.append(Recommendation(
|
|
363
|
+
2, "👥", "Partager la connaissance",
|
|
364
|
+
f"{len(bus_risks)} fichiers n'ont qu'un seul auteur (ex. `{top.path}`, par {owner}). "
|
|
365
|
+
"Documentez-les ou passez-les en revue à deux : si cette personne part, ce savoir disparaît.",
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
dupes = _duplicate_authors(commits)
|
|
369
|
+
if dupes:
|
|
370
|
+
a, b = dupes[0][0], dupes[0][1]
|
|
371
|
+
recs.append(Recommendation(
|
|
372
|
+
2, "🪪", "Unifier les identités git",
|
|
373
|
+
f"« {a} » et « {b} » semblent être la même personne (casse différente). Ajoutez un fichier "
|
|
374
|
+
"`.mailmap` pour fusionner les statistiques et compter les contributeurs correctement.",
|
|
375
|
+
))
|
|
376
|
+
|
|
377
|
+
if coupling:
|
|
378
|
+
p = coupling[0]
|
|
379
|
+
recs.append(Recommendation(
|
|
380
|
+
3, "🔗", "Clarifier un couplage caché",
|
|
381
|
+
f"`{p.a}` et `{p.b}` changent presque toujours ensemble ({p.shared}×). Vérifiez cette dépendance : "
|
|
382
|
+
"à expliciter, documenter, ou factoriser si elle est accidentelle.",
|
|
383
|
+
))
|
|
384
|
+
|
|
385
|
+
if stale:
|
|
386
|
+
s = stale[0]
|
|
387
|
+
recs.append(Recommendation(
|
|
388
|
+
3, "🕸️", "Revoir du code qui vieillit",
|
|
389
|
+
f"`{s.path}` est complexe (complexité {s.complexity}) mais intact depuis {s.days_stale} jours. "
|
|
390
|
+
"Vérifiez qu'il est encore utile, à jour et documenté avant que personne ne s'en souvienne.",
|
|
391
|
+
))
|
|
392
|
+
|
|
393
|
+
recs.sort(key=lambda r: r.priority)
|
|
394
|
+
return recs[:limit]
|
|
395
|
+
|
|
396
|
+
|
|
317
397
|
@dataclass
|
|
318
398
|
class Health:
|
|
319
399
|
score: int
|
|
@@ -71,21 +71,33 @@ def _run_report(repo: str, args: argparse.Namespace) -> int:
|
|
|
71
71
|
stats = analyzers.build_file_stats(commits, repo)
|
|
72
72
|
summary = analyzers.summarize(commits, stats)
|
|
73
73
|
hot = analyzers.hotspots(stats, limit=args.top)
|
|
74
|
+
bus = analyzers.bus_factor_risks(stats, limit=args.top)
|
|
74
75
|
hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
|
|
76
|
+
trend = analyzers.monthly_trend(commits, hotspot_paths)
|
|
77
|
+
coupled = analyzers.coupling(commits, stats, limit=args.top)
|
|
78
|
+
stale = analyzers.stale_risks(stats, summary.last_timestamp, limit=args.top)
|
|
79
|
+
recs = analyzers.recommendations(commits, hot, bus, coupled, trend, stale)
|
|
80
|
+
|
|
75
81
|
html = render(
|
|
76
82
|
repo_name=os.path.basename(repo.rstrip("/\\")) or repo,
|
|
77
83
|
summary=summary,
|
|
78
84
|
hotspots=hot,
|
|
79
|
-
bus_risks=
|
|
80
|
-
coupling=
|
|
81
|
-
trend=
|
|
82
|
-
stale=
|
|
85
|
+
bus_risks=bus,
|
|
86
|
+
coupling=coupled,
|
|
87
|
+
trend=trend,
|
|
88
|
+
stale=stale,
|
|
89
|
+
recommendations=recs,
|
|
83
90
|
)
|
|
84
91
|
|
|
85
92
|
out = os.path.abspath(args.output)
|
|
86
93
|
with open(out, "w", encoding="utf-8") as fh:
|
|
87
94
|
fh.write(html)
|
|
88
95
|
print(f"\nReport written to {out}")
|
|
96
|
+
if recs:
|
|
97
|
+
print("\nRecommandations :")
|
|
98
|
+
for i, r in enumerate(recs, 1):
|
|
99
|
+
print(f" {i}. {r.icon} {r.title.replace('`', '')}")
|
|
100
|
+
print(f" {r.action.replace('`', '')}")
|
|
89
101
|
if args.open:
|
|
90
102
|
webbrowser.open(f"file://{out}")
|
|
91
103
|
return 0
|
|
@@ -10,7 +10,7 @@ import datetime as _dt
|
|
|
10
10
|
import html
|
|
11
11
|
|
|
12
12
|
from . import charts
|
|
13
|
-
from .analyzers import CouplingPair, FileStat, StaleFile, Summary, Trend
|
|
13
|
+
from .analyzers import CouplingPair, FileStat, Recommendation, StaleFile, Summary, Trend
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _esc(value: object) -> str:
|
|
@@ -79,6 +79,32 @@ def _stale_section(stale: list[StaleFile]) -> str:
|
|
|
79
79
|
</section>"""
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def _code_spans(text: str) -> str:
|
|
83
|
+
"""Escape, then turn `backtick` spans into <code>."""
|
|
84
|
+
out, code = [], False
|
|
85
|
+
for part in _esc(text).split("`"):
|
|
86
|
+
out.append(f"<code>{part}</code>" if code else part)
|
|
87
|
+
code = not code
|
|
88
|
+
return "".join(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _rec_section(recs: list[Recommendation]) -> str:
|
|
92
|
+
if not recs:
|
|
93
|
+
return ""
|
|
94
|
+
items = "\n".join(
|
|
95
|
+
f"""<li class="rec p{min(r.priority, 3)}">
|
|
96
|
+
<div class="rec-head">{r.icon} <strong>{_esc(r.title)}</strong></div>
|
|
97
|
+
<div class="rec-action">{_code_spans(r.action)}</div>
|
|
98
|
+
</li>"""
|
|
99
|
+
for r in recs
|
|
100
|
+
)
|
|
101
|
+
return f"""<section class="recs-section">
|
|
102
|
+
<h2>🎯 Recommandations</h2>
|
|
103
|
+
<p class="hint">Les actions concrètes à prendre, de la plus urgente à la moins urgente.</p>
|
|
104
|
+
<ol class="recs">{items}</ol>
|
|
105
|
+
</section>"""
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
def render(
|
|
83
109
|
repo_name: str,
|
|
84
110
|
summary: Summary,
|
|
@@ -87,6 +113,7 @@ def render(
|
|
|
87
113
|
coupling: list[CouplingPair],
|
|
88
114
|
trend: Trend | None = None,
|
|
89
115
|
stale: list[StaleFile] | None = None,
|
|
116
|
+
recommendations: list[Recommendation] | None = None,
|
|
90
117
|
) -> str:
|
|
91
118
|
generated = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
92
119
|
span = f"{_date(summary.first_timestamp)} → {_date(summary.last_timestamp)}"
|
|
@@ -136,6 +163,7 @@ def render(
|
|
|
136
163
|
generated=generated,
|
|
137
164
|
span=span,
|
|
138
165
|
chart_css=charts.CHART_CSS,
|
|
166
|
+
rec_section=_rec_section(recommendations or []),
|
|
139
167
|
trend_section=_trend_section(trend),
|
|
140
168
|
stale_section=_stale_section(stale or []),
|
|
141
169
|
total_commits=f"{summary.total_commits:,}",
|
|
@@ -207,6 +235,23 @@ tr:last-child td {{ border-bottom: none; }}
|
|
|
207
235
|
.badge.mid {{ background: var(--mid); }}
|
|
208
236
|
.badge.low {{ background: var(--low); }}
|
|
209
237
|
.empty {{ color: var(--muted); text-align: center; padding: 20px; }}
|
|
238
|
+
.recs-section {{ border-color: var(--accent); }}
|
|
239
|
+
ol.recs {{ list-style: none; counter-reset: rec; margin: 0; padding: 0; }}
|
|
240
|
+
ol.recs .rec {{ counter-increment: rec; position: relative; padding: 12px 12px 12px 44px; margin-bottom: 10px;
|
|
241
|
+
border: 1px solid var(--line); border-left: 4px solid var(--muted); border-radius: 10px; background: var(--bg); }}
|
|
242
|
+
ol.recs .rec::before {{ content: counter(rec); position: absolute; left: 12px; top: 12px; width: 22px; height: 22px;
|
|
243
|
+
border-radius: 50%; background: var(--muted); color: #fff; font-size: 12px; font-weight: 700;
|
|
244
|
+
display: flex; align-items: center; justify-content: center; }}
|
|
245
|
+
ol.recs .rec.p1 {{ border-left-color: var(--hot); }}
|
|
246
|
+
ol.recs .rec.p1::before {{ background: var(--hot); }}
|
|
247
|
+
ol.recs .rec.p2 {{ border-left-color: var(--mid); }}
|
|
248
|
+
ol.recs .rec.p2::before {{ background: var(--mid); }}
|
|
249
|
+
ol.recs .rec.p3 {{ border-left-color: var(--low); }}
|
|
250
|
+
ol.recs .rec.p3::before {{ background: var(--low); }}
|
|
251
|
+
.rec-head {{ font-size: 15px; margin-bottom: 3px; }}
|
|
252
|
+
.rec-action {{ color: var(--muted); font-size: 14px; }}
|
|
253
|
+
.rec-action code {{ font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--ink);
|
|
254
|
+
background: var(--panel); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }}
|
|
210
255
|
ul.contrib {{ list-style: none; margin: 0; padding: 0; }}
|
|
211
256
|
ul.contrib li {{ display: grid; grid-template-columns: 180px 1fr 40px; gap: 10px; align-items: center; padding: 5px 0; }}
|
|
212
257
|
ul.contrib .c-name {{ font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
@@ -232,6 +277,8 @@ footer a {{ color: var(--accent); text-decoration: none; }}
|
|
|
232
277
|
<div class="card"><div class="k">Contributeurs</div><div class="v">{contributors}</div></div>
|
|
233
278
|
</div>
|
|
234
279
|
|
|
280
|
+
{rec_section}
|
|
281
|
+
|
|
235
282
|
{trend_section}
|
|
236
283
|
|
|
237
284
|
<section>
|
|
@@ -90,6 +90,21 @@ def test_release_risk_high_for_hotspot_single_owner():
|
|
|
90
90
|
assert any("auteur unique" in r for r in risk.files[0].reasons)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_recommendations_prioritize_and_detect_duplicate_authors():
|
|
94
|
+
commits = [
|
|
95
|
+
Commit(str(i), i * DAY, "Alice" if i % 2 else "alice",
|
|
96
|
+
files=[FileChange("hot.py", 1, 0)])
|
|
97
|
+
for i in range(6)
|
|
98
|
+
]
|
|
99
|
+
hot = [stat("hot.py", commits=6, authors={"Alice": 3, "alice": 3}, complexity=40)]
|
|
100
|
+
bus = [stat("solo.py", commits=4, authors={"Alice": 4})]
|
|
101
|
+
recs = analyzers.recommendations(commits, hot, bus, [], None, [])
|
|
102
|
+
titles = " ".join(r.title for r in recs)
|
|
103
|
+
assert "hot.py" in titles # top hotspot recommended
|
|
104
|
+
assert any("identit" in r.title.lower() for r in recs) # duplicate Alice/alice detected
|
|
105
|
+
assert recs == sorted(recs, key=lambda r: r.priority) # sorted by urgency
|
|
106
|
+
|
|
107
|
+
|
|
93
108
|
def test_suggested_reviewers_ranks_by_coverage_then_depth():
|
|
94
109
|
stats = {
|
|
95
110
|
"a.py": stat("a.py", 5, {"Alice": 4, "Bob": 1}),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|