tga 10.1.0

Developer productivity analytics — git commit collection, classification, and reporting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! Report-specific aggregated data structures.
//!
//! These structs are populated by [`crate::report::aggregator::Aggregator`] from
//! database queries and then consumed by the formatters in
//! [`crate::report::formatters`]. They are `serde`-friendly so that the JSON
//! formatter can emit [`ReportData`] directly.
//!
//! Every struct here is `#[non_exhaustive]` (#137), so a new metric is an
//! additive change. Outside this crate, start from `Default::default()` and
//! assign the public fields.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Aggregated per-author commit summary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuthorSummary {
    /// Canonical author display name.
    pub name: String,
    /// Canonical author email.
    pub email: String,
    /// Number of commits attributed to this author.
    pub commit_count: usize,
    /// Total insertions across all of this author's commits.
    pub insertions: i64,
    /// Total deletions across all of this author's commits.
    pub deletions: i64,
    /// Total files changed across all of this author's commits.
    pub files_changed: i64,
    /// Per-category commit counts for this author.
    pub categories: HashMap<String, usize>,
    /// ISO 8601 timestamp of the author's earliest observed commit.
    pub first_commit: String,
    /// ISO 8601 timestamp of the author's latest observed commit.
    pub last_commit: String,
}

/// Aggregated per-repository summary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RepositorySummary {
    /// Repository name (matches `commits.repository`).
    pub name: String,
    /// Total commits in this repository.
    pub commit_count: usize,
    /// Distinct author count in this repository.
    pub author_count: usize,
    /// Total insertions across all commits in this repository.
    pub insertions: i64,
    /// Total deletions across all commits in this repository.
    pub deletions: i64,
    /// Top categories by commit count, sorted descending.
    pub top_categories: Vec<(String, usize)>,
}

/// Per-week-per-author-per-repository activity row.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WeeklyActivity {
    /// ISO week label, e.g. `"2024-W03"`.
    pub week: String,
    /// Author name.
    pub author: String,
    /// Repository name.
    pub repository: String,
    /// Number of commits in this week/author/repo bucket.
    pub commit_count: usize,
    /// Insertions in this bucket.
    pub insertions: i64,
    /// Deletions in this bucket.
    pub deletions: i64,
    /// Per-category counts within this bucket.
    pub categories: HashMap<String, usize>,
    /// Commits in this bucket detected as reverts (issue #377). Negative
    /// quality signal — feeds [`Self::quality_score`].
    #[serde(default)]
    pub revert_count: usize,
    /// Commits in this bucket classified as `bugfix` (issue #377). Exposed so
    /// the bugfix rate behind the quality score is auditable downstream.
    #[serde(default)]
    pub bugfix_count: usize,
    /// Commits in this bucket carrying a ticket reference (issue #377).
    /// Positive quality signal.
    #[serde(default)]
    pub ticketed_count: usize,
    /// Per-engineer-per-week quality score in `[0.0, 1.0]` (higher is
    /// better). See [`crate::core::quality`] for the formula orientation.
    #[serde(default)]
    pub quality_score: f64,
    /// Quality T-shirt bucket as the string `"1".."5"` (5 = best), parallel
    /// to `effort_tshirt` so consumers can join it the same way.
    #[serde(default)]
    pub quality_tshirt: String,
    /// Closed-but-unmerged ("abandoned") PRs attributed to this engineer in
    /// this week (issue #377). Best-effort attribution by author login; see
    /// the aggregator note on its limitations.
    #[serde(default)]
    pub abandoned_pr_count: usize,
    /// Commits in this bucket where `is_ai_assisted = 1` (issue #445).
    /// Additive — downstream can sum to get an org-wide AI-adoption count.
    ///
    /// This is NOT a trailer-only count and never was a floor derivable from
    /// commit messages: since #5249 a body footer or an agent-identifying
    /// author address sets the flag too. The three `ai_*_count` fields below
    /// break it down by which of those fired (#4418).
    #[serde(default)]
    pub ai_assisted_count: usize,
    /// Mean LLM-assigned complexity score (1–5) across commits in this bucket
    /// that have a non-null `classifications.complexity` value (issue #445
    /// batch B, request #6). `None` when no commit in the bucket has been
    /// assigned a complexity score (e.g., all classified by rules/external
    /// sources or no classification at all).
    #[serde(default)]
    pub avg_complexity: Option<f64>,
    /// Full-agentic commits in this bucket (issue #1113: `agentic_mode =
    /// 'full_agentic'`). Counts autonomous CLI-tool commits (Claude Code, etc.).
    /// Downstream agentic % = `agentic_count / (commit_count - revert_count)`.
    #[serde(default)]
    pub agentic_count: usize,
    /// IDE-assisted commits (issue #1113: `agentic_mode = 'ide_assisted'`).
    /// Counts inline-completion commits (Cursor, GitHub Copilot).
    #[serde(default)]
    pub ide_assisted_count: usize,
    /// Why: [`Self::ai_assisted_count`] fuses three signal families, and a
    /// consumer can independently re-derive only one of them — the literal
    /// `Co-Authored-By:` trailer, by regexing commit messages. Read as if it
    /// were that subset, the count is not the conservative floor it looks
    /// like; read as the whole, it cannot be checked against anything. This
    /// field is the subset (#4418), so the floor is stated rather than
    /// assumed.
    /// What: AI-assisted commits in this bucket whose evidence was a
    /// `Co-Authored-By:` trailer. Additive — [`Self::ai_assisted_count`] keeps
    /// its existing meaning.
    /// Test: `report::tests::weekly_activity_splits_ai_count_by_detection_method`.
    #[serde(default)]
    pub ai_trailer_count: usize,
    /// AI-assisted commits whose evidence was a message-body footer such as
    /// `Generated with trusty-mpm` (#4418) — the rows a consumer's own trailer
    /// regex cannot see.
    #[serde(default)]
    pub ai_message_count: usize,
    /// AI-assisted commits whose evidence was an agent-identifying author or
    /// committer address (#4418).
    #[serde(default)]
    pub ai_email_count: usize,
    /// Why: `commit_count` counts revert commits identically to original
    /// work, so a developer who lands N commits and reverts all N shows 2N
    /// commits with zero net code change — a 2x+ inflation downstream
    /// consumers cannot correct without re-deriving it themselves (issue
    /// #660). `persist_weekly_engineer` already computed this exact formula
    /// privately for `fact_weekly_engineer.net_commits`; this field exposes
    /// it on the report row itself.
    /// What: `commit_count` minus `revert_count` for this (week, author,
    /// repository) bucket, saturating at zero. Additive field — `commit_count`
    /// keeps its existing gross-count meaning unchanged for backward compatibility.
    /// Test: `report::tests::weekly_activity_commit_count_net_excludes_reverts`.
    #[serde(default)]
    pub commit_count_net: usize,
}

/// Per-week aggregated metrics across all developers and repositories.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WeeklyMetrics {
    /// ISO week label (e.g. `"2024-W03"`).
    pub week: String,
    /// Total commits in the week.
    pub total_commits: usize,
    /// Feature commits.
    pub feature_commits: usize,
    /// Bugfix commits.
    pub bugfix_commits: usize,
    /// Maintenance commits.
    pub maintenance_commits: usize,
    /// Refactor commits.
    pub refactor_commits: usize,
    /// Test commits.
    pub test_commits: usize,
    /// Documentation commits.
    pub doc_commits: usize,
    /// Distinct active developers.
    pub active_developers: usize,
    /// Story points (placeholder — sourced from work items when available).
    pub story_points: f64,
}

/// Per-developer activity summary across the full reporting period.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DeveloperActivitySummary {
    /// Stable developer identifier (canonical email).
    pub developer_id: String,
    /// Canonical display name.
    pub display_name: String,
    /// Total commits attributed to this developer.
    pub total_commits: usize,
    /// Distinct ISO weeks with at least one commit.
    pub active_weeks: usize,
    /// `total_commits / active_weeks` (zero when no active weeks).
    pub avg_commits_per_week: f64,
    /// Modal `change_type` for this developer (e.g. `"feature"`).
    pub primary_work_type: String,
    /// Story points contributed (currently zero — placeholder).
    pub story_points_total: f64,
    /// Composite activity score, see [`ActivityWeights`].
    pub activity_score: f64,
}

/// Single-row overview metrics for the whole report.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReportSummary {
    /// Period range as `"<start> .. <end>"` (ISO 8601, UTC).
    pub date_range: String,
    /// Total commit count.
    pub total_commits: usize,
    /// Total distinct developer count.
    pub total_developers: usize,
    /// Total distinct ISO weeks observed.
    pub total_weeks: usize,
    /// Classification coverage percent (commits with a non-null category).
    pub classification_coverage_pct: f64,
}

/// A commit that has no associated work-item / ticket reference.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UntrackedCommit {
    /// Commit SHA.
    pub sha: String,
    /// Author display name.
    pub author: String,
    /// Author timestamp (ISO 8601).
    pub date: String,
    /// First line of the commit message.
    pub message: String,
}

/// Per-week per-change-type count.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WeeklyCategorization {
    /// ISO week label.
    pub week: String,
    /// Change type / category label.
    pub change_type: String,
    /// Number of commits.
    pub commit_count: usize,
    /// Percentage of the week's total commits this category represents.
    pub pct_of_week: f64,
}

/// Per-week velocity metrics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WeeklyVelocity {
    /// ISO week label.
    pub week: String,
    /// PRs merged in the week.
    pub prs_merged: usize,
    /// Average PR cycle time (created → merged) in hours.
    pub avg_pr_cycle_time_hours: f64,
    /// Story points delivered (placeholder, currently zero).
    pub story_points: f64,
    /// Average commits per active developer in the week.
    pub commits_per_developer: f64,
}

/// DORA "Accelerate" metrics over the full reporting period.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DoraMetrics {
    /// Deployment frequency in deploys per week.
    pub deployment_frequency: f64,
    // #212 review round 2: `0.0` used to double as both "measured zero" and
    // "no data at all" — a deploy that named a `git_sha` with no matching
    // commit and zero `pull_requests` rows silently rendered as a measured
    // `0.0`. `None` now means genuinely unmeasurable; see
    // `lead_time_source` for which case produced the value.
    /// Average lead time in hours: measured (`fact_deployments` row →
    /// linked commit) when available, else the PR open→merge proxy, else
    /// `None`.
    pub lead_time_hours: Option<f64>,
    /// Change failure rate: bugfix commits / total commits.
    pub change_failure_rate: f64,
    /// MTTR approximation: average hours between a bug-introducing commit and
    /// its bugfix commit. Zero if no commit-pairs found.
    pub mttr_hours: f64,
    /// Aggregate performance band: `"elite" | "high" | "medium" | "low"`.
    /// An unmeasurable lead time (see [`Self::lead_time_source`]) fails every
    /// tier's lead-time bound, so it never classifies above `"low"`.
    pub performance_level: String,
    // #212: distinguishes a measured `fact_deployments` reading from the
    // PR-merge proxy so a report reader can tell which source produced
    // `deployment_frequency`.
    /// Data source for `deployment_frequency`: `"fact_deployments"` when the
    /// table had in-period `environment='production' AND status='success'`
    /// rows, `"pr_merge_proxy"` when it did not, `"pr_merge_proxy_query_failed"`
    /// when the query itself errored (e.g. a pre-migration DB missing the
    /// table) — distinct from a genuinely empty table.
    pub deployment_frequency_source: String,
    // #212 review round 2: split from `deployment_frequency_source` because
    // a deploy can be measured while its lead time is not (unmatched
    // `git_sha`).
    /// Data source for `lead_time_hours`: `"measured"` (deploy→commit link
    /// resolved), `"proxy"` (PR open→merge cycle time stood in), or
    /// `"unmeasurable"` (neither signal was available — `lead_time_hours` is
    /// `None`).
    pub lead_time_source: String,
}

/// Period-level velocity summary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct VelocitySummary {
    /// Average PR cycle time in hours (outlier-filtered).
    pub pr_cycle_time_avg_hours: f64,
    /// Median PR cycle time in hours (outlier-filtered).
    pub pr_cycle_time_median_hours: f64,
    /// PRs merged per ISO week (mean across weeks observed).
    pub pr_throughput_per_week: f64,
    /// Revision rate placeholder — currently zero because review-round data
    /// is not yet collected; surfaced so the schema is stable.
    pub revision_rate: f64,
    /// Total PRs considered after outlier filtering.
    pub pr_count: usize,
}

/// Quality / hygiene metrics derived from commit messages and classifications.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct QualitySummary {
    /// Composite quality score in `[0, 1]`.
    pub quality_score: f64,
    /// Total revert commits detected.
    pub revert_count: usize,
    /// `revert_count / total_commits` as a fraction in `[0, 1]`.
    pub revert_pct: f64,
    /// Bugfix commit percentage in `[0, 1]`.
    pub bugfix_pct: f64,
    /// Defect rate: bugfix / non-bugfix-feature commits, in `[0, 1]`.
    pub defect_rate: f64,
}

/// Configurable weights for composite developer activity score.
///
/// Defaults match the values in `docs/trusty-git-analytics/requirements/reporting.md`. The five
/// components are normalized via min-max scaling across the reporting period
/// and then linearly combined.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ActivityWeights {
    /// Weight for raw commit count.
    pub commits: f64,
    /// Weight for merged PRs.
    pub prs: f64,
    /// Weight for code impact (lines changed).
    pub code_impact: f64,
    /// Weight for code complexity proxy (files changed per commit).
    pub complexity: f64,
    /// Weight for ticketing hygiene (ticketed commits / total).
    pub ticketing: f64,
}

impl Default for ActivityWeights {
    fn default() -> Self {
        Self {
            commits: 0.22,
            prs: 0.26,
            code_impact: 0.26,
            complexity: 0.11,
            ticketing: 0.15,
        }
    }
}

/// Full report payload passed to every formatter.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReportData {
    /// ISO 8601 timestamp at which the report was generated.
    pub generated_at: String,
    /// Earliest observed commit timestamp (ISO 8601), or `None` if empty.
    pub period_start: Option<String>,
    /// Latest observed commit timestamp (ISO 8601), or `None` if empty.
    pub period_end: Option<String>,
    /// Per-author summaries.
    pub authors: Vec<AuthorSummary>,
    /// Per-repository summaries.
    pub repositories: Vec<RepositorySummary>,
    /// Weekly activity rows.
    pub weekly_activity: Vec<WeeklyActivity>,
    /// Total commit count across the dataset.
    pub total_commits: usize,
    /// Total distinct author count across the dataset.
    pub total_authors: usize,
    /// Cross-cutting category → count tally.
    pub category_breakdown: HashMap<String, usize>,
    /// Weekly cross-developer aggregate metrics.
    pub weekly_metrics: Vec<WeeklyMetrics>,
    /// Per-developer activity rollup.
    pub developer_activity: Vec<DeveloperActivitySummary>,
    /// Single-row period summary.
    pub summary: Option<ReportSummary>,
    /// Commits with no work-item reference.
    pub untracked_commits: Vec<UntrackedCommit>,
    /// Per-week per-category counts.
    pub weekly_categorization: Vec<WeeklyCategorization>,
    /// Per-week velocity metrics.
    pub weekly_velocity: Vec<WeeklyVelocity>,
    /// DORA period-level metrics.
    pub dora: Option<DoraMetrics>,
    /// Velocity period-level summary.
    pub velocity: Option<VelocitySummary>,
    /// Quality period-level summary.
    pub quality: Option<QualitySummary>,
    /// Total boilerplate commits detected.
    pub boilerplate_count: usize,
    /// Total revert commits detected.
    pub revert_count: usize,
    /// Number of repositories analyzed for this report (i.e. distinct
    /// `commits.repository` values observed). Surfaced so downstream
    /// consumers can detect undercounting when the configured repo roster
    /// is narrower than the actual portfolio (see issue #67).
    #[serde(default)]
    pub repository_coverage: usize,
    /// Number of commits whose author identity could not be resolved to a
    /// canonical team member (see issue #68). These commits still appear in
    /// the commit totals but are tracked separately so developer counts are
    /// not silently inflated by phantom identities.
    #[serde(default)]
    pub unresolved_author_commits: usize,
    /// Number of distinct author identities that did not resolve to a
    /// configured canonical team member (see issue #68).
    #[serde(default)]
    pub unresolved_authors: usize,
}

impl ReportData {
    /// Construct an empty `ReportData` with the given generation timestamp.
    pub fn empty(generated_at: String) -> Self {
        Self {
            generated_at,
            period_start: None,
            period_end: None,
            authors: Vec::new(),
            repositories: Vec::new(),
            weekly_activity: Vec::new(),
            total_commits: 0,
            total_authors: 0,
            category_breakdown: HashMap::new(),
            weekly_metrics: Vec::new(),
            developer_activity: Vec::new(),
            summary: None,
            untracked_commits: Vec::new(),
            weekly_categorization: Vec::new(),
            weekly_velocity: Vec::new(),
            dora: None,
            velocity: None,
            quality: None,
            boilerplate_count: 0,
            revert_count: 0,
            repository_coverage: 0,
            unresolved_author_commits: 0,
            unresolved_authors: 0,
        }
    }
}