use chrono::Utc;
use clap::Args;
use tga::core::config::Config;
use tga::core::db::Database;
use tga::report::drilldown::{
extract_provider_logins, format_json, format_markdown, lookup_author_for_drilldown,
query_author_categories, query_commit_summary, query_effort_histogram, query_pr_metrics,
AuthorDrilldownData, CommitSection, EffortSection, PrSection, ReportPeriod,
};
#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum AuthorFormat {
#[default]
Markdown,
Json,
}
#[derive(Args, Debug)]
#[command(
about = "Per-engineer drill-down report for a single canonical identity.",
long_about = "Produce a focused report for one engineer covering:\n\
- Commit summary (total, ticket coverage, repositories, first/last date)\n\
- Effort histogram (XS/S/M/L/XL from tga backfill effort)\n\
- Pull-request metrics (total, merged, avg/median/p95 cycle time)\n\
- Category breakdown (feature, bugfix, maintenance, …)\n\n\
The email must match a canonical_email in the authors table (case-insensitive).\n\
If the engineer's provider logins are not mapped, PR metrics will show 0 PRs.\n\
Map them first with: tga aliases add-login <email> github <login>",
after_help = "EXAMPLES:\n\
# Markdown report for all history\n\
tga author alice@example.com\n\n\
# JSON output scoped to the last quarter\n\
tga author alice@example.com --format json --since 2026-01-01 --until 2026-03-31\n\n\
TIPS:\n\
- Run `tga aliases list` to find the exact canonical_email to use.\n\
- Run `tga aliases add-login alice@example.com github alice-gh` to map PR authorship.\n\
- Run `tga backfill effort` to populate the effort histogram data."
)]
pub struct AuthorArgs {
pub email: String,
#[arg(long, value_enum, default_value_t = AuthorFormat::Markdown)]
pub format: AuthorFormat,
#[arg(long, value_name = "DATE")]
pub since: Option<String>,
#[arg(long, value_name = "DATE")]
pub until: Option<String>,
}
pub fn run(_config: Config, db: &Database, args: AuthorArgs) -> anyhow::Result<()> {
let author = lookup_author_for_drilldown(db, &args.email)?.ok_or_else(|| {
anyhow::anyhow!(
"no canonical identity with canonical_email '{}' found. \
Run `tga aliases list` to see all known identities.",
args.email
)
})?;
let (_id, canonical_name, canonical_email, aliases_json) = author;
let provider_logins = extract_provider_logins(&aliases_json);
let since = args.since.as_deref();
let until = args.until.as_deref();
let commit_summary = query_commit_summary(db, &canonical_email, since, until)?;
let effort = query_effort_histogram(db, &canonical_email, since, until)?;
let pr_metrics = query_pr_metrics(db, &provider_logins, since, until)?;
let categories = query_author_categories(db, &canonical_email, since, until)?;
let ticket_coverage = if commit_summary.total_commits > 0 {
Some(commit_summary.ticketed_commits as f64 / commit_summary.total_commits as f64)
} else {
None
};
let data = AuthorDrilldownData {
generated_at: Utc::now().to_rfc3339(),
email: canonical_email,
name: canonical_name,
period: ReportPeriod {
since: args.since.clone(),
until: args.until.clone(),
},
commits: CommitSection {
total: commit_summary.total_commits,
ticketed: commit_summary.ticketed_commits,
ticket_coverage,
repositories: commit_summary.repositories,
first_commit: commit_summary.first_commit,
last_commit: commit_summary.last_commit,
insertions: commit_summary.insertions,
deletions: commit_summary.deletions,
},
effort: EffortSection {
scored_commits: effort.scored_commits,
total_commits: effort.total_commits,
histogram: effort.histogram,
},
pull_requests: PrSection {
total: pr_metrics.total,
merged: pr_metrics.merged,
avg_cycle_time_hours: pr_metrics.avg_cycle_time_hours,
median_cycle_time_hours: pr_metrics.median_cycle_time_hours,
p95_cycle_time_hours: pr_metrics.p95_cycle_time_hours,
},
categories,
};
match args.format {
AuthorFormat::Markdown => {
print!("{}", format_markdown(&data));
}
AuthorFormat::Json => {
let json =
format_json(&data).map_err(|e| anyhow::anyhow!("JSON serialisation error: {e}"))?;
println!("{json}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tga::core::config::Config;
use tga::core::db::Database;
fn seed_db() -> Database {
let db = Database::open_in_memory().expect("open");
let conn = db.connection();
conn.execute(
"INSERT INTO authors (id, canonical_name, canonical_email, aliases) \
VALUES (1, 'Alice Smith', 'alice@example.com', '[\"alice-gh\"]')",
[],
)
.expect("insert author");
conn.execute(
"INSERT INTO classifications (id, category, confidence, method) \
VALUES (1, 'feature', 0.9, 'exact_rule')",
[],
)
.expect("insert classification");
conn.execute(
"INSERT INTO commits (sha, author_id, author_name, author_email, timestamp, \
message, repository, insertions, deletions, classification_id, ticketed) \
VALUES ('abc123', 1, 'Alice Smith', 'alice@example.com', \
'2025-06-01T10:00:00Z', 'feat: add login', 'acme/api', 42, 10, 1, 1)",
[],
)
.expect("insert commit");
conn.execute(
"INSERT INTO commits (sha, author_id, author_name, author_email, timestamp, \
message, repository, insertions, deletions) \
VALUES ('def456', 1, 'Alice Smith', 'alice@example.com', \
'2025-07-01T10:00:00Z', 'fix: edge case', 'acme/api', 5, 2)",
[],
)
.expect("insert commit 2");
db
}
#[test]
fn run_produces_markdown_output() {
let db = seed_db();
let args = AuthorArgs {
email: "alice@example.com".to_string(),
format: AuthorFormat::Markdown,
since: None,
until: None,
};
let mut output = String::new();
let author = lookup_author_for_drilldown(&db, &args.email)
.expect("lookup")
.expect("author present");
let (_id, canonical_name, canonical_email, aliases_json) = author;
let logins = extract_provider_logins(&aliases_json);
let commit_summary =
query_commit_summary(&db, &canonical_email, None, None).expect("commits");
let effort = query_effort_histogram(&db, &canonical_email, None, None).expect("effort");
let pr_metrics = query_pr_metrics(&db, &logins, None, None).expect("pr");
let categories =
query_author_categories(&db, &canonical_email, None, None).expect("categories");
let data = AuthorDrilldownData {
generated_at: "2026-05-28T10:00:00Z".to_string(),
email: canonical_email,
name: canonical_name,
period: ReportPeriod {
since: None,
until: None,
},
commits: CommitSection {
total: commit_summary.total_commits,
ticketed: commit_summary.ticketed_commits,
ticket_coverage: Some(
commit_summary.ticketed_commits as f64 / commit_summary.total_commits as f64,
),
repositories: commit_summary.repositories,
first_commit: commit_summary.first_commit,
last_commit: commit_summary.last_commit,
insertions: commit_summary.insertions,
deletions: commit_summary.deletions,
},
effort: EffortSection {
scored_commits: effort.scored_commits,
total_commits: effort.total_commits,
histogram: effort.histogram,
},
pull_requests: PrSection {
total: pr_metrics.total,
merged: pr_metrics.merged,
avg_cycle_time_hours: pr_metrics.avg_cycle_time_hours,
median_cycle_time_hours: pr_metrics.median_cycle_time_hours,
p95_cycle_time_hours: pr_metrics.p95_cycle_time_hours,
},
categories,
};
output.push_str(&format_markdown(&data));
assert!(!output.is_empty(), "output must be non-empty");
assert!(
output.contains("alice@example.com"),
"output must contain the email"
);
assert!(output.contains("## Summary"), "must have Summary section");
assert!(output.contains("# Engineer Report:"));
}
#[test]
fn run_errors_on_unknown_email() {
let db = seed_db();
let result = lookup_author_for_drilldown(&db, "nobody@example.com").expect("query ok");
assert!(result.is_none(), "unknown email should return None");
}
#[test]
fn run_json_output_parseable() {
let db = seed_db();
let author = lookup_author_for_drilldown(&db, "alice@example.com")
.expect("lookup")
.expect("found");
let (_id, canonical_name, canonical_email, aliases_json) = author;
let logins = extract_provider_logins(&aliases_json);
let commit_summary =
query_commit_summary(&db, &canonical_email, None, None).expect("commits");
let effort = query_effort_histogram(&db, &canonical_email, None, None).expect("effort");
let pr_metrics = query_pr_metrics(&db, &logins, None, None).expect("pr");
let categories =
query_author_categories(&db, &canonical_email, None, None).expect("categories");
let data = AuthorDrilldownData {
generated_at: "2026-05-28T10:00:00Z".to_string(),
email: canonical_email,
name: canonical_name,
period: ReportPeriod {
since: None,
until: None,
},
commits: CommitSection {
total: commit_summary.total_commits,
ticketed: commit_summary.ticketed_commits,
ticket_coverage: Some(
commit_summary.ticketed_commits as f64 / commit_summary.total_commits as f64,
),
repositories: commit_summary.repositories,
first_commit: commit_summary.first_commit,
last_commit: commit_summary.last_commit,
insertions: commit_summary.insertions,
deletions: commit_summary.deletions,
},
effort: EffortSection {
scored_commits: effort.scored_commits,
total_commits: effort.total_commits,
histogram: effort.histogram,
},
pull_requests: PrSection {
total: pr_metrics.total,
merged: pr_metrics.merged,
avg_cycle_time_hours: pr_metrics.avg_cycle_time_hours,
median_cycle_time_hours: pr_metrics.median_cycle_time_hours,
p95_cycle_time_hours: pr_metrics.p95_cycle_time_hours,
},
categories,
};
let json_str = format_json(&data).expect("json");
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
assert_eq!(
parsed["email"].as_str(),
Some("alice@example.com"),
"email must be in JSON output"
);
assert_eq!(
parsed["commits"]["total"].as_u64(),
Some(2),
"total commits should be 2"
);
}
#[test]
fn run_config_not_needed() {
let db = seed_db();
let cfg = Config::default();
let args = AuthorArgs {
email: "alice@example.com".to_string(),
format: AuthorFormat::Markdown,
since: None,
until: None,
};
let _ = run(cfg, &db, args);
}
}