use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context as _, Result};
use clap::Parser;
use tracing::{info, warn};
use trusty_review::{
config::{Provider, ReviewConfig},
llm::build_provider,
profile::{
ContributorProfile, DiffSamplerConfig, assemble_period_batches,
batch::Window,
batch_reviewer::BatchReviewer,
reporter::{GithubIssueConfig, ReportFormat, Reporter},
resolve_contributor, resolve_db_path, sample_diffs_for_batches,
synthesizer::Synthesizer,
},
};
#[derive(Debug, Parser)]
pub struct ProfileArgs {
#[arg(value_name = "CONTRIBUTOR")]
pub contributor: String,
#[arg(long, value_name = "PATH")]
pub db: Option<PathBuf>,
#[arg(long, value_name = "DATE")]
pub since: Option<String>,
#[arg(long, value_name = "DATE")]
pub until: Option<String>,
#[arg(long, default_value = "quarterly", value_name = "WINDOW")]
pub window: String,
#[arg(long, value_name = "NAME,...", value_delimiter = ',')]
pub repos: Option<Vec<String>>,
#[arg(long, value_name = "PATH")]
pub repos_root: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
pub output: Option<PathBuf>,
#[arg(long, default_value = "both", value_name = "FORMAT")]
pub format: String,
#[arg(long, default_value_t = 10, value_name = "N")]
pub max_diffs: usize,
#[arg(long)]
pub dry_run: bool,
#[arg(long, value_name = "PROVIDER")]
pub provider: Option<String>,
#[arg(long, value_name = "SLUG")]
pub reviewer_model: Option<String>,
#[arg(long)]
pub github_issue: bool,
#[arg(long, value_name = "OWNER/REPO")]
pub github_repo: Option<String>,
}
pub async fn cmd_profile(config: ReviewConfig, args: ProfileArgs) -> Result<()> {
let db_path = resolve_db_path(args.db.as_deref())
.context("cannot resolve tga DB path — use --db <path>")?;
eprintln!("[trusty-review profile] Using DB: {}", db_path.display());
let identity = resolve_contributor(&db_path, &args.contributor)
.with_context(|| format!("contributor '{}' not found in DB", args.contributor))?;
let db = tga::core::db::Database::open(&db_path)
.with_context(|| format!("failed to open tga DB: {}", db_path.display()))?;
eprintln!(
"[trusty-review profile] Contributor: {} <{}>",
identity.canonical_name, identity.canonical_email
);
let window = parse_window(&args.window);
eprintln!(
"[trusty-review profile] Assembling period batches (window={:?})...",
window
);
let mut batches = assemble_period_batches(
&db,
&identity.canonical_email,
window,
args.since.as_deref(),
args.until.as_deref(),
)
.context("failed to assemble period batches")?;
eprintln!(
"[trusty-review profile] {} period(s) assembled.",
batches.len()
);
let mut profile = ContributorProfile::new(
&identity.canonical_email,
&identity.canonical_name,
args.since.as_deref().unwrap_or("earliest"),
args.until.as_deref().unwrap_or("latest"),
);
let mut repos: std::collections::HashSet<String> = std::collections::HashSet::new();
for b in &batches {
for r in &b.stats.repositories {
repos.insert(r.clone());
}
}
if let Some(ref filter) = args.repos {
profile.repositories = filter.clone();
} else {
profile.repositories = repos.into_iter().collect();
profile.repositories.sort();
}
let all_period_findings: Vec<Vec<trusty_review::profile::LongitudinalFinding>> = if args.dry_run
{
eprintln!("[trusty-review profile] --dry-run: skipping diff sampling and LLM calls.");
vec![]
} else {
let sampler_config = DiffSamplerConfig {
max_diffs: args.max_diffs,
repo_paths: std::collections::HashMap::new(),
repos_root: args.repos_root.clone(),
};
eprintln!("[trusty-review profile] Sampling diffs...");
if let Err(e) = sample_diffs_for_batches(
&mut batches,
&db,
&identity.canonical_email,
&sampler_config,
) {
warn!("diff sampling failed: {e} — continuing without diffs");
}
let total_diffs: usize = batches.iter().map(|b| b.sampled_diffs.len()).sum();
eprintln!("[trusty-review profile] Sampled {total_diffs} diffs across all periods.");
let default_provider = args
.provider
.as_deref()
.and_then(|p| p.parse::<Provider>().ok())
.unwrap_or_else(|| config.role_models.reviewer.provider.clone());
let reviewer_model = args
.reviewer_model
.clone()
.unwrap_or_else(|| config.role_models.reviewer.model.clone());
eprintln!("[trusty-review profile] Building LLM provider (model={reviewer_model})...");
let llm = build_provider(
&reviewer_model,
&default_provider,
&config.openrouter_api_key,
)
.await
.map_err(|e| anyhow::anyhow!("failed to build LLM provider: {e}"))?;
let batch_reviewer = BatchReviewer::new(Arc::clone(&llm), &reviewer_model);
let mut all_findings = Vec::new();
for batch in &batches {
eprintln!(
"[trusty-review profile] Reviewing period {} ...",
batch.stats.period_label
);
let findings = batch_reviewer
.review_period(batch, &mut profile.token_cost)
.await;
eprintln!("[trusty-review profile] → {} finding(s)", findings.len());
all_findings.push(findings);
}
profile.periods = batches.clone();
eprintln!("[trusty-review profile] Synthesising across periods...");
let synthesizer = Synthesizer::new(llm, &reviewer_model);
profile = synthesizer
.synthesize(profile, all_findings, &batches)
.await;
vec![]
};
if args.dry_run {
profile.periods = batches;
profile.quality_trend = profile
.periods
.iter()
.map(|b| (b.stats.period_label.clone(), b.stats.quality_score))
.collect();
use trusty_review::profile::synthesizer::derive_trajectory;
profile.improvement_trajectory = derive_trajectory(&profile.quality_trend);
}
let _ = all_period_findings;
let output_dir = args
.output
.clone()
.unwrap_or_else(|| config.log_dir.join("profiles"));
let report_format: ReportFormat = args.format.parse().unwrap_or_else(|e| {
warn!("unknown --format {}: {e} — defaulting to both", args.format);
ReportFormat::Both
});
let mut reporter = Reporter::new(&output_dir, report_format);
if args.github_issue {
if let Some(ref gh_repo) = args.github_repo {
let parts: Vec<&str> = gh_repo.splitn(2, '/').collect();
if parts.len() == 2 {
let token = config.github_token.clone();
if token.is_empty() {
warn!("--github-issue set but GITHUB_TOKEN is empty — skipping issue upsert");
} else {
let gc = GithubIssueConfig {
owner: parts[0].to_string(),
repo: parts[1].to_string(),
label: "dev-profile".to_string(),
token,
};
reporter = reporter.with_github_issue(gc);
}
} else {
warn!("--github-repo must be in 'owner/repo' format — got {gh_repo}");
}
} else {
warn!("--github-issue set but --github-repo not provided — skipping issue upsert");
}
}
eprintln!(
"[trusty-review profile] Writing report to {} ...",
output_dir.display()
);
match reporter.write_profile(&profile) {
Ok(paths) => {
for p in &paths {
eprintln!("[trusty-review profile] Written: {}", p.display());
}
}
Err(e) => {
warn!("failed to write profile report: {e}");
}
}
if args.github_issue {
eprintln!("[trusty-review profile] Upserting GitHub issue...");
match reporter.upsert_github_issue(&profile).await {
Some(url) => eprintln!("[trusty-review profile] GitHub issue: {url}"),
None => eprintln!("[trusty-review profile] GitHub issue upsert skipped or failed."),
}
}
info!(
contributor = %identity.canonical_email,
periods = profile.periods.len(),
findings = profile.all_findings.len(),
trajectory = ?profile.improvement_trajectory,
cost_usd = profile.token_cost.cost_usd,
"profile complete"
);
Ok(())
}
fn parse_window(s: &str) -> Window {
match s.to_lowercase().as_str() {
"quarterly" | "q" => Window::Quarterly,
"monthly" | "m" => Window::Monthly,
"weekly" | "w" => Window::Weekly,
other => {
if let Ok(n) = other.parse::<u32>() {
Window::Custom(n)
} else {
warn!("unknown window '{}' — defaulting to quarterly", s);
Window::Quarterly
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_args_parse_defaults() {
let args = ProfileArgs::try_parse_from(["profile", "alice@example.com"])
.expect("parse should succeed");
assert_eq!(args.contributor, "alice@example.com");
assert!(args.db.is_none());
assert_eq!(args.window, "quarterly");
assert_eq!(args.max_diffs, 10);
assert!(!args.dry_run);
assert!(!args.github_issue);
assert_eq!(args.format, "both");
}
#[test]
fn profile_args_parse_all_flags() {
let args = ProfileArgs::try_parse_from([
"profile",
"alice@example.com",
"--db",
"/tmp/org.tga.db",
"--since",
"2026-01-01",
"--until",
"2026-06-30",
"--window",
"monthly",
"--repos",
"acme/api,acme/web",
"--repos-root",
"/repos",
"--output",
"/tmp/profiles",
"--format",
"json",
"--max-diffs",
"5",
"--dry-run",
"--provider",
"bedrock",
"--reviewer-model",
"bedrock/us.anthropic.claude-sonnet-4-6",
"--github-issue",
"--github-repo",
"acme/trusty-profiles",
])
.expect("parse should succeed");
assert_eq!(args.contributor, "alice@example.com");
assert_eq!(args.db, Some(PathBuf::from("/tmp/org.tga.db")));
assert_eq!(args.since, Some("2026-01-01".to_string()));
assert_eq!(args.until, Some("2026-06-30".to_string()));
assert_eq!(args.window, "monthly");
assert_eq!(
args.repos,
Some(vec!["acme/api".to_string(), "acme/web".to_string()])
);
assert_eq!(args.repos_root, Some(PathBuf::from("/repos")));
assert_eq!(args.output, Some(PathBuf::from("/tmp/profiles")));
assert_eq!(args.format, "json");
assert_eq!(args.max_diffs, 5);
assert!(args.dry_run);
assert_eq!(args.provider, Some("bedrock".to_string()));
assert_eq!(
args.reviewer_model,
Some("bedrock/us.anthropic.claude-sonnet-4-6".to_string())
);
assert!(args.github_issue);
assert_eq!(args.github_repo, Some("acme/trusty-profiles".to_string()));
}
#[test]
fn parse_window_variants() {
assert_eq!(parse_window("quarterly"), Window::Quarterly);
assert_eq!(parse_window("monthly"), Window::Monthly);
assert_eq!(parse_window("weekly"), Window::Weekly);
assert_eq!(parse_window("4"), Window::Custom(4));
assert_eq!(parse_window("sprint"), Window::Quarterly);
}
}