use chrono::{DateTime, Utc};
use tga::core::db::{
load_effort_candidates, prune_non_meaningful_effort, summarize_effort, upsert_pm_effort,
CheckpointMode, Database, PmEffortRow,
};
use tga::core::pm_effort::{self, EffortCounts, PmEffortInput};
use tga::core::pm_work::extract as work_extract;
pub(super) fn backfill_pm_effort(db: &mut Database, dry_run: bool) -> anyhow::Result<()> {
backfill_pm_effort_at(db, dry_run, Utc::now())
}
pub(super) fn backfill_pm_effort_at(
db: &mut Database,
dry_run: bool,
now: DateTime<Utc>,
) -> anyhow::Result<()> {
let candidates = load_effort_candidates(db.connection())
.map_err(|e| anyhow::anyhow!("pm-effort read failed: {e}"))?;
if dry_run {
println!(
"Dry run — would score {} meaningful work item(s) into fact_pm_effort \
(formula_version={}). No changes written.",
candidates.len(),
pm_effort::FORMULA_VERSION
);
return Ok(());
}
let pruned = prune_non_meaningful_effort(db.connection())
.map_err(|e| anyhow::anyhow!("pm-effort prune failed: {e}"))?;
let mut written = 0usize;
for candidate in &candidates {
let row = score_candidate(candidate, now);
upsert_pm_effort(db.connection(), &row)
.map_err(|e| anyhow::anyhow!("pm-effort upsert failed for {}: {e}", candidate.id))?;
written += 1;
}
report(db, written, pruned, candidates.is_empty())?;
Ok(())
}
fn score_candidate(
candidate: &tga::core::db::PmEffortCandidate,
now: DateTime<Utc>,
) -> PmEffortRow {
let work_fields = work_extract::extract_fields(candidate.raw_json.as_deref());
let effort_fields = pm_effort::extract::extract_fields(candidate.raw_json.as_deref());
let age_days = work_fields
.created
.map(|created| (now - created).num_days());
let counts = EffortCounts {
epic_children: candidate.epic_children_count,
description_words: description_word_count(work_fields.description.as_deref()),
comments: candidate.comment_count,
transitions: candidate.transition_count,
story_points: effort_fields.story_points,
};
let score = pm_effort::score(&PmEffortInput {
item_type: &candidate.item_type,
age_days,
counts,
});
PmEffortRow {
work_item_id: candidate.id.clone(),
work_item_source: candidate.source.clone(),
pm_name: work_fields.reporter.clone(),
week_key: work_fields.created.map(work_extract::week_key),
age_days,
counts,
score,
}
}
fn description_word_count(description: Option<&str>) -> u32 {
let words = description.map_or(0, tga::core::pm_work::word_count);
u32::try_from(words).unwrap_or(u32::MAX)
}
fn report(db: &mut Database, written: usize, pruned: usize, empty: bool) -> anyhow::Result<()> {
let (deferred, buckets) = summarize_effort(db.connection())
.map_err(|e| anyhow::anyhow!("pm-effort summary failed: {e}"))?;
println!(
"Backfilled fact_pm_effort: {written} row(s) written (UPSERT semantics), \
formula_version={}.",
pm_effort::FORMULA_VERSION
);
if pruned > 0 {
println!(" pruned {pruned} row(s) whose ticket is no longer meaningful");
}
for (bucket, count) in buckets {
println!(" {bucket}: {count}");
}
println!(
" deferred (inside the {}-day recency floor): {deferred}",
pm_effort::thresholds::RECENCY_FLOOR_DAYS
);
if empty {
println!(
" no meaningful tickets found — run `tga backfill pm-work` first: \
fact_pm_work is the gate this tier scores through."
);
}
if let Err(e) = db.wal_checkpoint(CheckpointMode::Truncate) {
tracing::warn!(error = %e, "WAL TRUNCATE checkpoint failed after pm-effort backfill");
}
Ok(())
}