tga 10.0.0

Developer productivity analytics — git commit collection, classification, and reporting
Documentation
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
//! CSV formatter — writes `authors.csv` and `weekly_activity.csv`.

use std::path::{Path, PathBuf};

use tracing::debug;

use crate::report::errors::Result;
use crate::report::models::ReportData;

/// Filename for the per-author summary CSV.
pub const AUTHORS_CSV: &str = "authors.csv";

/// Filename for the weekly activity CSV.
pub const WEEKLY_CSV: &str = "weekly_activity.csv";

/// Write the per-author summary as CSV into `output_dir`.
///
/// Returns the full path to the written file.
///
/// # Errors
///
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_author_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(AUTHORS_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "name",
        "email",
        "commit_count",
        "insertions",
        "deletions",
        "files_changed",
        "first_commit",
        "last_commit",
        "categories",
    ])?;
    for a in &data.authors {
        let categories = serialize_categories(&a.categories);
        w.write_record([
            a.name.as_str(),
            a.email.as_str(),
            &a.commit_count.to_string(),
            &a.insertions.to_string(),
            &a.deletions.to_string(),
            &a.files_changed.to_string(),
            a.first_commit.as_str(),
            a.last_commit.as_str(),
            categories.as_str(),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.authors.len(), "wrote authors.csv");
    Ok(path)
}

/// Write the weekly activity table as CSV into `output_dir`.
///
/// # Errors
///
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_weekly_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(WEEKLY_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "week",
        "author",
        "repository",
        "commit_count",
        "insertions",
        "deletions",
        "categories",
        "revert_count",
        "bugfix_count",
        "ticketed_count",
        "quality_score",
        "quality_tshirt",
        "abandoned_pr_count",
        // Issue #445: AI-adoption column — commits with a known AI co-author trailer.
        "ai_assisted_count",
        // Issue #445 batch B (request #6): mean LLM-assigned complexity for
        // this bucket; empty string when no commit has a complexity score.
        "avg_complexity",
        // Issue #660: net-new commit count excluding reverts, appended last
        // so existing column-index consumers are unaffected.
        "commit_count_net",
        // #4418: `ai_assisted_count` split by the signal family that produced
        // each verdict, so a consumer can cut the trailer-only subset out of
        // it rather than reading the total as if it already were that subset.
        // Appended after `commit_count_net` for the same index-stability
        // reason.
        "ai_trailer_count",
        "ai_message_count",
        "ai_email_count",
    ])?;
    for row in &data.weekly_activity {
        let categories = serialize_categories(&row.categories);
        let avg_complexity_str = match row.avg_complexity {
            Some(c) => format!("{:.4}", c),
            None => String::new(),
        };
        w.write_record([
            row.week.as_str(),
            row.author.as_str(),
            row.repository.as_str(),
            &row.commit_count.to_string(),
            &row.insertions.to_string(),
            &row.deletions.to_string(),
            categories.as_str(),
            &row.revert_count.to_string(),
            &row.bugfix_count.to_string(),
            &row.ticketed_count.to_string(),
            &format!("{:.4}", row.quality_score),
            row.quality_tshirt.as_str(),
            &row.abandoned_pr_count.to_string(),
            &row.ai_assisted_count.to_string(),
            avg_complexity_str.as_str(),
            &row.commit_count_net.to_string(),
            &row.ai_trailer_count.to_string(),
            &row.ai_message_count.to_string(),
            &row.ai_email_count.to_string(),
        ])?;
    }
    w.flush()?;
    debug!(
        path = %path.display(),
        rows = data.weekly_activity.len(),
        "wrote weekly_activity.csv"
    );
    Ok(path)
}

/// Filename for the per-week aggregate metrics CSV.
pub const WEEKLY_METRICS_CSV: &str = "weekly_metrics.csv";

/// Filename for the per-developer activity summary CSV.
pub const DEV_ACTIVITY_CSV: &str = "developer_activity_summary.csv";

/// Filename for the single-row overview CSV.
pub const SUMMARY_CSV: &str = "summary.csv";

/// Filename for the untracked commits CSV.
pub const UNTRACKED_CSV: &str = "untracked_commits.csv";

/// Filename for the weekly categorization CSV.
pub const WEEKLY_CATEGORIZATION_CSV: &str = "weekly_categorization.csv";

/// Filename for the weekly velocity CSV.
pub const WEEKLY_VELOCITY_CSV: &str = "weekly_velocity.csv";

/// Filename for the weekly DORA metrics CSV (one-row currently — DORA is
/// period-scoped, but the file matches the spec's filename slot).
pub const WEEKLY_DORA_CSV: &str = "weekly_dora_metrics.csv";

/// Write `weekly_metrics.csv`.
///
/// Why: surfaces per-week category counts and active developer counts in a
/// stable tabular form that downstream BI tooling can ingest.
/// What: one row per ISO week with category-aware tallies.
/// Test: seed two commits in different weeks, assert the file has 2 rows
/// plus header.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_weekly_metrics_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(WEEKLY_METRICS_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "week_id",
        "total_commits",
        "feature_commits",
        "bugfix_commits",
        "maintenance_commits",
        "refactor_commits",
        "test_commits",
        "doc_commits",
        "active_developers",
        "story_points",
    ])?;
    for m in &data.weekly_metrics {
        w.write_record([
            m.week.as_str(),
            &m.total_commits.to_string(),
            &m.feature_commits.to_string(),
            &m.bugfix_commits.to_string(),
            &m.maintenance_commits.to_string(),
            &m.refactor_commits.to_string(),
            &m.test_commits.to_string(),
            &m.doc_commits.to_string(),
            &m.active_developers.to_string(),
            &format!("{:.2}", m.story_points),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.weekly_metrics.len(), "wrote weekly_metrics.csv");
    Ok(path)
}

/// Write `developer_activity_summary.csv`.
///
/// Why: provides a single comparable activity score plus headline counts per
/// developer for leadership reporting.
/// What: one row per developer with score, active weeks, and primary work type.
/// Test: with two developers having different commit counts, assert the row
/// with higher commits has higher `activity_score`.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_developer_activity_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(DEV_ACTIVITY_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "developer_id",
        "display_name",
        "total_commits",
        "active_weeks",
        "avg_commits_per_week",
        "primary_work_type",
        "story_points_total",
        "activity_score",
    ])?;
    for d in &data.developer_activity {
        w.write_record([
            d.developer_id.as_str(),
            d.display_name.as_str(),
            &d.total_commits.to_string(),
            &d.active_weeks.to_string(),
            &format!("{:.2}", d.avg_commits_per_week),
            d.primary_work_type.as_str(),
            &format!("{:.2}", d.story_points_total),
            &format!("{:.4}", d.activity_score),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.developer_activity.len(), "wrote developer_activity_summary.csv");
    Ok(path)
}

/// Write `summary.csv` — single-row period overview.
///
/// Why: gives a one-line headline ("X commits by Y developers across Z weeks")
/// that fits naturally into downstream digests.
/// What: a single row reflecting the [`crate::report::models::ReportSummary`].
/// Test: assert the file has exactly one data row and matching totals.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_summary_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(SUMMARY_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "date_range",
        "total_commits",
        "total_developers",
        "total_weeks",
        "classification_coverage_pct",
    ])?;
    if let Some(s) = &data.summary {
        w.write_record([
            s.date_range.as_str(),
            &s.total_commits.to_string(),
            &s.total_developers.to_string(),
            &s.total_weeks.to_string(),
            &format!("{:.2}", s.classification_coverage_pct),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), "wrote summary.csv");
    Ok(path)
}

/// Write `untracked_commits.csv`.
///
/// Why: highlights commits without ticket references so teams can improve
/// ticketing hygiene.
/// What: one row per commit that has no work-item reference, newest first.
/// Test: insert one untracked commit, assert the file contains it.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_untracked_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(UNTRACKED_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record(["sha", "author", "date", "message"])?;
    for u in &data.untracked_commits {
        w.write_record([
            u.sha.as_str(),
            u.author.as_str(),
            u.date.as_str(),
            u.message.as_str(),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.untracked_commits.len(), "wrote untracked_commits.csv");
    Ok(path)
}

/// Write `weekly_categorization.csv`.
///
/// Why: surfaces how each week's commits are split across change types so
/// stakeholders can see, e.g., a feature-heavy vs maintenance-heavy week.
/// What: one row per (week, change_type) with count and percentage of the
/// week's commits.
/// Test: seed two commits of category `feature` in week W; assert one row
/// with `pct_of_week == 100`.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_weekly_categorization_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(WEEKLY_CATEGORIZATION_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record(["week_id", "change_type", "commit_count", "pct_of_week"])?;
    for c in &data.weekly_categorization {
        w.write_record([
            c.week.as_str(),
            c.change_type.as_str(),
            &c.commit_count.to_string(),
            &format!("{:.2}", c.pct_of_week),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.weekly_categorization.len(), "wrote weekly_categorization.csv");
    Ok(path)
}

/// Write `weekly_velocity.csv`.
///
/// Why: tracks delivery cadence on a per-week basis (PRs merged, cycle time,
/// commits per developer).
/// What: one row per ISO week.
/// Test: with no PR data, file still emits one row per week with zero PRs.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_weekly_velocity_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(WEEKLY_VELOCITY_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "week_id",
        "prs_merged",
        "avg_pr_cycle_time_hours",
        "story_points",
        "commits_per_developer",
    ])?;
    for v in &data.weekly_velocity {
        w.write_record([
            v.week.as_str(),
            &v.prs_merged.to_string(),
            &format!("{:.2}", v.avg_pr_cycle_time_hours),
            &format!("{:.2}", v.story_points),
            &format!("{:.2}", v.commits_per_developer),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), rows = data.weekly_velocity.len(), "wrote weekly_velocity.csv");
    Ok(path)
}

/// Write `weekly_dora_metrics.csv`.
///
/// Why: surfaces the four DORA metrics in tabular form so they can be
/// plotted alongside the JSON dashboard payload.
/// What: a single-row CSV (DORA is period-scoped). When no DORA data is
/// available the file contains only the header.
/// Test: with seeded commits + PRs, assert one data row with a recognized
/// `performance_level`.
///
/// # Errors
/// - [`crate::report::ReportError::Io`] / [`crate::report::ReportError::Csv`] on write failure.
pub fn write_weekly_dora_csv(data: &ReportData, output_dir: &Path) -> Result<PathBuf> {
    let path = output_dir.join(WEEKLY_DORA_CSV);
    let mut w = ::csv::Writer::from_path(&path)?;
    w.write_record([
        "deployment_frequency_per_week",
        "lead_time_hours",
        "change_failure_rate",
        "mttr_hours",
        "performance_level",
        "deployment_frequency_source",
        "lead_time_source",
    ])?;
    if let Some(d) = &data.dora {
        // #212 review round 2: `lead_time_hours` is `None` when genuinely
        // unmeasurable — an empty cell, never a `0.0` that would misread as
        // a measured zero.
        let lead_time_cell = d
            .lead_time_hours
            .map(|h| format!("{h:.2}"))
            .unwrap_or_default();
        w.write_record([
            &format!("{:.4}", d.deployment_frequency),
            &lead_time_cell,
            &format!("{:.4}", d.change_failure_rate),
            &format!("{:.2}", d.mttr_hours),
            d.performance_level.as_str(),
            d.deployment_frequency_source.as_str(),
            d.lead_time_source.as_str(),
        ])?;
    }
    w.flush()?;
    debug!(path = %path.display(), "wrote weekly_dora_metrics.csv");
    Ok(path)
}

/// Encode a category histogram as a deterministic `key=value;…` string so
/// the CSV cell is stable and machine-parseable.
fn serialize_categories(map: &std::collections::HashMap<String, usize>) -> String {
    let mut entries: Vec<(&String, &usize)> = map.iter().collect();
    entries.sort_by_key(|e| e.0);
    entries
        .into_iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect::<Vec<_>>()
        .join(";")
}