gitmole 0.3.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.
- gitmole/__init__.py +3 -0
- gitmole/__main__.py +6 -0
- gitmole/backtest.py +82 -0
- gitmole/banner.py +107 -0
- gitmole/blame.py +135 -0
- gitmole/cli.py +439 -0
- gitmole/filetypes.py +78 -0
- gitmole/findings.py +405 -0
- gitmole/functions.py +108 -0
- gitmole/hotspots.py +18 -0
- gitmole/identity.py +66 -0
- gitmole/knowledge.py +55 -0
- gitmole/leaks.py +124 -0
- gitmole/load.py +253 -0
- gitmole/loss.py +44 -0
- gitmole/maat.py +299 -0
- gitmole/render.py +736 -0
- gitmole/run.py +407 -0
- gitmole/textfmt.py +91 -0
- gitmole/trend.py +154 -0
- gitmole/watch.py +172 -0
- gitmole-0.3.0.dist-info/METADATA +469 -0
- gitmole-0.3.0.dist-info/RECORD +27 -0
- gitmole-0.3.0.dist-info/WHEEL +5 -0
- gitmole-0.3.0.dist-info/entry_points.txt +2 -0
- gitmole-0.3.0.dist-info/licenses/LICENSE +21 -0
- gitmole-0.3.0.dist-info/top_level.txt +1 -0
gitmole/render.py
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""Turn a loaded report into sections, then draw them with rich or as Markdown/JSON.
|
|
2
|
+
|
|
3
|
+
The default report is the tighter one: the columns you actually read, capped rows, elided
|
|
4
|
+
paths. `full` restores every column and row (Markdown export is always full)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from rich import box
|
|
8
|
+
from rich.columns import Columns
|
|
9
|
+
from rich.console import Console, Group
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.padding import Padding
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from . import filetypes, hotspots, identity, knowledge, leaks, loss, textfmt, trend, watch
|
|
17
|
+
|
|
18
|
+
SEVERITY_STYLE = {"critical": "bold red", "warning": "yellow", "info": "cyan"}
|
|
19
|
+
|
|
20
|
+
# section styling: the banner's palette carried into the tables
|
|
21
|
+
ACCENT = "#5ad0ff" # section titles
|
|
22
|
+
HEADER = "bold #c86cff" # column headers
|
|
23
|
+
BAR = "#5ad0ff" # inline share bars
|
|
24
|
+
HOT = "bold #ff5cc8" # values past a threshold
|
|
25
|
+
WARM = "#ff9ee0" # values worth a glance
|
|
26
|
+
ROW_STYLES = ["", "on #1c2230"]
|
|
27
|
+
SIDE_BY_SIDE_MIN_WIDTH = 100
|
|
28
|
+
|
|
29
|
+
SYMBOLS = {"Size by language": "▤", "People": "◉", "Activity": "◔", "Timeline": "▦", "Hotspots": "◆", "Change coupling": "⟷",
|
|
30
|
+
"Surviving code by year written": "◷", "Net lines added by year": "◷", "Paths in history by year last changed": "◷",
|
|
31
|
+
"Knowledge map": "⌂", "Repo health": "✚", "Portfolio": "▣", "File types": "▥", "Complex functions": "λ", "Watch list": "◎",
|
|
32
|
+
"Change risk": "◈"}
|
|
33
|
+
# the one column to read first in each table; the rest are dimmed
|
|
34
|
+
KEY_METRIC = {"Size by language": "code", "People": "commits", "Hotspots": "revs", "Change coupling": "degree",
|
|
35
|
+
"Knowledge map": "lines added", "Surviving code by year written": "lines", "Net lines added by year": "net lines",
|
|
36
|
+
"Paths in history by year last changed": "paths", "Activity": "commits", "Portfolio": "commits", "Complex functions": "ccn",
|
|
37
|
+
"Watch list": "why", "Change risk": "risk"}
|
|
38
|
+
SEVERITY_MARK = {"critical": "✖", "warning": "▲", "info": "●"}
|
|
39
|
+
RIGHT = {"justify": "right"}
|
|
40
|
+
FOLD = {"overflow": "fold"}
|
|
41
|
+
PATH = {"overflow": "fold", "no_wrap": False}
|
|
42
|
+
|
|
43
|
+
# rows shown by default; `full` lifts the caps. Markdown gets a looser cap of its own.
|
|
44
|
+
CAPS = {"People": 6, "Hotspots": 8, "Change coupling": 5, "Knowledge map": 6, "Size by language": 8, "Timeline": 8, "Complex functions": 8}
|
|
45
|
+
MARKDOWN_CAP = 50
|
|
46
|
+
TREND_TOP = 10 # the trend step's own --top default: only those files have samples
|
|
47
|
+
WATCH_CAP, WATCH_FULL = 5, 15 # the watch list is a short list by design; `full` and Markdown get a longer one, never all files
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pct(part, whole) -> str:
|
|
54
|
+
return f"{100 * part / whole:.0f}%" if whole else "-"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bar(part, whole, width=30) -> str:
|
|
58
|
+
return "█" * int(width * part / whole) if whole else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _section(title, columns, rows, note=None, caption=None) -> dict:
|
|
62
|
+
"""columns: list of (name, rich column options). rows: lists of already-formatted cells."""
|
|
63
|
+
return {"title": title, "columns": [c[0] for c in columns], "col_opts": [c[1] for c in columns],
|
|
64
|
+
"rows": [[str(c) for c in r] for r in rows], "note": note, "caption": caption}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _limit(title: str, full, cap=None):
|
|
68
|
+
"""How many rows to keep: None for all. `full` may be False (terminal default), True, or 'markdown'.
|
|
69
|
+
An explicit `cap` is the section's own cap and holds for Markdown too."""
|
|
70
|
+
if full is True:
|
|
71
|
+
return None
|
|
72
|
+
if cap is not None:
|
|
73
|
+
return cap
|
|
74
|
+
return MARKDOWN_CAP if full == "markdown" else CAPS.get(title)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _more(total: int, limit) -> str:
|
|
78
|
+
return f"and {total - limit} more" if limit is not None and total > limit else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tuple:
|
|
82
|
+
"""Drop rows whose path (or any of whose paths) is a test path, unless `full` is True.
|
|
83
|
+
`path_of(row)` returns a single path or a tuple of paths to check. `noun` names one hidden row
|
|
84
|
+
and `plural` names several, `noun + "s"` by default (a multi-word noun gives its own plural:
|
|
85
|
+
"function in a test file" -> "functions in test files"). Returns (rows, note), `note` being
|
|
86
|
+
the caption note for the hidden count, or None."""
|
|
87
|
+
if full is True:
|
|
88
|
+
return rows, None
|
|
89
|
+
kept, hidden = [], 0
|
|
90
|
+
for row in rows:
|
|
91
|
+
paths = path_of(row)
|
|
92
|
+
paths = (paths,) if isinstance(paths, str) else paths
|
|
93
|
+
if any(filetypes.is_test_path(p) for p in paths):
|
|
94
|
+
hidden += 1
|
|
95
|
+
else:
|
|
96
|
+
kept.append(row)
|
|
97
|
+
note = f"{hidden} {noun if hidden == 1 else plural or noun + 's'} hidden; --full shows them" if hidden else None
|
|
98
|
+
return kept, note
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _empty_note(base, hidden_note, source_base=None) -> str:
|
|
102
|
+
"""The note that replaces a table with no rows left. When test rows were hidden the note has to
|
|
103
|
+
carry the count, since the caption goes with the table, and what is left is the source rows."""
|
|
104
|
+
return f"{source_base or base}; {hidden_note}" if hidden_note else base
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _keep(columns: list, rows: list, names) -> tuple:
|
|
108
|
+
"""Keep only the columns called `names`, in the given order, for both header and rows."""
|
|
109
|
+
index = {c[0]: i for i, c in enumerate(columns)}
|
|
110
|
+
picked = [index[n] for n in names]
|
|
111
|
+
return [columns[i] for i in picked], [tuple(r[i] for i in picked) for r in rows]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
FOLD_BUDGET = 24 # the most a non-path folding column (a function name, say) may take from the paths' room
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _path_indices(path_columns) -> tuple:
|
|
118
|
+
"""path_columns: the first N columns (an int) or explicit column indices."""
|
|
119
|
+
return tuple(range(path_columns)) if isinstance(path_columns, int) else tuple(path_columns)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _path_room(width, rows: list, columns: list, path_columns=1) -> int:
|
|
123
|
+
"""Characters available to each path column once the other cells and rich's padding are counted."""
|
|
124
|
+
if width is None:
|
|
125
|
+
return None
|
|
126
|
+
paths = _path_indices(path_columns)
|
|
127
|
+
other = [i for i in range(len(columns)) if i not in paths]
|
|
128
|
+
def charged(i):
|
|
129
|
+
widest = max([len(str(r[i])) for r in rows] + [len(columns[i][0])])
|
|
130
|
+
return min(widest, FOLD_BUDGET) if columns[i][1].get("overflow") == "fold" else widest # a folding column wraps instead
|
|
131
|
+
widest = sum(charged(i) for i in other)
|
|
132
|
+
padding = 3 * (len(columns) - 1)
|
|
133
|
+
return max(16, (width - widest - padding) // len(paths))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _shorten(rows: list, width, columns: list, path_columns=1) -> list:
|
|
137
|
+
room = _path_room(width, rows, columns, path_columns)
|
|
138
|
+
if room is None:
|
|
139
|
+
return rows
|
|
140
|
+
paths = _path_indices(path_columns)
|
|
141
|
+
return [tuple(textfmt.shorten_path(str(c), room) if i in paths else c for i, c in enumerate(r)) for r in rows]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- data ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def summary(report: dict) -> dict:
|
|
147
|
+
m = report["meta"]
|
|
148
|
+
ids = m.get("identities") or []
|
|
149
|
+
return {
|
|
150
|
+
"name": m.get("name", "repo"), "commits": m.get("commits", 0),
|
|
151
|
+
"first_date": m.get("first_date", "?"), "last_date": m.get("last_date", "?"),
|
|
152
|
+
"identities": len(ids), "branch": m.get("branch", "?"),
|
|
153
|
+
"lines": report["size"]["total_code"], "files": report["size"]["total_files"],
|
|
154
|
+
"languages": [l["name"] for l in report["size"]["languages"][:4]],
|
|
155
|
+
"since": m.get("since"),
|
|
156
|
+
"pulse": pulse(report),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pulse(report: dict) -> list:
|
|
161
|
+
"""One phrase each for the descriptive tables the default report leaves out."""
|
|
162
|
+
out = []
|
|
163
|
+
act = report.get("activity") or {}
|
|
164
|
+
days, hours = act.get("by_weekday") or [], act.get("by_hour") or []
|
|
165
|
+
if days and max(days):
|
|
166
|
+
day = WEEKDAYS[max(range(7), key=lambda i: days[i])]
|
|
167
|
+
when = f" at {max(range(24), key=lambda i: hours[i]):02d}:00" if hours and max(hours) else ""
|
|
168
|
+
out.append(f"most commits on {day}{when}")
|
|
169
|
+
total = sum(days)
|
|
170
|
+
if act.get("fix_commits") is not None and total:
|
|
171
|
+
out.append(f"{_pct(act['fix_commits'], total)} of commits are fixes")
|
|
172
|
+
if act.get("revert_commits") and total:
|
|
173
|
+
pct = _pct(act['revert_commits'], total)
|
|
174
|
+
if pct == "0%":
|
|
175
|
+
reverts = act['revert_commits']
|
|
176
|
+
out.append(f"{reverts} revert" if reverts == 1 else f"{reverts} reverts")
|
|
177
|
+
else:
|
|
178
|
+
out.append(f"{pct} of commits are reverts")
|
|
179
|
+
cohorts = report.get("cohorts") or {}
|
|
180
|
+
if cohorts:
|
|
181
|
+
label, lines = max(cohorts.items(), key=lambda kv: kv[1])
|
|
182
|
+
out.append(f"{_pct(lines, sum(cohorts.values()))} of surviving code from {label.replace('Code added in ', '')}")
|
|
183
|
+
elif _age_status(report) != "run":
|
|
184
|
+
out.append(_age_reason(report)) # the age table is --full only, so this is where a timeout shows
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _age_status(report: dict) -> str:
|
|
189
|
+
return (report["meta"].get("age") or {}).get("status", "run")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _age_reason(report: dict) -> str:
|
|
193
|
+
status = _age_status(report)
|
|
194
|
+
return {"timeout": "code age timed out", "skipped": "code age skipped"}.get(status, f"code age {status}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def watch_section(report: dict, full: bool = True, width=None) -> dict:
|
|
198
|
+
"""The files to keep an eye on, with the reasons in words. Paths stay whole here."""
|
|
199
|
+
ranked = watch.risks(report)
|
|
200
|
+
limit = WATCH_CAP if full is False else WATCH_FULL
|
|
201
|
+
rows = [(r["file"], " · ".join(r["reasons"])) for r in ranked[:limit]]
|
|
202
|
+
columns = [("file", PATH), ("why", {"overflow": "fold", "ratio": 3})]
|
|
203
|
+
since = report["meta"].get("since")
|
|
204
|
+
notes = ["ranked by churn × recent fixes × complexity × single ownership" + (f"; commits since {since}" if since else "")]
|
|
205
|
+
bt = watch.backtest(report)
|
|
206
|
+
status = report["meta"].get("backtest") or {}
|
|
207
|
+
if bt:
|
|
208
|
+
notes.append(f"6 months ago this list would have named {bt['hits']} of the {bt['fixed']} files fixed since "
|
|
209
|
+
f"(a random {bt['listed']} of the {bt['pool']} files that had changed more than once would name {bt['expected']})"
|
|
210
|
+
+ ("; whole history" if since else "")) # the backtest ignores the window
|
|
211
|
+
elif status.get("reason"):
|
|
212
|
+
notes.append(status["reason"])
|
|
213
|
+
elif status.get("status") in ("failed", "timeout"):
|
|
214
|
+
notes.append(f"backtest {status['status']}")
|
|
215
|
+
caption = "\n".join(notes)
|
|
216
|
+
return _section("Watch list", columns, rows, note=None if rows else watch.why_empty(report), caption=caption if rows else None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
RISK_CAP = 15
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def risk_section(risk: dict, base: str, full=True) -> dict:
|
|
223
|
+
"""The files a change touches, each with its watch score as a bar scaled to the repo's worst file."""
|
|
224
|
+
rows_all = risk["files"]
|
|
225
|
+
limit = _limit("Change risk", full, cap=RISK_CAP)
|
|
226
|
+
top = risk["max_score"] or 1.0
|
|
227
|
+
rows = [(r["file"], "▰" * round(10 * r["score"] / top) if r["score"] else "", " · ".join(r["reasons"])) for r in rows_all[:limit]]
|
|
228
|
+
columns = [("file", PATH), ("risk", {}), ("why", {"overflow": "fold", "ratio": 3})]
|
|
229
|
+
watched = risk["watched"]
|
|
230
|
+
notes = [f"total {risk['total']:.1f}; {watched} of these files {'is' if watched == 1 else 'are'} on the watch list"] if rows else []
|
|
231
|
+
more = _more(len(rows_all), limit)
|
|
232
|
+
if more:
|
|
233
|
+
notes.append(more)
|
|
234
|
+
return _section(f"Change risk ({len(rows_all)} files since {base})", columns, rows,
|
|
235
|
+
note=None if rows else f"no files changed since {base}", caption="\n".join(notes) or None)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def size_section(report: dict, full: bool = True, width=None) -> dict:
|
|
239
|
+
langs = report["size"]["languages"]
|
|
240
|
+
total = report["size"]["total_code"]
|
|
241
|
+
limit = _limit("Size by language", full)
|
|
242
|
+
rows = [(l["name"], l["files"], f"{l['code']:,}", _pct(l["code"], total), l["complexity"]) for l in langs[:limit]]
|
|
243
|
+
columns = [("language", {}), ("files", RIGHT), ("code", RIGHT), ("share", RIGHT), ("complexity", RIGHT)]
|
|
244
|
+
if full is not True:
|
|
245
|
+
columns, rows = _keep(columns, rows, ["language", "files", "code", "share"])
|
|
246
|
+
return _section("Size by language", columns, rows, caption=_more(len(langs), limit))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def people_section(report: dict, full: bool = True, width=None) -> dict:
|
|
250
|
+
ids = report["meta"].get("identities") or []
|
|
251
|
+
total_commits = sum(i["commits"] for i in ids)
|
|
252
|
+
surviving = report.get("theseus_authors") or {}
|
|
253
|
+
total_lines = sum(surviving.values())
|
|
254
|
+
limit = _limit("People", full)
|
|
255
|
+
rows = [(i["name"], i["email"], i["commits"], _pct(i["commits"], total_commits), _pct(surviving.get(i["name"], 0), total_lines)) for i in ids[:limit]]
|
|
256
|
+
columns = [("author", {}), ("email", {"style": "dim", "overflow": "fold"}), ("commits", RIGHT), ("share", RIGHT), ("surviving code", RIGHT)]
|
|
257
|
+
if full is not True:
|
|
258
|
+
columns, rows = _keep(columns, rows, ["author", "commits", "share", "surviving code"])
|
|
259
|
+
since = report["meta"].get("since")
|
|
260
|
+
notes = [f"commits since {since}; surviving code is for the whole tree"] if since else []
|
|
261
|
+
more = _more(len(ids), limit)
|
|
262
|
+
if more:
|
|
263
|
+
notes.append(more)
|
|
264
|
+
bots = report["meta"].get("bots") or []
|
|
265
|
+
if bots:
|
|
266
|
+
notes.append("bots left out: " + ", ".join(f"{b['name']} ({b['commits']}{' commits' if i == 0 else ''})" for i, b in enumerate(bots[:3]))
|
|
267
|
+
+ (f" and {len(bots) - 3} more" if len(bots) > 3 else ""))
|
|
268
|
+
merged = [i["name"] for i in ids if i.get("aliases")]
|
|
269
|
+
if merged:
|
|
270
|
+
who = ", ".join(merged[:3]) + (f" and {len(merged) - 3} more" if len(merged) > 3 else "")
|
|
271
|
+
notes.append(f"aliases merged for {who}; a .mailmap makes that permanent")
|
|
272
|
+
return _section("People", columns, rows, caption="\n".join(notes) or None)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def activity_section(report: dict, full: bool = True, width=None) -> dict:
|
|
276
|
+
act = report.get("activity") or {}
|
|
277
|
+
columns = [("weekday", {}), ("commits", RIGHT), ("share", RIGHT), ("", {"style": "blue"})]
|
|
278
|
+
if not act.get("by_weekday"):
|
|
279
|
+
return _section("Activity", columns, [], note="no activity data")
|
|
280
|
+
total = sum(act["by_weekday"])
|
|
281
|
+
rows = [(WEEKDAYS[i], n, _pct(n, total), _bar(n, total, 20)) for i, n in enumerate(act["by_weekday"])]
|
|
282
|
+
hours = act.get("by_hour") or []
|
|
283
|
+
notes = []
|
|
284
|
+
if hours and max(hours):
|
|
285
|
+
h = max(range(24), key=lambda i: hours[i])
|
|
286
|
+
notes.append(f"busiest hour {h:02d}:00 ({hours[h]} commits)")
|
|
287
|
+
if act.get("fix_commits") is not None and total:
|
|
288
|
+
notes.append(f"{_pct(act['fix_commits'], total)} of commits are fixes")
|
|
289
|
+
return _section("Activity", columns, rows, caption="\n".join(notes) or None)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _month_range(last: str, n: int = 12) -> list:
|
|
296
|
+
"""The n months ending at 'YYYY-MM', oldest first."""
|
|
297
|
+
y, m = int(last[:4]), int(last[5:7])
|
|
298
|
+
out = []
|
|
299
|
+
for _ in range(n):
|
|
300
|
+
out.append(f"{y:04d}-{m:02d}")
|
|
301
|
+
m -= 1
|
|
302
|
+
if m == 0:
|
|
303
|
+
y, m = y - 1, 12
|
|
304
|
+
return out[::-1]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _month_label(ym: str) -> str:
|
|
308
|
+
return f"{MONTHS[int(ym[5:7]) - 1]} {ym[:4]}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def timeline_section(report: dict, full: bool = True, width=None, months: int = 12) -> dict:
|
|
312
|
+
tl = (report.get("activity") or {}).get("timeline") or {}
|
|
313
|
+
if not tl:
|
|
314
|
+
return _section("Timeline", [("author", {})], [], note="no timeline data")
|
|
315
|
+
last = max(m for per in tl.values() for m in per)
|
|
316
|
+
span = _month_range(last, months)
|
|
317
|
+
since = report["meta"].get("since")
|
|
318
|
+
if since:
|
|
319
|
+
span = [m for m in span if m >= since[:7]] or span[-1:]
|
|
320
|
+
columns = [("author", {"overflow": "fold"})] + [(MONTHS[int(m[5:7]) - 1], RIGHT) for m in span]
|
|
321
|
+
in_window = {a: sum(per.get(m, 0) for m in span) for a, per in tl.items()}
|
|
322
|
+
# the run decided who is a bot from name and email; the timeline only has the name, so it asks the run
|
|
323
|
+
bots = {b["name"] for b in report["meta"].get("bots") or []}
|
|
324
|
+
ranked = [a for a in sorted(in_window, key=lambda a: -in_window[a]) if in_window[a] > 0 and a not in bots and not identity.is_bot(a)]
|
|
325
|
+
limit = _limit("Timeline", full)
|
|
326
|
+
rows = [(a, *[tl[a].get(m) or "·" for m in span]) for a in ranked[:limit]]
|
|
327
|
+
return _section(f"Timeline ({_month_label(span[0])} → {_month_label(span[-1])})", columns, rows, caption=_more(len(ranked), limit))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
|
|
331
|
+
"""Change frequency times size, Tornhill-style. Files no longer in the tree sort last."""
|
|
332
|
+
authors = {a["entity"]: a["n-authors"] for a in report.get("authors") or []}
|
|
333
|
+
ages = {a["entity"]: a["age-months"] for a in report.get("age") or []}
|
|
334
|
+
fixes = {f["entity"]: f["n-fixes"] for f in report.get("fixes") or []}
|
|
335
|
+
scored = hotspots.ranked(report)
|
|
336
|
+
scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
|
|
337
|
+
title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
|
|
338
|
+
limit = _limit("Hotspots", full)
|
|
339
|
+
series = (report.get("trend") or {}).get("files") or {}
|
|
340
|
+
last = report["meta"].get("last_date") or ""
|
|
341
|
+
def trend_cell(path):
|
|
342
|
+
s = series.get(path) or []
|
|
343
|
+
if full is True:
|
|
344
|
+
return trend.sparkline(s) or "-"
|
|
345
|
+
return trend.change_over_year(s, last) if last else "-"
|
|
346
|
+
rows = []
|
|
347
|
+
for h in scored[:limit]:
|
|
348
|
+
gone = h["code"] is None
|
|
349
|
+
rows.append((h["entity"], h["revs"], "-" if gone else f"{h['code']:,}", "-" if gone else h["complexity"],
|
|
350
|
+
"-" if gone else f"{h['score']:,}", fixes.get(h["entity"], 0), authors.get(h["entity"], "-"), ages.get(h["entity"], "-"),
|
|
351
|
+
trend_cell(h["entity"])))
|
|
352
|
+
columns = [("file", PATH), ("revs", RIGHT), ("lines", RIGHT), ("cplx", RIGHT), ("score", RIGHT), ("fixes", RIGHT), ("authors", RIGHT), ("idle", RIGHT),
|
|
353
|
+
("trend", RIGHT)]
|
|
354
|
+
if full is not True:
|
|
355
|
+
columns, rows = _keep(columns, rows, ["file", "revs", "lines", "fixes", "authors", "trend"])
|
|
356
|
+
rows = _shorten(rows, width, columns)
|
|
357
|
+
note = None if rows else _empty_note(None, hidden_note, "no source hotspots")
|
|
358
|
+
notes = [c for c in (_more(len(scored), limit), None if note else hidden_note) if c]
|
|
359
|
+
if series and full is not False: # the tight report keeps its captions short
|
|
360
|
+
notes.append(f"trend sampled for the top {TREND_TOP} hotspots") # the rest of the column is empty by design
|
|
361
|
+
return _section(title, columns, rows, note=note, caption="; ".join(notes) or None)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def coupling_section(report: dict, full: bool = True, width=None) -> dict:
|
|
365
|
+
pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
|
|
366
|
+
pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
|
|
367
|
+
limit = _limit("Change coupling", full)
|
|
368
|
+
rows = [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:limit]]
|
|
369
|
+
columns = [("file", PATH), ("changes with", PATH), ("degree", RIGHT), ("avg revs", RIGHT)]
|
|
370
|
+
if full is not True:
|
|
371
|
+
columns, rows = _keep(columns, rows, ["file", "changes with", "degree"])
|
|
372
|
+
rows = _shorten(rows, width, columns, path_columns=2)
|
|
373
|
+
note = None if rows else _empty_note("no pairs with 5+ shared revisions", hidden_note, "no source pairs with 5+ shared revisions")
|
|
374
|
+
notes = [c for c in (_more(len(pairs), limit), None if note else hidden_note) if c]
|
|
375
|
+
return _section("Change coupling", columns, rows, note=note, caption="; ".join(notes) or None)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def age_section(report: dict, full: bool = True, width=None) -> dict:
|
|
379
|
+
cohorts = report.get("cohorts") or {}
|
|
380
|
+
if not cohorts and _age_status(report) != "run":
|
|
381
|
+
return age_fallback_section(report)
|
|
382
|
+
total = sum(cohorts.values())
|
|
383
|
+
rows = [(label.replace("Code added in ", ""), f"{lines:,}", _pct(lines, total), _bar(lines, total)) for label, lines in cohorts.items()]
|
|
384
|
+
return _section("Surviving code by year written", [("year", {}), ("lines", RIGHT), ("share", RIGHT), ("", {"style": "blue"})], rows,
|
|
385
|
+
note=None if rows else "no age data")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def age_fallback_section(report: dict) -> dict:
|
|
389
|
+
"""When the blame pass did not run: net lines added per year from the log, or failing that,
|
|
390
|
+
paths by the year they were last changed."""
|
|
391
|
+
reason = _age_reason(report)
|
|
392
|
+
net = (report.get("activity") or {}).get("net_by_year") or {}
|
|
393
|
+
if net:
|
|
394
|
+
total = sum(v for v in net.values() if v > 0)
|
|
395
|
+
rows = [(y, f"{v:,}", _pct(v, total) if v > 0 else "-", _bar(v, total) if v > 0 else "") for y, v in net.items()]
|
|
396
|
+
return _section("Net lines added by year", [("year", {}), ("net lines", RIGHT), ("share", RIGHT), ("", {"style": "blue"})], rows,
|
|
397
|
+
caption=f"{reason}; approximation from the log, not a blame")
|
|
398
|
+
last = report["meta"].get("last_date") or ""
|
|
399
|
+
columns = [("year", {}), ("paths", RIGHT), ("share", RIGHT), ("", {"style": "blue"})]
|
|
400
|
+
try:
|
|
401
|
+
end_year, end_month = int(last[:4]), int(last[5:7])
|
|
402
|
+
except ValueError:
|
|
403
|
+
return _section("Paths in history by year last changed", columns, [], note=f"no age data ({reason})")
|
|
404
|
+
counts = {}
|
|
405
|
+
for row in report.get("age") or []:
|
|
406
|
+
months_back = end_month - 1 - int(row["age-months"])
|
|
407
|
+
year = end_year + months_back // 12
|
|
408
|
+
counts[year] = counts.get(year, 0) + 1
|
|
409
|
+
total = sum(counts.values())
|
|
410
|
+
rows = [(str(y), n, _pct(n, total), _bar(n, total)) for y, n in sorted(counts.items(), reverse=True)]
|
|
411
|
+
return _section("Paths in history by year last changed", columns, rows, note=None if rows else f"no age data ({reason})", caption=reason)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
CCN_FLOOR = 10 # lizard's own "complex" threshold; below it a function is not worth a row
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def functions_section(report: dict, full: bool = True, width=None) -> dict:
|
|
418
|
+
"""Functions at or over the complexity floor, worst first, from lizard when it is installed."""
|
|
419
|
+
measured = report.get("functions") or []
|
|
420
|
+
funcs = sorted((f for f in measured if f["ccn"] >= CCN_FLOOR), key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
|
|
421
|
+
funcs, hidden_note = _hide_tests(funcs, lambda f: f["file"], full, noun="function in a test file", plural="functions in test files")
|
|
422
|
+
limit = _limit("Complex functions", full)
|
|
423
|
+
rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
|
|
424
|
+
columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
|
|
425
|
+
if full is not True:
|
|
426
|
+
rows = _shorten(rows, width, columns, path_columns=(1,))
|
|
427
|
+
status = (report["meta"].get("functions") or {}).get("status", "skipped" if not measured else "run")
|
|
428
|
+
reason = {"timeout": "function metrics timed out", "failed": "function metrics failed (see run.log)",
|
|
429
|
+
"skipped": "no function metrics (install lizard)"}.get(status, "function metrics did not complete")
|
|
430
|
+
partial = f"partial: {reason}" if measured and status in ("timeout", "failed") else None # the step streams rows, so a stopped one leaves some
|
|
431
|
+
if not measured and status != "run":
|
|
432
|
+
note = reason
|
|
433
|
+
elif not measured:
|
|
434
|
+
note = "no functions found in the code files"
|
|
435
|
+
elif not rows:
|
|
436
|
+
counted = f"({len(measured):,} function{'s' if len(measured) != 1 else ''} measured{'; ' + partial if partial else ''})"
|
|
437
|
+
note = _empty_note(f"nothing over complexity {CCN_FLOOR} {counted}", hidden_note,
|
|
438
|
+
f"nothing over complexity {CCN_FLOOR} in source files {counted}")
|
|
439
|
+
else:
|
|
440
|
+
note = None
|
|
441
|
+
caption = "; ".join(c for c in (_more(len(funcs), limit), None if note else hidden_note, partial) if c) or None
|
|
442
|
+
return _section("Complex functions", columns, rows, note=note, caption=caption)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
446
|
+
"""Ownership by area of the tree: who wrote most of each directory, gone owners marked."""
|
|
447
|
+
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
448
|
+
gone = {g["name"] for g in loss.gone(report, months)}
|
|
449
|
+
areas = loss.areas(report.get("ownership") or [], gone) # every area the map showed before, tests included
|
|
450
|
+
limit = _limit("Knowledge map", full)
|
|
451
|
+
rows = []
|
|
452
|
+
for a in areas[:limit]:
|
|
453
|
+
owners = [f"{name}{' (gone)' if name in gone else ''} ({_pct(n, a['lines'])})" for name, n in a["owners"][:2]] + ["-"]
|
|
454
|
+
lost = f"{100 * a['lost_share']:.0f}%" if a["lines"] else "-"
|
|
455
|
+
rows.append((a["area"], f"{a['lines']:,}", a["authors"], lost if gone else "-", owners[0], owners[1]))
|
|
456
|
+
columns = [("area", PATH), ("lines added", RIGHT), ("authors", RIGHT), ("lost", RIGHT), ("main owner", {}), ("second", {})]
|
|
457
|
+
if full is not True:
|
|
458
|
+
columns, rows = _keep(columns, rows, ["area", "lines added", "main owner", "second"])
|
|
459
|
+
notes = [c for c in (_more(len(areas), limit),) if c]
|
|
460
|
+
if gone:
|
|
461
|
+
notes.append(f"gone = no commits in the {months} months before {report['meta'].get('last_date')}"
|
|
462
|
+
+ ("; gone and lost are measured over the whole history" if report["meta"].get("since") else ""))
|
|
463
|
+
return _section("Knowledge map", columns, rows, note=None if rows else "no ownership data", caption="\n".join(notes) or None)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def health_section(report: dict, full: bool = True, width=None) -> dict:
|
|
467
|
+
rows = [(r["name"], r["value"], "*" * r["concern"], r["ref"]) for r in report.get("sizer") or []]
|
|
468
|
+
return _section("Repo health (git-sizer concerns)", [("metric", {}), ("value", RIGHT), ("concern", {}), ("object", FOLD)], rows,
|
|
469
|
+
note=None if rows else "nothing flagged")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
BUILDERS = [watch_section, size_section, people_section, knowledge_section, activity_section, timeline_section,
|
|
473
|
+
hotspots_section, coupling_section, age_section, functions_section, health_section]
|
|
474
|
+
DESCRIPTIVE = {"size", "activity", "age"} # interesting once, rarely change what you do next: `--full` only
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def sections(report: dict, full: bool = True, width=None) -> list:
|
|
478
|
+
"""Every section as a dict with an `id` (the builder's name without _section). The default terminal
|
|
479
|
+
report (`full` False) leaves the descriptive ones out; `full` True and Markdown keep them."""
|
|
480
|
+
out = []
|
|
481
|
+
for b in BUILDERS:
|
|
482
|
+
sid = b.__name__[:-len("_section")]
|
|
483
|
+
if full is False and sid in DESCRIPTIVE:
|
|
484
|
+
continue
|
|
485
|
+
sec = b(report, full, width)
|
|
486
|
+
sec["id"] = sid
|
|
487
|
+
out.append(sec)
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def secrets_line(report: dict) -> str:
|
|
492
|
+
rows = report.get("secrets") or []
|
|
493
|
+
groups = leaks.group(rows)
|
|
494
|
+
places = sum(g["places"] for g in groups)
|
|
495
|
+
line = (f"Secrets: {len(groups)} distinct value{'s' if len(groups) != 1 else ''} in {places} place{'s' if places != 1 else ''}"
|
|
496
|
+
if groups else "Secrets: none found")
|
|
497
|
+
skipped = leaks.placeholders(rows)
|
|
498
|
+
if skipped:
|
|
499
|
+
line += f"; {skipped} placeholder-shaped hit{'s' if skipped != 1 else ''} left out"
|
|
500
|
+
return line
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# --- rich ------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
def header(report: dict, findings: list = ()) -> Panel:
|
|
506
|
+
s = summary(report)
|
|
507
|
+
body = Text()
|
|
508
|
+
body.append(f"{s['commits']} commits", style="bold")
|
|
509
|
+
body.append(f" · {s['first_date']} → {s['last_date']}")
|
|
510
|
+
if s["since"]:
|
|
511
|
+
body.append(f" · since {s['since']}", style="yellow")
|
|
512
|
+
body.append(f" · {s['identities']} {'identity' if s['identities'] == 1 else 'identities'} · branch {s['branch']}\n")
|
|
513
|
+
body.append(f"{s['lines']:,} lines in {s['files']} files · {', '.join(s['languages']) or 'unknown'}\n")
|
|
514
|
+
if s["pulse"]:
|
|
515
|
+
body.append(" · ".join(s["pulse"]) + "\n", style="dim")
|
|
516
|
+
tally = textfmt.tally(list(findings))
|
|
517
|
+
worst = next((f["severity"] for f in findings), None)
|
|
518
|
+
body.append(tally, style=SEVERITY_STYLE.get(worst, "green"))
|
|
519
|
+
return Panel(body, title=f"[bold]{s['name']}[/bold]", title_align="left", border_style="blue")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def findings_panel(findings: list) -> Panel:
|
|
523
|
+
if not findings:
|
|
524
|
+
return Panel(Text("Nothing flagged.", style="green"), title="Findings", title_align="left", border_style="green")
|
|
525
|
+
grid = Table.grid(padding=(0, 1))
|
|
526
|
+
grid.add_column(no_wrap=True)
|
|
527
|
+
grid.add_column(overflow="fold")
|
|
528
|
+
for g in textfmt.group_findings(findings):
|
|
529
|
+
style = SEVERITY_STYLE[g["severity"]]
|
|
530
|
+
body = Text(g["title"], style=style)
|
|
531
|
+
for item in g["items"]:
|
|
532
|
+
body.append(f"\n{item}", style="dim" if len(g["items"]) == 1 else "")
|
|
533
|
+
for advice in g["advice"]:
|
|
534
|
+
body.append(f"\n↳ {advice}", style="dim italic")
|
|
535
|
+
grid.add_row(Text(SEVERITY_MARK[g["severity"]], style=style), body)
|
|
536
|
+
return Panel(grid, title=f"Findings ({len(findings)})", title_align="left", border_style=SEVERITY_STYLE[findings[0]["severity"]])
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def cell_style(column: str, value: str):
|
|
540
|
+
"""A style for values that crossed a threshold, or None."""
|
|
541
|
+
try:
|
|
542
|
+
if column == "share":
|
|
543
|
+
n = int(value.rstrip("%"))
|
|
544
|
+
return HOT if n >= 50 else (WARM if n >= 20 else None)
|
|
545
|
+
if column == "degree" and int(value.rstrip("%")) >= 90:
|
|
546
|
+
return HOT
|
|
547
|
+
if column == "fixes" and int(value) >= 5:
|
|
548
|
+
return HOT
|
|
549
|
+
except ValueError:
|
|
550
|
+
pass
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _base_title(title: str) -> str:
|
|
555
|
+
return title.split(" (")[0]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _cell(column: str, value: str, bars: bool) -> Text:
|
|
559
|
+
style = cell_style(column, value) or ""
|
|
560
|
+
if bars and column == "share" and value.endswith("%"):
|
|
561
|
+
n = int(value[:-1])
|
|
562
|
+
return Text(f"{value:>4} ", style=style) + Text("▰" * max(1, n // 10) if n else "", style=BAR)
|
|
563
|
+
return Text(value, style=style)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def rich_table(sec: dict):
|
|
567
|
+
"""A table for a section: no title (the caller prints the heading), caption underneath,
|
|
568
|
+
coloured headers, bold key column, dimmed secondary columns, zebra rows, threshold colours."""
|
|
569
|
+
key = KEY_METRIC.get(_base_title(sec["title"]))
|
|
570
|
+
kw = {}
|
|
571
|
+
if sec.get("caption"):
|
|
572
|
+
kw = {"caption": escape(sec["caption"]), "caption_justify": "left", "caption_style": "dim italic"} # a name like renovate[bot] is not markup
|
|
573
|
+
fits = max((len(line) for line in (sec.get("caption") or "").split("\n")), default=0)
|
|
574
|
+
bars = "share" in sec["columns"] and "" not in sec["columns"] # inline bars only where there is no bar column
|
|
575
|
+
t = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, min_width=fits, header_style=HEADER,
|
|
576
|
+
row_styles=ROW_STYLES, border_style="#3a4150", **kw)
|
|
577
|
+
for i, (name, opts) in enumerate(zip(sec["columns"], sec["col_opts"])):
|
|
578
|
+
o = dict(opts)
|
|
579
|
+
o.setdefault("style", "bold" if i == 0 else ("" if name in (key, "share", "") else "dim"))
|
|
580
|
+
if bars and name == "share":
|
|
581
|
+
o["justify"] = "left" # the percentage is padded to four characters, so the bars line up
|
|
582
|
+
t.add_column(name, **o)
|
|
583
|
+
for row in sec["rows"]:
|
|
584
|
+
t.add_row(*[_cell(col, cell, bars) for col, cell in zip(sec["columns"], row)])
|
|
585
|
+
return t
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def heading(sec: dict) -> Text:
|
|
589
|
+
symbol = SYMBOLS.get(_base_title(sec["title"]), "•")
|
|
590
|
+
return Text(f"{symbol} ", style=ACCENT) + Text(sec["title"], style=f"bold {ACCENT}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def section_block(sec: dict):
|
|
594
|
+
"""Heading plus table, or heading plus a dim note for an empty section."""
|
|
595
|
+
if not sec["rows"] and sec["note"]:
|
|
596
|
+
return heading(sec) + Text(f": {sec['note']}", style="dim")
|
|
597
|
+
return Group(heading(sec), Padding(rich_table(sec), (0, 0, 0, 2)))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def print_section(console: Console, sec: dict) -> None:
|
|
601
|
+
"""Blank line, then the section's heading and table (or note)."""
|
|
602
|
+
console.print(Text(""))
|
|
603
|
+
console.print(section_block(sec))
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# small tables that sit side by side when the terminal is wide enough, by section id; a section pairs at most once
|
|
607
|
+
PAIRS = [("size", "people"), ("activity", "age"), ("people", "knowledge")]
|
|
608
|
+
PAIR_GAP = 3
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _partners(secs: list) -> dict:
|
|
612
|
+
present, taken, out = {s["id"] for s in secs}, set(), {}
|
|
613
|
+
for a, b in PAIRS:
|
|
614
|
+
if a in present and b in present and a not in taken and b not in taken:
|
|
615
|
+
out[a], out[b] = b, a
|
|
616
|
+
taken.update((a, b))
|
|
617
|
+
return out
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def report(report: dict, findings: list, console: Console, full: bool = False, risk: dict = None, base: str = None) -> None:
|
|
621
|
+
console.print(header(report, findings))
|
|
622
|
+
console.print(findings_panel(findings))
|
|
623
|
+
secs = sections(report, full=full, width=console.width)
|
|
624
|
+
by_id = {s["id"]: s for s in secs}
|
|
625
|
+
partners = _partners(secs) if console.width >= SIDE_BY_SIDE_MIN_WIDTH else {}
|
|
626
|
+
done = set()
|
|
627
|
+
for sec in secs:
|
|
628
|
+
if sec["id"] in done:
|
|
629
|
+
continue
|
|
630
|
+
other = partners.get(sec["id"])
|
|
631
|
+
if other and other not in done:
|
|
632
|
+
left, right = section_block(sec), section_block(by_id[other])
|
|
633
|
+
if console.measure(left).maximum + PAIR_GAP + console.measure(right).maximum <= console.width:
|
|
634
|
+
console.print(Text(""))
|
|
635
|
+
console.print(Columns([left, right], padding=(0, PAIR_GAP), equal=False, expand=False))
|
|
636
|
+
done.update((sec["id"], other))
|
|
637
|
+
continue
|
|
638
|
+
print_section(console, sec) # stacked, with the usual blank line before it
|
|
639
|
+
done.add(sec["id"])
|
|
640
|
+
if risk is not None and sec["id"] == "watch":
|
|
641
|
+
print_section(console, risk_section(risk, base, full)) # the change, right under the list it is scored against
|
|
642
|
+
console.print(Text(""))
|
|
643
|
+
console.print(Text(secrets_line(report), style="red" if leaks.group(report.get("secrets") or []) else "green"))
|
|
644
|
+
console.print(Text(f"Full results and plots in {report['out_dir']}", style="dim"), soft_wrap=True)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# --- markdown / json -------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
def _md_cell(cell: str) -> str:
|
|
650
|
+
return cell.replace("|", "\\|").replace("\n", " ")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _md_findings(findings: list) -> list:
|
|
654
|
+
if not findings:
|
|
655
|
+
return ["Nothing flagged."]
|
|
656
|
+
out = []
|
|
657
|
+
for g in textfmt.group_findings(findings):
|
|
658
|
+
line = f"- **{g['severity']}** {g['title']} — " + "; ".join(g["items"])
|
|
659
|
+
line += "".join(f" _{advice}_" for advice in g["advice"])
|
|
660
|
+
out.append(line)
|
|
661
|
+
return out
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def markdown(report: dict, findings: list, full: bool = False, risk: dict = None, base: str = None) -> str:
|
|
665
|
+
s = summary(report)
|
|
666
|
+
out = [f"# {s['name']}", "",
|
|
667
|
+
f"{s['commits']} commits · {s['first_date']} → {s['last_date']}" + (f" · since {s['since']}" if s["since"] else "") + f" · {s['identities']} {'identity' if s['identities'] == 1 else 'identities'} · branch {s['branch']} ",
|
|
668
|
+
f"{s['lines']:,} lines in {s['files']} files · {', '.join(s['languages']) or 'unknown'}" + (" " if s["pulse"] else ""),
|
|
669
|
+
*([" · ".join(s["pulse"])] if s["pulse"] else []), "",
|
|
670
|
+
"## Findings", ""]
|
|
671
|
+
out += _md_findings(findings)
|
|
672
|
+
secs = sections(report, full=True if full else "markdown")
|
|
673
|
+
if risk is not None:
|
|
674
|
+
after = next((i for i, sec in enumerate(secs) if sec["id"] == "watch"), len(secs) - 1)
|
|
675
|
+
secs = secs[:after + 1] + [risk_section(risk, base, full=True if full else "markdown")] + secs[after + 1:]
|
|
676
|
+
for sec in secs:
|
|
677
|
+
out += ["", f"## {sec['title']}", ""]
|
|
678
|
+
if not sec["rows"]:
|
|
679
|
+
out.append(f"_{sec['note'] or 'nothing'}_")
|
|
680
|
+
continue
|
|
681
|
+
out.append("| " + " | ".join(sec["columns"]) + " |")
|
|
682
|
+
out.append("| " + " | ".join("---:" if o.get("justify") == "right" else "---" for o in sec["col_opts"]) + " |")
|
|
683
|
+
out += ["| " + " | ".join(_md_cell(c) for c in row) + " |" for row in sec["rows"]]
|
|
684
|
+
if sec.get("caption"):
|
|
685
|
+
out += ["", f"_{sec['caption']}_"]
|
|
686
|
+
out += ["", secrets_line(report), "", f"Full results and plots in {report['out_dir']}", ""]
|
|
687
|
+
return "\n".join(out)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def to_json(report: dict, findings: list, risk: dict = None) -> dict:
|
|
691
|
+
out = {**{k: v for k, v in report.items() if k != "backtest"}, "findings": findings, # the sub-report is a report of its own
|
|
692
|
+
"watch": [{k: v for k, v in r.items() if k != "function"} | {"function": r["function"]["function"] if r["function"] else None}
|
|
693
|
+
for r in watch.risks(report)[:WATCH_FULL]]}
|
|
694
|
+
bt = watch.backtest(report)
|
|
695
|
+
if bt is not None:
|
|
696
|
+
out["watch_backtest"] = bt
|
|
697
|
+
if risk is not None:
|
|
698
|
+
out["change_risk"] = risk
|
|
699
|
+
return out
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# --- portfolio -------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
def portfolio_section(reports: list) -> dict:
|
|
705
|
+
"""reports: [(name, report, findings)] -> one row per repository."""
|
|
706
|
+
rows = []
|
|
707
|
+
for name, rep, found in reports:
|
|
708
|
+
s = summary(rep)
|
|
709
|
+
surviving = rep.get("theseus_authors") or {}
|
|
710
|
+
total = sum(surviving.values())
|
|
711
|
+
bus = _pct(max(surviving.values()), total) if surviving else "-"
|
|
712
|
+
worst = f"{found[0]['severity']}: {found[0]['title']}" if found else "-"
|
|
713
|
+
rows.append((name, s["commits"], s["identities"], bus, len(leaks.group(rep.get("secrets") or [])), f"{s['lines']:,}", worst))
|
|
714
|
+
return _section(f"Portfolio ({len(reports)} repositories)",
|
|
715
|
+
[("repo", {"overflow": "fold"}), ("commits", RIGHT), ("people", RIGHT), ("top author", RIGHT),
|
|
716
|
+
("secrets", RIGHT), ("lines", RIGHT), ("worst finding", {"overflow": "fold", "ratio": 2})], rows,
|
|
717
|
+
note=None if rows else "no repositories")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def portfolio_markdown(owner: str, reports: list) -> str:
|
|
721
|
+
sec = portfolio_section(reports)
|
|
722
|
+
out = [f"# {owner}", "", f"{len(reports)} repositories analysed with gitmole.", "", f"## {sec['title']}", ""]
|
|
723
|
+
if sec["rows"]:
|
|
724
|
+
out.append("| " + " | ".join(sec["columns"]) + " |")
|
|
725
|
+
out.append("| " + " | ".join("---:" if o.get("justify") == "right" else "---" for o in sec["col_opts"]) + " |")
|
|
726
|
+
out += ["| " + " | ".join(_md_cell(c) for c in row) + " |" for row in sec["rows"]]
|
|
727
|
+
else:
|
|
728
|
+
out.append(f"_{sec['note']}_")
|
|
729
|
+
for name, rep, found in reports:
|
|
730
|
+
out += ["", f"## {name}", ""]
|
|
731
|
+
out += _md_findings(found)
|
|
732
|
+
return "\n".join(out) + "\n"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def portfolio_json(owner: str, reports: list) -> dict:
|
|
736
|
+
return {"owner": owner, "repos": [{"name": n, "summary": summary(r), "findings": f, "out_dir": r["out_dir"]} for n, r, f in reports]}
|