Skip to content

Commit fe29198

Browse files
authored
Merge pull request #12 from lex0c/feat/dev-profile-top-commits
Feat/dev profile top commits
2 parents 6885767 + df3cbd5 commit fe29198

8 files changed

Lines changed: 351 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ Each profile includes:
296296
- **Collaboration**: top devs sharing the same files (ranked by `shared_lines` = Σ min(linesA, linesB))
297297
- **Weekend %**: off-hours work ratio
298298
- **Top files**: most impacted files by churn
299+
- **Top commits**: the dev's largest individual commits by lines changed (additions + deletions); surfaces vendored drops and bulk rewrites that can skew the totals
299300

300301
### Coupling analysis
301302

docs/METRICS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ Per-developer report combining multiple metrics.
219219
| Specialization | Herfindahl index over the **full** per-directory file-count distribution: Σ pᵢ² where pᵢ is the share of the dev's files in directory i. 1 = all files in one directory (narrow specialist); 1/N for a uniform spread across N directories; approaches 0 as the distribution widens. Computed before the top-5 Scope truncation so it reflects actual breadth. Labels (see `specBroadGeneralistMax`, `specBalancedMax`, `specFocusedMax` constants): `< 0.15` broad generalist, `< 0.35` balanced, `< 0.7` focused specialist, `≥ 0.7` narrow specialist. Herfindahl, not Gini, because Gini would collapse "1 file in 1 dir" and "1 file in each of 5 dirs" to the same value (both have zero inequality among buckets), which misses the specialization distinction. **Measures file distribution, not domain expertise** — see caveat below. **Display vs raw:** CLI and HTML show the value rounded to 3 decimals (`%.3f`) for readability; JSON output preserves the full float64. Band classification runs against the raw float, so a value like 0.149 lands in `broad generalist` even though %.2f would have rounded it to `0.15`. JSON consumers that reproduce the banding must use the raw value, not a rounded version. |
220220
| Contribution type | Based on del/add ratio: growth (<0.4), balanced (0.4-0.8), refactor (>0.8) |
221221
| Collaborators | Top 5 devs sharing code with this dev. Ranked by `shared_lines` (Σ min(linesA, linesB) across shared files), tiebreak `shared_files`, then email. Same `shared_lines` semantics as the Developer Network metric — discounts trivial one-line touches so "collaborator" reflects real overlap. |
222+
| Top commits | The dev's top 10 commits by `lines_changed` (additions + deletions), tiebreak `sha asc`. Same ranking key and tiebreak as the dataset-level Top Commits section so the two read consistently side by side. Messages follow the same 80-character truncation rule and are only populated when `extract` ran with `--include-commit-messages`. Rendered in the CLI `profile` stat and in the standalone `--email` HTML profile page; intentionally omitted from the main report's Developer Profiles cards to keep those compact. **Divergence from dataset-level Top Commits:** commits with a zero `author_date` are dropped from the per-dev list (they share the guard that protects grid/monthly bucketing); the dataset-level section renders them as `0001-01-01`. Negligible in practice — the JSONL extract always emits `author_date` — but worth knowing if you compare the two views. |
222223

223224
## Top Commits
224225

@@ -373,7 +374,7 @@ Every ranking function has an explicit tiebreaker so the same input produces the
373374
| `dev-network` | shared_lines | shared_files |
374375
| `profile` | commits | email asc |
375376

376-
A third-level tiebreaker on path/sha/email asc is applied where primary and secondary can both tie (`churn-risk`, `coupling`, `dev-network`) so ordering is stable even with exact equality on the first two keys. Inside each profile, the `TopFiles`, `Scope`, and `Collaborators` sub-lists are also sorted with explicit tiebreakers (path / dir / email asc) so their internal ordering is deterministic too.
377+
A third-level tiebreaker on path/sha/email asc is applied where primary and secondary can both tie (`churn-risk`, `coupling`, `dev-network`) so ordering is stable even with exact equality on the first two keys. Inside each profile, the `TopFiles`, `TopCommits`, `Scope`, and `Collaborators` sub-lists are also sorted with explicit tiebreakers (path / sha / dir / email asc) so their internal ordering is deterministic too.
377378

378379
Inside `busfactor`, the per-file `TopDevs` list is sorted by lines desc with an email asc tiebreaker. Without it, binary assets and small files where two devs contribute equal lines (e.g. `.gif`, `.png`, one-line configs) produced a different `TopDevs` email order on every run.
379380

internal/report/profile_template.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,27 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
190190
</table>
191191
{{end}}
192192
193+
{{if .Profile.TopCommits}}
194+
{{$hasMsg := (index .Profile.TopCommits 0).Message}}
195+
<h2>Top Commits</h2>
196+
<p class="hint">This developer's largest individual commits by lines changed (additions + deletions). A handful of outsized commits (vendored drops, bulk renames, generated code) reads very differently from a steady stream of medium-sized ones, even when the totals match.</p>
197+
<table>
198+
<tr><th>SHA</th><th>Date</th><th>Lines</th><th>Files</th>{{if $hasMsg}}<th>Message</th>{{end}}</tr>
199+
{{range .Profile.TopCommits}}
200+
<tr>
201+
<td class="mono">{{printf "%.12s" .SHA}}</td>
202+
<td class="mono" style="font-size:11px;">{{.Date}}</td>
203+
<td>{{thousands .LinesChanged}}</td>
204+
<td>{{thousands .FilesChanged}}</td>
205+
{{if $hasMsg}}<td class="truncate">{{.Message}}</td>{{end}}
206+
</tr>
207+
{{end}}
208+
{{if gt .Profile.TopCommitsHidden 0}}
209+
<tr><td colspan="{{if $hasMsg}}5{{else}}4{{end}}" style="color:#656d76; font-style:italic; text-align:center;">+{{.Profile.TopCommitsHidden}} more commits not shown</td></tr>
210+
{{end}}
211+
</table>
212+
{{end}}
213+
193214
{{if .ActivityYears}}
194215
<h2 style="display:flex; justify-content:space-between; align-items:center;">Activity <button onclick="var h=document.getElementById('prof-act-heatmap'),t=document.getElementById('prof-act-table');h.hidden=!h.hidden;t.hidden=!t.hidden;this.textContent=h.hidden?'heatmap':'table'" style="font-size:11px; font-weight:normal; padding:2px 10px; border:1px solid #d0d7de; border-radius:4px; background:#f6f8fa; color:#24292f; cursor:pointer;">table</button></h2>
195216
<p class="hint">Monthly commit heatmap. Darker = more commits. Gaps = inactive periods; steady cadence signals healthy pace. Hover for details; toggle to table for exact numbers. · {{docRef "activity"}}</p>

internal/report/report_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,58 @@ func TestGenerateProfile_SmokeRender(t *testing.T) {
256256
}
257257
}
258258

259+
func TestProfileTmpl_TopCommitsShortSHAAndMessageGate(t *testing.T) {
260+
// Two invariants on the per-dev Top Commits block:
261+
// 1. SHAs shorter than 12 chars must not crash template execution.
262+
// LoadJSONL does not enforce SHA length, and the previous
263+
// {{slice .SHA 0 12}} raised "index out of range" on any short
264+
// input, aborting profile generation for the whole page.
265+
// 2. The Message column header and cells must drop out when no
266+
// commit carries a message — mirrors the dataset-level Top
267+
// Commits convention so `extract --include-commit-messages` is
268+
// a strict opt-in, not a silent empty-column penalty.
269+
data := ProfileReportData{
270+
GeneratedAt: "2024-01-01 00:00",
271+
RepoName: "t",
272+
Profile: stats.DevProfile{
273+
Name: "N", Email: "n@x",
274+
Commits: 1, ActiveDays: 1,
275+
FirstDate: "2024-01-01", LastDate: "2024-01-01",
276+
TopCommits: []stats.DevCommit{
277+
{SHA: "c1", Date: "2024-01-01", LinesChanged: 10, FilesChanged: 1},
278+
{SHA: "abcdef1234567890", Date: "2024-01-02", LinesChanged: 20, FilesChanged: 2},
279+
},
280+
},
281+
}
282+
var buf bytes.Buffer
283+
if err := profileTmpl.Execute(&buf, data); err != nil {
284+
t.Fatalf("profileTmpl.Execute: %v", err)
285+
}
286+
out := buf.String()
287+
288+
if !strings.Contains(out, ">c1<") {
289+
t.Errorf("short 2-char SHA should render intact, got:\n%s", out)
290+
}
291+
if !strings.Contains(out, ">abcdef123456<") {
292+
t.Errorf("16-char SHA should truncate to 12, got:\n%s", out)
293+
}
294+
if strings.Contains(out, "abcdef1234567890") {
295+
t.Errorf("16-char SHA leaked past the 12-char cap")
296+
}
297+
298+
// Message column must be absent when all TopCommits have empty messages.
299+
topBlock := out
300+
if idx := strings.Index(out, "<h2>Top Commits"); idx >= 0 {
301+
topBlock = out[idx:]
302+
}
303+
if end := strings.Index(topBlock, "</table>"); end >= 0 {
304+
topBlock = topBlock[:end]
305+
}
306+
if strings.Contains(topBlock, "<th>Message</th>") {
307+
t.Errorf("Message column should not render when no commit has a message, got:\n%s", topBlock)
308+
}
309+
}
310+
259311
func TestGenerateProfile_UnknownEmail(t *testing.T) {
260312
ds := loadFixture(t)
261313
var buf bytes.Buffer

internal/report/template.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
343343
<tr><th>SHA</th><th>Author</th><th>Date</th><th>Lines</th><th>Files</th>{{if and (gt (len .TopCommits) 0) (index .TopCommits 0).Message}}<th>Message</th>{{end}}</tr>
344344
{{range .TopCommits}}
345345
<tr>
346-
<td class="mono">{{slice .SHA 0 12}}</td>
346+
<td class="mono">{{printf "%.12s" .SHA}}</td>
347347
<td>{{.AuthorName}}</td>
348348
<td class="mono">{{.Date}}</td>
349349
<td>{{thousands .LinesChanged}}</td>
@@ -372,7 +372,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
372372
{{end}}
373373
374374
{{if .Profiles}}
375-
<h2>Developer Profiles</h2>
375+
<h2>Developer Profiles{{if lt (len .Profiles) .Summary.TotalDevs}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .Profiles)}} of {{thousands .Summary.TotalDevs}}</span>{{end}}</h2>
376376
<p class="hint">Per-developer view. Use to spot silos (narrow scope + few collaborators), knowledge concentration (high pace on few directories), and cultural patterns (weekend or refactor-heavy work). · {{docRef "profile"}}</p>
377377
{{range .Profiles}}
378378
<div style="background:#fff; border:1px solid #d0d7de; border-radius:6px; padding:16px; margin-bottom:16px;">

internal/stats/format.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,29 @@ func (f *Formatter) PrintProfiles(profiles []DevProfile) error {
520520
}
521521
}
522522

523+
if len(p.TopCommits) > 0 {
524+
fmt.Fprintln(f.w)
525+
fmt.Fprintln(f.w, " Top commits:")
526+
for _, tc := range p.TopCommits {
527+
// Defensive slice: LoadJSONL does not validate SHA
528+
// length, so hand-built fixtures (e.g. "c1") or a
529+
// future ingest path that emits abbreviated SHAs
530+
// would panic on a fixed tc.SHA[:12]. The other SHA
531+
// slice sites in this file (TopCommits / LatestCommits)
532+
// carry the same latent risk and are left as-is so
533+
// this change stays scoped to the new Top-commits block.
534+
sha := tc.SHA
535+
if len(sha) > 12 {
536+
sha = sha[:12]
537+
}
538+
fmt.Fprintf(f.w, " %s %s %6d lines %3d files %s\n",
539+
sha, tc.Date, tc.LinesChanged, tc.FilesChanged, tc.Message)
540+
}
541+
if p.TopCommitsHidden > 0 {
542+
fmt.Fprintf(f.w, " ... (+%d more commits not shown)\n", p.TopCommitsHidden)
543+
}
544+
}
545+
523546
if len(p.MonthlyActivity) > 0 {
524547
fmt.Fprintln(f.w, " Activity:")
525548
maxCommits := 0

internal/stats/stats.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,14 @@ type DevProfile struct {
13281328
// whole footprint or just a sample. Zero when the dev's touched
13291329
// file count fits in 10.
13301330
TopFilesHidden int
1331+
// TopCommits is the dev's largest commits by LinesChanged (add+del),
1332+
// capped at 10. Mirrors the dataset-level TopCommits metric so a
1333+
// reader can see which individual commits drive this dev's churn
1334+
// footprint — a handful of huge vendored-drop commits reads very
1335+
// differently from a steady stream of medium ones, even when the
1336+
// totals match. TopCommitsHidden follows the TopFilesHidden pattern.
1337+
TopCommits []DevCommit
1338+
TopCommitsHidden int
13311339
Scope []DirScope
13321340
// ScopeHidden / ExtensionsHidden count the buckets dropped by the
13331341
// top-5 truncation so CLI and HTML can surface "+N more" — without
@@ -1369,6 +1377,22 @@ type DevFileContrib struct {
13691377
Churn int64
13701378
}
13711379

1380+
// DevCommit is a single commit attributed to the dev, carrying the
1381+
// fields needed to render the per-dev "top commits" list. Mirrors the
1382+
// shape of BigCommit (the dataset-level TopCommits type) minus the
1383+
// AuthorName/AuthorEmail fields — those are redundant in a per-dev view
1384+
// where every entry belongs to the same author. Message is truncated
1385+
// at 80 chars (same as TopCommits) to keep the CLI/HTML table narrow.
1386+
type DevCommit struct {
1387+
SHA string
1388+
Date string
1389+
Message string
1390+
Additions int64
1391+
Deletions int64
1392+
LinesChanged int64
1393+
FilesChanged int
1394+
}
1395+
13721396
// DevExtContrib is a dev's footprint in a single extension bucket.
13731397
// Churn is the summed per-file dev-lines (from fe.devLines), so it
13741398
// reflects lines the dev personally added/removed across files that
@@ -1525,16 +1549,44 @@ func DevProfiles(ds *Dataset, filterEmail string, n int) []DevProfile {
15251549
// Per-dev work grid + monthly activity
15261550
devGrid := make(map[string]*[7][24]int)
15271551
devMonthly := make(map[string]map[string]*ActivityBucket)
1552+
// Per-dev commit list for TopCommits ranking. Collected in the same
1553+
// ds.commits pass as devGrid/devMonthly so we don't iterate the full
1554+
// commit map twice; actual sort + top-10 truncation happens in the
1555+
// per-dev assembly loop below.
1556+
devCommits := make(map[string][]DevCommit)
15281557
dayIdx := [7]int{6, 0, 1, 2, 3, 4, 5} // Sunday=6, Monday=0, ...
15291558

1530-
for _, cm := range ds.commits {
1559+
for sha, cm := range ds.commits {
15311560
if !inTarget(cm.email) {
15321561
continue
15331562
}
15341563
if cm.date.IsZero() {
1564+
// Note: dataset-level TopCommits() renders zero-date commits
1565+
// as "0001-01-01"; we drop them here because grid/monthly below
1566+
// share this guard and malformed-date commits are rare enough
1567+
// in practice (JSONL extract always emits author_date) that
1568+
// the divergence is not worth branching the loop for.
15351569
continue
15361570
}
15371571

1572+
// Message is stored un-truncated on purpose: the 80-char
1573+
// truncation is deferred to the per-dev assembly loop below,
1574+
// which runs after sort + top-10 cap. A dev with thousands of
1575+
// commits would otherwise pay N small string allocations here
1576+
// just to throw away all but 10. Dataset-level TopCommits()
1577+
// truncates inline because it builds BigCommits in one pass;
1578+
// the per-dev path splits collection from projection so we can
1579+
// avoid that cost.
1580+
devCommits[cm.email] = append(devCommits[cm.email], DevCommit{
1581+
SHA: sha,
1582+
Date: cm.date.UTC().Format("2006-01-02"),
1583+
Message: cm.message,
1584+
Additions: cm.add,
1585+
Deletions: cm.del,
1586+
LinesChanged: cm.add + cm.del,
1587+
FilesChanged: cm.files,
1588+
})
1589+
15381590
if devGrid[cm.email] == nil {
15391591
devGrid[cm.email] = &[7][24]int{}
15401592
}
@@ -1585,6 +1637,33 @@ func DevProfiles(ds *Dataset, filterEmail string, n int) []DevProfile {
15851637
}
15861638
}
15871639

1640+
// Top commits: rank this dev's commits by lines changed, mirroring
1641+
// the dataset-level TopCommits semantics. Deterministic tiebreak on
1642+
// SHA asc so the displayed top-10 is stable across runs when a dev
1643+
// has several same-sized commits (e.g. a series of formatting
1644+
// passes each touching the same LOC count). Message truncation is
1645+
// done here, post-cap, so we pay the string-copy cost for at most
1646+
// 10 entries per dev instead of the full commit count.
1647+
topCommits := devCommits[email]
1648+
topCommitsHidden := 0
1649+
if len(topCommits) > 0 {
1650+
sort.Slice(topCommits, func(i, j int) bool {
1651+
if topCommits[i].LinesChanged != topCommits[j].LinesChanged {
1652+
return topCommits[i].LinesChanged > topCommits[j].LinesChanged
1653+
}
1654+
return topCommits[i].SHA < topCommits[j].SHA
1655+
})
1656+
if len(topCommits) > 10 {
1657+
topCommitsHidden = len(topCommits) - 10
1658+
topCommits = topCommits[:10]
1659+
}
1660+
for i := range topCommits {
1661+
if len(topCommits[i].Message) > 80 {
1662+
topCommits[i].Message = topCommits[i].Message[:77] + "..."
1663+
}
1664+
}
1665+
}
1666+
15881667
var monthly []ActivityBucket
15891668
if months, ok := devMonthly[email]; ok {
15901669
var order []string
@@ -1805,6 +1884,7 @@ func DevProfiles(ds *Dataset, filterEmail string, n int) []DevProfile {
18051884
LinesChanged: cs.Additions + cs.Deletions, FilesTouched: cs.FilesTouched,
18061885
ActiveDays: cs.ActiveDays, FirstDate: cs.FirstDate, LastDate: cs.LastDate,
18071886
TopFiles: topFiles, TopFilesHidden: topFilesHidden,
1887+
TopCommits: topCommits, TopCommitsHidden: topCommitsHidden,
18081888
Scope: scope, ScopeHidden: scopeHidden,
18091889
Extensions: extensions, ExtensionsHidden: extensionsHidden,
18101890
Specialization: specialization,

0 commit comments

Comments
 (0)