use rusqlite::{params, Connection};
use crate::core::errors::{Result, TgaError};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CorrelationCounts {
pub commits: u64,
pub linked: u64,
pub ticketed_unlinked: u64,
pub unticketed: u64,
pub work_items: u64,
pub work_items_linked: u64,
}
impl CorrelationCounts {
pub fn new(
commits: u64,
linked: u64,
ticketed_unlinked: u64,
unticketed: u64,
work_items: u64,
work_items_linked: u64,
) -> Self {
Self {
commits,
linked,
ticketed_unlinked,
unticketed,
work_items,
work_items_linked,
}
}
pub fn unlinked(&self) -> u64 {
self.commits.saturating_sub(self.linked)
}
pub fn linked_fraction(&self) -> f64 {
if self.commits == 0 {
0.0
} else {
self.linked as f64 / self.commits as f64
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CorrelationRow {
pub sha: String,
pub repository: String,
pub subject: String,
pub ticket_id: Option<String>,
pub work_items: Vec<(String, String, String)>,
}
impl CorrelationRow {
pub fn is_linked(&self) -> bool {
!self.work_items.is_empty()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum CorrelationFilter {
#[default]
All,
Linked,
Unlinked,
}
impl CorrelationFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::Linked,
Self::Linked => Self::Unlinked,
Self::Unlinked => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "all",
Self::Linked => "linked",
Self::Unlinked => "unlinked",
}
}
}
pub fn linked_work_item_sources(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn
.prepare(
"SELECT DISTINCT work_item_source FROM commit_work_items \
ORDER BY work_item_source",
)
.map_err(TgaError::from)?;
let mapped = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(TgaError::from)?;
let mut out = Vec::new();
for row in mapped {
out.push(row.map_err(TgaError::from)?);
}
Ok(out)
}
pub fn correlation_counts(conn: &Connection) -> Result<CorrelationCounts> {
let scalar = |sql: &str| -> Result<u64> {
conn.query_row(sql, [], |r| r.get::<_, i64>(0))
.map(|v| v.max(0) as u64)
.map_err(TgaError::from)
};
let commits = scalar("SELECT COUNT(*) FROM commits")?;
let linked = scalar(
"SELECT COUNT(*) FROM commits c \
WHERE EXISTS (SELECT 1 FROM commit_work_items w WHERE w.commit_sha = c.sha)",
)?;
let ticketed_unlinked = scalar(
"SELECT COUNT(*) FROM commits c \
WHERE c.ticket_id IS NOT NULL AND TRIM(c.ticket_id) <> '' \
AND NOT EXISTS (SELECT 1 FROM commit_work_items w WHERE w.commit_sha = c.sha)",
)?;
let unticketed = scalar(
"SELECT COUNT(*) FROM commits c \
WHERE (c.ticket_id IS NULL OR TRIM(c.ticket_id) = '') \
AND NOT EXISTS (SELECT 1 FROM commit_work_items w WHERE w.commit_sha = c.sha)",
)?;
let work_items = scalar("SELECT COUNT(*) FROM work_items")?;
let work_items_linked = scalar(
"SELECT COUNT(*) FROM work_items i \
WHERE EXISTS (SELECT 1 FROM commit_work_items w \
WHERE w.work_item_id = i.id AND w.work_item_source = i.source)",
)?;
Ok(CorrelationCounts::new(
commits,
linked,
ticketed_unlinked,
unticketed,
work_items,
work_items_linked,
))
}
pub fn correlation_rows(
conn: &Connection,
filter: CorrelationFilter,
limit: usize,
) -> Result<Vec<CorrelationRow>> {
let predicate = match filter {
CorrelationFilter::All => "1 = 1",
CorrelationFilter::Linked => {
"EXISTS (SELECT 1 FROM commit_work_items w WHERE w.commit_sha = c.sha)"
}
CorrelationFilter::Unlinked => {
"NOT EXISTS (SELECT 1 FROM commit_work_items w WHERE w.commit_sha = c.sha)"
}
};
let sql = format!(
"SELECT c.sha, c.repository, c.message, c.ticket_id \
FROM commits c WHERE {predicate} \
ORDER BY c.timestamp DESC, c.sha LIMIT ?1"
);
let mut stmt = conn.prepare(&sql).map_err(TgaError::from)?;
let mut rows: Vec<CorrelationRow> = Vec::new();
let mapped = stmt
.query_map(params![limit as i64], |row| {
let message: String = row.get(2)?;
Ok(CorrelationRow {
sha: row.get(0)?,
repository: row.get(1)?,
subject: message.lines().next().unwrap_or_default().to_string(),
ticket_id: row.get(3)?,
work_items: Vec::new(),
})
})
.map_err(TgaError::from)?;
for r in mapped {
rows.push(r.map_err(TgaError::from)?);
}
let mut items = conn
.prepare(
"SELECT i.source, i.id, i.title FROM work_items i \
JOIN commit_work_items w \
ON w.work_item_id = i.id AND w.work_item_source = i.source \
WHERE w.commit_sha = ?1 ORDER BY i.source, i.id",
)
.map_err(TgaError::from)?;
for row in &mut rows {
let mapped = items
.query_map(params![row.sha], |r| {
Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?))
})
.map_err(TgaError::from)?;
for it in mapped {
row.work_items.push(it.map_err(TgaError::from)?);
}
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::db::work_items::{link_commit_work_item, upsert_work_item, WorkItemRow};
use crate::core::db::Database;
fn work_item(id: &str, source: &str) -> WorkItemRow {
WorkItemRow {
id: id.into(),
source: source.into(),
title: format!("Item {id}"),
status: "Open".into(),
item_type: "Task".into(),
tags: None,
project: None,
url: None,
raw_json: None,
}
}
fn insert_commit(conn: &Connection, sha: &str, message: &str, ticket: Option<&str>) {
conn.execute(
"INSERT INTO commits (sha, author_name, author_email, timestamp, message, \
repository, ticket_id) \
VALUES (?1, 'A', 'a@x', '2026-01-01T00:00:00Z', ?2, 'repo', ?3)",
params![sha, message, ticket],
)
.expect("insert commit");
}
#[test]
fn sources_name_only_boards_with_a_linked_item() {
let db = Database::open_in_memory().expect("open");
insert_commit(db.connection(), "aaa", "PROJ-1 x", Some("PROJ-1"));
upsert_work_item(db.connection(), &work_item("PROJ-1", "jira")).expect("jira");
upsert_work_item(db.connection(), &work_item("ENG-7", "linear")).expect("linear");
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "jira").expect("link");
assert_eq!(
linked_work_item_sources(db.connection()).expect("sources"),
vec!["jira".to_string()],
"linear was synced but never linked, so it is not a source"
);
}
#[test]
fn counts_split_linked_and_unlinked() {
let db = Database::open_in_memory().expect("open");
insert_commit(db.connection(), "aaa", "PROJ-1 do a thing", Some("PROJ-1"));
insert_commit(db.connection(), "bbb", "chore: tidy", None);
upsert_work_item(db.connection(), &work_item("PROJ-1", "jira")).expect("upsert");
let before = correlation_counts(db.connection()).expect("counts");
assert_eq!(before.commits, 2);
assert_eq!(before.linked, 0);
assert_eq!(before.ticketed_unlinked, 1);
assert_eq!(before.unticketed, 1);
assert_eq!(before.unlinked(), 2);
assert_eq!(before.work_items, 1);
assert_eq!(before.work_items_linked, 0);
assert_eq!(before.linked_fraction(), 0.0);
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "jira").expect("link");
let after = correlation_counts(db.connection()).expect("counts");
assert_eq!(after.linked, 1);
assert_eq!(after.ticketed_unlinked, 0);
assert_eq!(after.unlinked(), 1);
assert_eq!(after.work_items_linked, 1);
assert!((after.linked_fraction() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn empty_database_reports_zeroes_not_nan() {
let db = Database::open_in_memory().expect("open");
let c = correlation_counts(db.connection()).expect("counts");
assert_eq!(c, CorrelationCounts::default());
assert_eq!(c.linked_fraction(), 0.0);
}
#[test]
fn rows_report_links_and_gaps() {
let db = Database::open_in_memory().expect("open");
insert_commit(
db.connection(),
"aaa",
"PROJ-1 subject\n\nbody text",
Some("PROJ-1"),
);
insert_commit(db.connection(), "bbb", "chore: tidy", None);
upsert_work_item(db.connection(), &work_item("PROJ-1", "jira")).expect("upsert");
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "jira").expect("link");
let rows = correlation_rows(db.connection(), CorrelationFilter::All, 10).expect("rows");
assert_eq!(rows.len(), 2);
let linked: Vec<&CorrelationRow> = rows.iter().filter(|r| r.is_linked()).collect();
assert_eq!(linked.len(), 1);
assert_eq!(linked[0].sha, "aaa");
assert_eq!(
linked[0].subject, "PROJ-1 subject",
"subject is line 1 only"
);
assert_eq!(linked[0].repository, "repo");
assert_eq!(linked[0].ticket_id.as_deref(), Some("PROJ-1"));
assert_eq!(linked[0].work_items[0].0, "jira");
assert_eq!(linked[0].work_items[0].2, "Item PROJ-1");
let unlinked: Vec<&CorrelationRow> = rows.iter().filter(|r| !r.is_linked()).collect();
assert_eq!(unlinked[0].sha, "bbb");
assert!(unlinked[0].ticket_id.is_none());
}
#[test]
fn rows_list_every_source_for_one_commit() {
let db = Database::open_in_memory().expect("open");
insert_commit(db.connection(), "aaa", "PROJ-1 x", Some("PROJ-1"));
upsert_work_item(db.connection(), &work_item("PROJ-1", "jira")).expect("jira");
upsert_work_item(db.connection(), &work_item("PROJ-1", "linear")).expect("linear");
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "jira").expect("link jira");
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "linear").expect("link linear");
let rows = correlation_rows(db.connection(), CorrelationFilter::Linked, 10).expect("rows");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0]
.work_items
.iter()
.map(|(s, _, _)| s.as_str())
.collect::<Vec<_>>(),
vec!["jira", "linear"]
);
}
#[test]
fn rows_respect_filter() {
let db = Database::open_in_memory().expect("open");
insert_commit(db.connection(), "aaa", "PROJ-1 x", Some("PROJ-1"));
insert_commit(db.connection(), "bbb", "chore: tidy", None);
upsert_work_item(db.connection(), &work_item("PROJ-1", "jira")).expect("upsert");
link_commit_work_item(db.connection(), "aaa", "PROJ-1", "jira").expect("link");
let all = correlation_rows(db.connection(), CorrelationFilter::All, 10).expect("all");
let linked =
correlation_rows(db.connection(), CorrelationFilter::Linked, 10).expect("linked");
let unlinked =
correlation_rows(db.connection(), CorrelationFilter::Unlinked, 10).expect("unlinked");
assert_eq!(all.len(), 2);
assert_eq!(linked.len(), 1);
assert_eq!(unlinked.len(), 1);
assert_eq!(unlinked[0].sha, "bbb");
}
#[test]
fn rows_respect_limit() {
let db = Database::open_in_memory().expect("open");
for i in 0..5 {
insert_commit(db.connection(), &format!("sha{i}"), "chore: x", None);
}
let rows = correlation_rows(db.connection(), CorrelationFilter::All, 2).expect("rows");
assert_eq!(rows.len(), 2);
}
#[test]
fn filter_cycles() {
assert_eq!(CorrelationFilter::default(), CorrelationFilter::All);
assert_eq!(CorrelationFilter::All.next(), CorrelationFilter::Linked);
assert_eq!(
CorrelationFilter::Linked.next(),
CorrelationFilter::Unlinked
);
assert_eq!(CorrelationFilter::Unlinked.next(), CorrelationFilter::All);
assert_eq!(CorrelationFilter::All.label(), "all");
assert_eq!(CorrelationFilter::Linked.label(), "linked");
assert_eq!(CorrelationFilter::Unlinked.label(), "unlinked");
}
}