pub mod ai_attribution;
pub mod azdo;
pub mod bitbucket;
pub mod collector;
pub mod correlate;
pub mod env_expand;
pub mod errors;
pub mod git;
pub mod github;
mod github_pipeline;
pub mod identity;
pub mod jira;
pub mod linear;
mod linear_pipeline;
mod notify;
pub mod pm_adapter;
pub mod pr_provider;
pub mod ticket;
pub mod weeks;
pub use collector::{CollectionPipeline, CollectionStats};
pub use correlate::{correlate_commits, CorrelationOutcome};
pub use errors::{CollectError, Result};
pub use pm_adapter::{
build_adapters, AzureDevOpsAdapter, GitHubAdapter, JiraAdapter, LinearAdapter, PmAdapter,
PmError, PmSource, PmTicket,
};
pub use pr_provider::PrProvider;
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{Config, RepositoryConfig};
#[test]
fn git_collector_rejects_missing_path() {
let cfg = RepositoryConfig {
path: "/definitely/does/not/exist/here".into(),
..Default::default()
};
let err = git::GitCollector::new(&cfg).expect_err("should fail");
match err {
CollectError::Config(msg) => assert!(msg.contains("does not exist"), "msg: {msg}"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn git_collector_rejects_non_repo_path() {
let cfg = RepositoryConfig {
path: std::env::temp_dir(),
..Default::default()
};
let err = git::GitCollector::new(&cfg).expect_err("should fail");
assert!(matches!(err, CollectError::Git(_)));
}
#[test]
fn pipeline_constructs_with_default_config() {
let cfg = Config::default();
let _pipeline = CollectionPipeline::new(cfg);
}
#[test]
fn progress_is_disabled_by_default() {
let observer = crate::core::progress::ProgressBus::bounded(8);
let pipeline = CollectionPipeline::new(Config::default());
let mut db = crate::core::db::Database::open_in_memory().expect("open");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(pipeline.run(&mut db)).expect("run");
assert!(
observer.drain().is_empty(),
"a pipeline with no bus attached must publish nothing"
);
}
#[test]
fn run_emits_a_terminal_event_per_repo() {
let bus = crate::core::progress::ProgressBus::bounded(64);
let cfg = Config {
repositories: vec![RepositoryConfig {
path: std::env::temp_dir().join("tga-5197-not-a-repo"),
name: Some("ghost".into()),
..Default::default()
}],
..Default::default()
};
let pipeline = CollectionPipeline::new(cfg).with_progress(bus.clone());
let mut db = crate::core::db::Database::open_in_memory().expect("open");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(pipeline.run(&mut db)).expect("run");
let events = bus.drain();
assert!(!events.is_empty());
let terminal: Vec<_> = events
.iter()
.filter(|e| e.target == "ghost" && e.is_terminal())
.collect();
assert_eq!(terminal.len(), 1, "exactly one terminal event: {events:?}");
assert!(
matches!(
terminal[0].outcome,
Some(crate::core::progress::Outcome::Failed { .. })
),
"an unopenable repo reports failed, not completed"
);
}
fn empty_repos(tag: &str, n: usize) -> (std::path::PathBuf, Vec<RepositoryConfig>) {
let root = std::env::temp_dir().join(format!("tga-5197-{tag}-{}", std::process::id()));
let mut repos = Vec::new();
for i in 0..n {
let path = root.join(format!("repo{i}"));
std::fs::create_dir_all(&path).expect("mkdir");
git2::Repository::init(&path).expect("git init");
repos.push(RepositoryConfig {
path,
name: Some(format!("repo{i}")),
since_date: Some("2026-01-05".into()),
until_date: Some("2026-01-11".into()),
..Default::default()
});
}
(root, repos)
}
fn run_pipeline(repos: Vec<RepositoryConfig>, bus: &crate::core::progress::ProgressBus) {
let cfg = Config {
repositories: repos,
..Default::default()
};
let pipeline = CollectionPipeline::new(cfg).with_progress(bus.clone());
let mut db = crate::core::db::Database::open_in_memory().expect("open");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(pipeline.run(&mut db)).expect("run");
}
#[test]
fn run_reports_failed_when_a_week_fails() {
let bus = crate::core::progress::ProgressBus::bounded(64);
let (root, repos) = empty_repos("week-fail", 1);
run_pipeline(repos, &bus);
let _ = std::fs::remove_dir_all(&root);
let events = bus.drain();
let terminal: Vec<_> = events
.iter()
.filter(|e| e.target == "repo0" && e.is_terminal())
.collect();
assert_eq!(terminal.len(), 1, "exactly one terminal event: {events:?}");
match &terminal[0].outcome {
Some(crate::core::progress::Outcome::Failed { reason }) => {
assert!(
reason.contains("error(s)"),
"reason names the count: {reason}"
);
}
other => panic!("a repo whose walk failed must report Failed, got {other:?}"),
}
}
#[test]
fn operator_lines_reach_the_bus_instead_of_the_terminal() {
let bus = crate::core::progress::ProgressBus::bounded(64);
let root = std::env::temp_dir().join(format!("tga-5197-notify-{}", std::process::id()));
let path = root.join("repo0");
std::fs::create_dir_all(&path).expect("mkdir");
git2::Repository::init(&path).expect("git init");
run_pipeline(
vec![RepositoryConfig {
path,
name: Some("repo0".into()),
..Default::default()
}],
&bus,
);
let _ = std::fs::remove_dir_all(&root);
let events = bus.drain();
assert!(
events.iter().any(|e| e
.detail
.as_deref()
.is_some_and(|d| d.contains("collecting FULL git history"))),
"the full-history warning must arrive on the bus, not on stderr: {events:?}"
);
}
#[test]
fn no_repo_row_claims_a_fraction_it_cannot_substantiate() {
let bus = crate::core::progress::ProgressBus::bounded(64);
let (root, repos) = empty_repos("fraction", 2);
run_pipeline(repos, &bus);
let _ = std::fs::remove_dir_all(&root);
let events = bus.drain();
let repo_rows: Vec<_> = events
.iter()
.filter(|e| e.target.starts_with("repo") && !e.is_terminal())
.collect();
assert_eq!(
repo_rows.len(),
2,
"one in-flight event per repo: {events:?}"
);
for e in repo_rows {
assert_eq!(e.total, None, "no total is known mid-walk: {e:?}");
assert_eq!(e.done, 0, "no intra-repo progress is ever emitted: {e:?}");
}
}
}