use std::sync::Arc;
use tracing::{info, warn};
use crate::{
config::ReviewConfig,
integrations::github::{AuthStrategy, GithubClient, RunMode, post_pr_review},
models::ReviewResult,
pipeline::{
output::{print_review_result, write_review_log},
trigger::{TriggerDecision, effective_dry_run},
},
store::DedupStore,
};
pub fn format_review_footer(
grade: Option<&str>,
model: &str,
input_tokens: u32,
output_tokens: u32,
cost_usd: f64,
) -> String {
let model_display = if model.is_empty() {
"(unknown)".to_string()
} else {
model.to_string()
};
let in_fmt = format_with_thousands(input_tokens);
let out_fmt = format_with_thousands(output_tokens);
let cost_fmt = format_cost(cost_usd);
let grade_prefix = match grade {
Some(g) if !g.is_empty() => format!("Grade: {g} · "),
_ => String::new(),
};
format!(
"\n---\n{grade_prefix}🤖 Reviewed by `{model_display}` · tokens ↑{in_fmt} ↓{out_fmt} · est. ${cost_fmt}"
)
}
fn format_with_thousands(n: u32) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (i, &b) in bytes.iter().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
out
}
fn format_cost(cost_usd: f64) -> String {
if cost_usd == 0.0 {
return "0".to_string();
}
let raw = format!("{cost_usd:.3}");
let trimmed = raw.trim_end_matches('0');
if trimmed.ends_with('.') {
format!("{trimmed}0")
} else {
trimmed.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinalizeAction {
Post,
LogOnly,
}
pub fn decide_action(
config_dry_run: bool,
trigger: TriggerDecision,
allow_posting: bool,
is_github_source: bool,
) -> FinalizeAction {
let dry = effective_dry_run(config_dry_run, trigger);
if !dry && allow_posting && is_github_source {
FinalizeAction::Post
} else {
FinalizeAction::LogOnly
}
}
pub struct PostContext<'a> {
pub owner: &'a str,
pub repo: &'a str,
pub pr: u64,
pub head_sha: &'a str,
pub run_mode: RunMode,
pub dedup: Option<&'a Arc<DedupStore>>,
}
pub async fn finalize_review(
mut result: ReviewResult,
config: &ReviewConfig,
trigger: TriggerDecision,
allow_posting: bool,
write_log: bool,
print_result: bool,
post_ctx: PostContext<'_>,
) -> ReviewResult {
let footer = format_review_footer(
result.grade.as_deref(),
&result.model,
result.input_tokens,
result.output_tokens,
result.cost_estimate_usd,
);
result.review_body.push_str(&footer);
let is_github = !post_ctx.owner.is_empty() && post_ctx.owner != "local";
let action = decide_action(config.dry_run, trigger, allow_posting, is_github);
match action {
FinalizeAction::Post => {
match post_live(&mut result, config, &post_ctx).await {
Ok(()) => {
result.posted = true;
result.dry_run = false;
info!(
owner = post_ctx.owner,
repo = post_ctx.repo,
pr = post_ctx.pr,
verdict = %result.verdict,
"review posted live to GitHub PR"
);
if let Some(store) = post_ctx.dedup
&& !post_ctx.head_sha.is_empty()
&& let Err(e) = store.complete(
post_ctx.owner,
post_ctx.repo,
post_ctx.pr,
post_ctx.head_sha,
)
{
warn!("dedup complete() failed (non-fatal): {e}");
}
}
Err(e) => {
warn!("live post failed (falling back to dry-run log): {e}");
result.dry_run = true;
if result.error.is_none() {
result.error = Some(format!("post failed: {e}"));
}
write_review_log(&result, &config.log_dir);
}
}
}
FinalizeAction::LogOnly => {
result.dry_run = true;
if write_log {
write_review_log(&result, &config.log_dir);
}
}
}
if print_result {
print_review_result(&result);
}
result
}
async fn post_live(
result: &mut ReviewResult,
config: &ReviewConfig,
ctx: &PostContext<'_>,
) -> Result<(), crate::integrations::github::GithubError> {
let client = GithubClient::new();
let strategy = AuthStrategy::select(ctx.run_mode, None);
let token = strategy.resolve_token(&client, config, ctx.owner).await?;
let posted = post_pr_review(&client, ctx.owner, ctx.repo, ctx.pr, &token, result).await?;
if !posted.html_url.is_empty() {
info!(review_url = %posted.html_url, "posted review URL");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footer_format_known_tuple() {
let footer = format_review_footer(
None,
"us.anthropic.claude-sonnet-4-6",
13499,
1718,
0.066_267,
);
assert_eq!(
footer,
"\n---\n🤖 Reviewed by `us.anthropic.claude-sonnet-4-6` · tokens ↑13,499 ↓1,718 · est. $0.066"
);
}
#[test]
fn footer_format_with_grade() {
let footer = format_review_footer(
Some("B+"),
"us.anthropic.claude-sonnet-4-6",
13499,
1718,
0.066_267,
);
assert_eq!(
footer,
"\n---\nGrade: B+ · 🤖 Reviewed by `us.anthropic.claude-sonnet-4-6` · tokens ↑13,499 ↓1,718 · est. $0.066"
);
}
#[test]
fn footer_thousands_separator() {
assert_eq!(format_with_thousands(1000), "1,000");
assert_eq!(format_with_thousands(999), "999");
assert_eq!(format_with_thousands(1_000_000), "1,000,000");
assert_eq!(format_with_thousands(0), "0");
}
#[test]
fn footer_empty_model_renders_unknown() {
let footer = format_review_footer(None, "", 10, 5, 0.001);
assert!(
footer.contains("`(unknown)`"),
"empty model must render as (unknown): {footer}"
);
}
#[test]
fn footer_zero_cost() {
let footer = format_review_footer(None, "my-model", 1, 1, 0.0);
assert!(
footer.contains("$0"),
"zero cost must render as $0: {footer}"
);
assert!(
!footer.contains("$0."),
"zero cost must not have decimal: {footer}"
);
}
#[test]
fn decide_action_live_posts() {
assert_eq!(
decide_action(false, TriggerDecision::None, true, true),
FinalizeAction::Post
);
}
#[test]
fn decide_action_force_live_overrides_dry_config() {
assert_eq!(
decide_action(true, TriggerDecision::ForceLive, true, true),
FinalizeAction::Post
);
}
#[test]
fn decide_action_dry_logs() {
assert_eq!(
decide_action(true, TriggerDecision::None, true, true),
FinalizeAction::LogOnly
);
}
#[test]
fn decide_action_force_dry_run_overrides_live_config() {
assert_eq!(
decide_action(false, TriggerDecision::ForceDryRun, true, true),
FinalizeAction::LogOnly
);
}
#[test]
fn decide_action_disallowed_logs() {
assert_eq!(
decide_action(false, TriggerDecision::ForceLive, false, true),
FinalizeAction::LogOnly
);
}
#[test]
fn decide_action_local_logs() {
assert_eq!(
decide_action(false, TriggerDecision::ForceLive, true, false),
FinalizeAction::LogOnly
);
}
}