use std::collections::HashMap;
use rusqlite::{params, Connection};
use tracing::debug;
use crate::core::errors::{Result, TgaError};
use crate::core::pm_effort::{self, EffortBucket, EffortCounts, PmEffortScore, ScoreStatus};
const TRANSITIONS_SOURCE: &str = "jira";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PmEffortCandidate {
pub id: String,
pub source: String,
pub item_type: String,
pub raw_json: Option<String>,
pub epic_children_count: u32,
pub comment_count: u32,
pub transition_count: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PmEffortRow {
pub work_item_id: String,
pub work_item_source: String,
pub pm_name: Option<String>,
pub week_key: Option<String>,
pub age_days: Option<i64>,
pub counts: EffortCounts,
pub score: PmEffortScore,
}
pub fn load_effort_candidates(conn: &Connection) -> Result<Vec<PmEffortCandidate>> {
let children = child_counts_by_parent(conn)?;
let comments = counts_by_ticket(conn, "fact_jira_comment_detail")?;
let transitions = counts_by_ticket(conn, "fact_ticket_transitions")?;
let mut stmt = conn
.prepare(
"SELECT w.id, w.source, w.item_type, w.raw_json \
FROM work_items w \
JOIN fact_pm_work f \
ON f.work_item_id = w.id AND f.work_item_source = w.source \
WHERE f.is_meaningful = 1 \
ORDER BY w.source, w.id",
)
.map_err(TgaError::from)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(TgaError::from)?;
let mut out = Vec::new();
for row in rows {
let (id, source, item_type, raw_json) = row.map_err(TgaError::from)?;
let jira = source == TRANSITIONS_SOURCE;
out.push(PmEffortCandidate {
epic_children_count: children
.get(&(source.clone(), id.clone()))
.copied()
.unwrap_or(0),
comment_count: if jira {
comments.get(&id).copied().unwrap_or(0)
} else {
0
},
transition_count: if jira {
transitions.get(&id).copied().unwrap_or(0)
} else {
0
},
id,
source,
item_type,
raw_json,
});
}
Ok(out)
}
fn child_counts_by_parent(conn: &Connection) -> Result<HashMap<(String, String), u32>> {
let mut stmt = conn
.prepare("SELECT source, raw_json FROM work_items WHERE raw_json IS NOT NULL")
.map_err(TgaError::from)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(TgaError::from)?;
let mut out: HashMap<(String, String), u32> = HashMap::new();
for row in rows {
let (source, raw_json) = row.map_err(TgaError::from)?;
if let Some(parent) = pm_effort::extract::extract_fields(Some(&raw_json)).parent_key {
*out.entry((source, parent)).or_insert(0) += 1;
}
}
Ok(out)
}
fn counts_by_ticket(conn: &Connection, table: &'static str) -> Result<HashMap<String, u32>> {
let sql = format!("SELECT ticket_key, COUNT(*) FROM {table} GROUP BY ticket_key");
let mut stmt = conn.prepare(&sql).map_err(TgaError::from)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.map_err(TgaError::from)?;
let mut out = HashMap::new();
for row in rows {
let (key, count) = row.map_err(TgaError::from)?;
out.insert(key, u32::try_from(count).unwrap_or(u32::MAX));
}
Ok(out)
}
pub fn upsert_pm_effort(conn: &Connection, row: &PmEffortRow) -> Result<()> {
let computed_at = chrono::Utc::now().timestamp();
conn.execute(
"INSERT OR REPLACE INTO fact_pm_effort \
(work_item_id, work_item_source, pm_name, week_key, effort_score, effort_bucket, \
score_status, epic_children_count, description_word_count, comment_count, \
transition_count, story_points, inputs_present, age_days_at_score, \
formula_version, computed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
row.work_item_id,
row.work_item_source,
row.pm_name,
row.week_key,
row.score.effort_score,
row.score.effort_bucket.map(EffortBucket::as_wire_str),
row.score.status.as_wire_str(),
row.counts.epic_children,
row.counts.description_words,
row.counts.comments,
row.counts.transitions,
row.counts.story_points,
row.score.inputs_present.to_wire_string(),
row.age_days,
pm_effort::FORMULA_VERSION,
computed_at,
],
)
.map_err(TgaError::from)?;
debug!(
id = %row.work_item_id,
source = %row.work_item_source,
status = %row.score.status,
score = ?row.score.effort_score,
"upserted pm effort score"
);
Ok(())
}
pub fn prune_non_meaningful_effort(conn: &Connection) -> Result<usize> {
let deleted = conn
.execute(
"DELETE FROM fact_pm_effort WHERE NOT EXISTS ( \
SELECT 1 FROM fact_pm_work f \
WHERE f.work_item_id = fact_pm_effort.work_item_id \
AND f.work_item_source = fact_pm_effort.work_item_source \
AND f.is_meaningful = 1)",
[],
)
.map_err(TgaError::from)?;
Ok(deleted)
}
pub fn summarize_effort(conn: &Connection) -> Result<(i64, Vec<(EffortBucket, i64)>)> {
let mut stmt = conn
.prepare(
"SELECT score_status, effort_bucket, COUNT(*) FROM fact_pm_effort \
GROUP BY score_status, effort_bucket",
)
.map_err(TgaError::from)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, i64>(2)?,
))
})
.map_err(TgaError::from)?;
let mut deferred = 0i64;
let mut by_bucket: HashMap<EffortBucket, i64> = HashMap::new();
for row in rows {
let (status, bucket, count) = row.map_err(TgaError::from)?;
match ScoreStatus::from_wire_str(&status) {
Some(ScoreStatus::DeferredRecent) => deferred += count,
Some(ScoreStatus::Scored) => {
match bucket.as_deref().and_then(EffortBucket::from_wire_str) {
Some(b) => *by_bucket.entry(b).or_insert(0) += count,
None => debug!(bucket = ?bucket, "skipping unrecognised effort_bucket"),
}
}
None => debug!(status = %status, "skipping unrecognised score_status"),
}
}
let ordered = [EffortBucket::Low, EffortBucket::Medium, EffortBucket::High]
.into_iter()
.map(|b| (b, by_bucket.get(&b).copied().unwrap_or(0)))
.collect();
Ok((deferred, ordered))
}