use lechange_core::{
coordination::{FileProcessor, WorkflowTracker},
git::GitRepository,
http::WorkflowApiClient,
interner::StringInterner,
types::{
ChangeType, ChangedFile, FailureTrackingLevel, FileOrigin, InputConfig, WorkflowConclusion,
WorkflowFailure, WorkflowJob, WorkflowRun, WorkflowStatus, WorkflowSuccess,
},
};
fn has_github_env() -> bool {
std::env::var("GITHUB_TOKEN").is_ok()
&& std::env::var("GITHUB_REPOSITORY").is_ok()
&& std::env::var("GITHUB_REF").is_ok()
}
fn setup_test_env() -> (String, String) {
if !has_github_env() {
panic!(
"Required environment variables not set: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_REF"
);
}
let repository = std::env::var("GITHUB_REPOSITORY").unwrap();
let parts: Vec<&str> = repository.split('/').collect();
if parts.len() != 2 {
panic!("Invalid GITHUB_REPOSITORY format: {}", repository);
}
(parts[0].to_string(), parts[1].to_string())
}
#[tokio::test]
#[ignore] async fn test_workflow_api_client_connection() {
let (owner, repo) = setup_test_env();
let client = WorkflowApiClient::from_env().expect("Failed to create API client");
let interner = StringInterner::new();
let result = client
.list_workflow_runs(&owner, &repo, "", None, 10, 1, &interner)
.await;
assert!(
result.is_ok(),
"Failed to list workflows: {:?}",
result.err()
);
let workflows = result.unwrap();
println!("Found {} workflows", workflows.len());
}
#[tokio::test]
#[ignore]
async fn test_list_workflow_runs_with_status_filter() {
let (owner, repo) = setup_test_env();
let client = WorkflowApiClient::from_env().expect("Failed to create API client");
let interner = StringInterner::new();
let queued = client
.list_workflow_runs(&owner, &repo, "", Some("queued"), 10, 1, &interner)
.await
.expect("Failed to list queued workflows");
println!("Queued workflows: {}", queued.len());
let in_progress = client
.list_workflow_runs(&owner, &repo, "", Some("in_progress"), 10, 1, &interner)
.await
.expect("Failed to list in_progress workflows");
println!("In-progress workflows: {}", in_progress.len());
let completed = client
.list_workflow_runs(&owner, &repo, "", Some("completed"), 10, 1, &interner)
.await
.expect("Failed to list completed workflows");
println!("Completed workflows: {}", completed.len());
}
#[tokio::test]
#[ignore]
async fn test_get_commit_files() {
let (owner, repo) = setup_test_env();
let client = WorkflowApiClient::from_env().expect("Failed to create API client");
let interner = StringInterner::new();
let repo_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let git_repo = match GitRepository::discover(&repo_path) {
Ok(r) => r,
Err(_) => {
println!("Not in a git repository, skipping test");
return;
}
};
let head_sha = git_repo
.resolve_sha_sync("HEAD")
.expect("Failed to resolve HEAD");
println!("Testing with HEAD SHA: {}", head_sha);
let result = client
.get_commit_files(&owner, &repo, &head_sha, &interner)
.await;
assert!(
result.is_ok(),
"Failed to get commit files: {:?}",
result.err()
);
let files = result.unwrap();
println!("Commit has {} files", files.len());
for file_path in &files {
let resolved = interner.resolve(*file_path);
assert!(resolved.is_some(), "File path not properly interned");
println!(" - {}", resolved.unwrap());
}
}
#[tokio::test]
#[ignore]
async fn test_workflow_tracker_basic() {
let (_owner, _repo) = setup_test_env();
let interner = StringInterner::new();
let config = InputConfig {
track_workflow_failures: true,
workflow_lookback_commits: 5,
wait_for_active_workflows: false, workflow_max_wait_seconds: 10,
include_failed_files: true,
..Default::default()
};
let api_client = WorkflowApiClient::from_env().expect("Failed to create API client");
let tracker = WorkflowTracker::new(api_client, &config, &interner, &[]);
let current_files = vec![ChangedFile {
path: interner.intern("README.md"),
change_type: ChangeType::Modified,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
}];
let github_ref = std::env::var("GITHUB_REF").unwrap_or_else(|_| "refs/heads/main".to_string());
let branch = github_ref
.strip_prefix("refs/heads/")
.unwrap_or(&github_ref);
println!("Testing with branch: {}", branch);
let result = tracker.check_workflows(branch, ¤t_files).await;
assert!(
result.is_ok(),
"Failed to check workflows: {:?}",
result.err()
);
let workflow_result = result.unwrap();
println!("Blocking runs: {}", workflow_result.blocking_runs.len());
println!("Recent failures: {}", workflow_result.failures.len());
println!("Waited: {}", workflow_result.waited);
println!("Wait time: {}ms", workflow_result.wait_time_ms);
}
#[tokio::test]
#[ignore]
async fn test_workflow_tracker_file_merging() {
let interner = StringInterner::new();
let config = InputConfig::default();
let api_client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
let tracker = WorkflowTracker::new(api_client, &config, &interner, &[]);
let path1 = interner.intern("src/lib.rs");
let path2 = interner.intern("src/types.rs");
let path3 = interner.intern("src/error.rs");
let mut current_files = vec![
ChangedFile {
path: path1,
change_type: ChangeType::Modified,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
},
ChangedFile {
path: path2,
change_type: ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
},
];
let failures = vec![lechange_core::types::WorkflowFailure {
run: lechange_core::types::WorkflowRun {
id: 123,
name: interner.intern("CI"),
status: lechange_core::types::WorkflowStatus::Completed,
conclusion: Some(lechange_core::types::WorkflowConclusion::Failure),
branch: interner.intern("main"),
head_sha: interner.intern("abc123"),
created_at: 0,
},
files: vec![path2, path3], failed_jobs: Vec::new(),
}];
tracker.merge_failed_files(&mut current_files, &failures);
assert_eq!(current_files.len(), 3);
assert!(current_files[0].origin.in_current_changes);
assert!(!current_files[0].origin.in_previous_failure);
assert!(current_files[1].origin.in_current_changes);
assert!(current_files[1].origin.in_previous_failure);
assert!(!current_files[2].origin.in_current_changes);
assert!(current_files[2].origin.in_previous_failure);
assert_eq!(current_files[2].change_type, ChangeType::Unknown);
println!("File merging test passed!");
}
#[tokio::test]
#[ignore]
async fn test_full_pipeline_with_workflow_tracking() {
if !has_github_env() {
println!("Skipping test - GitHub environment not available");
return;
}
let repo_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let git_repo = match GitRepository::discover(&repo_path) {
Ok(r) => r,
Err(e) => {
println!("Not in a git repository, skipping test: {:?}", e);
return;
}
};
let rev = |spec: &str| -> Option<String> {
let out = std::process::Command::new("git")
.args(["rev-parse", spec])
.current_dir(&repo_path)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let (Some(head), Some(base)) = (rev("HEAD"), rev("HEAD~1")) else {
println!("Shallow checkout without HEAD~1, skipping test");
return;
};
let interner = StringInterner::new();
let config = InputConfig {
base_sha: Some(std::borrow::Cow::Borrowed(&base)),
sha: Some(std::borrow::Cow::Borrowed(&head)),
track_workflow_failures: true,
workflow_lookback_commits: 3,
wait_for_active_workflows: false, workflow_max_wait_seconds: 30,
include_failed_files: true,
..Default::default()
};
let processor = FileProcessor::new(&git_repo, &interner, &config);
let result = processor.process().await;
assert!(result.is_ok(), "Pipeline failed: {:?}", result.err());
let processed = result.unwrap();
println!("Pipeline completed successfully!");
println!("Total files: {}", processed.all_files.len());
let mut current_count = 0;
let mut failed_count = 0;
let mut both_count = 0;
for file in &processed.all_files {
if file.origin.in_current_changes && file.origin.in_previous_failure {
both_count += 1;
} else if file.origin.in_current_changes {
current_count += 1;
} else if file.origin.in_previous_failure {
failed_count += 1;
}
}
println!("Current only: {}", current_count);
println!("Failed only: {}", failed_count);
println!("Both: {}", both_count);
}
#[tokio::test]
#[ignore]
async fn test_rate_limit_detection() {
let client = WorkflowApiClient::new(
"https://api.github.com".to_string(),
None, );
let interner = StringInterner::new();
let result = client
.list_workflow_runs("octocat", "Hello-World", "", None, 10, 1, &interner)
.await;
match result {
Ok(workflows) => {
println!("Successfully fetched {} workflows", workflows.len());
}
Err(e) => {
println!("Error (expected without token): {:?}", e);
assert!(e.to_string().contains("Workflow") || e.to_string().contains("rate"));
}
}
}
#[tokio::test]
#[ignore]
async fn test_exponential_backoff_timeout() {
let interner = StringInterner::new();
let config = InputConfig {
wait_for_active_workflows: true,
workflow_max_wait_seconds: 5, ..Default::default()
};
let api_client = WorkflowApiClient::from_env().expect("Failed to create API client");
let _tracker = WorkflowTracker::new(api_client, &config, &interner, &[]);
println!("Timeout configuration test passed");
}
#[test]
fn test_workflow_tracker_environment_parsing() {
let original_repo = std::env::var("GITHUB_REPOSITORY").ok();
let original_ref = std::env::var("GITHUB_REF").ok();
std::env::set_var("GITHUB_REPOSITORY", "owner/repo");
std::env::set_var("GITHUB_REF", "refs/heads/main");
let interner = StringInterner::new();
let config = InputConfig::default();
let api_client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
let tracker = WorkflowTracker::new(api_client, &config, &interner, &[]);
assert!(std::env::var("GITHUB_REPOSITORY").is_ok());
assert!(std::env::var("GITHUB_REF").is_ok());
drop(tracker);
if let Some(repo) = original_repo {
std::env::set_var("GITHUB_REPOSITORY", repo);
} else {
std::env::remove_var("GITHUB_REPOSITORY");
}
if let Some(ref_val) = original_ref {
std::env::set_var("GITHUB_REF", ref_val);
} else {
std::env::remove_var("GITHUB_REF");
}
}
#[test]
fn test_job_level_partitioning_and_merge() {
use lechange_core::patterns::{loader::PatternGroup, matcher::PatternMatcher};
let interner = StringInterner::new();
let dev_matcher = PatternMatcher::new(&["stacks/dev/**"], &[], true).unwrap();
let staging_matcher = PatternMatcher::new(&["stacks/staging/**"], &[], true).unwrap();
let prod_matcher = PatternMatcher::new(&["stacks/prod/**"], &[], true).unwrap();
let groups = vec![
PatternGroup {
name: "dev".to_string(),
matcher: dev_matcher,
},
PatternGroup {
name: "staging".to_string(),
matcher: staging_matcher,
},
PatternGroup {
name: "prod".to_string(),
matcher: prod_matcher,
},
];
let config = InputConfig {
failure_tracking_level: FailureTrackingLevel::Job,
track_workflow_failures: true,
include_failed_files: true,
skip_successful_files: true,
..Default::default()
};
let api_client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
let tracker = WorkflowTracker::new(api_client, &config, &interner, &groups);
let file_dev = interner.intern("stacks/dev/config.yaml");
let file_prod = interner.intern("stacks/prod/config.yaml");
let failed_jobs_run1 = vec![WorkflowJob {
id: 10,
name: interner.intern("Deploy [prod]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
run_id: 1000,
started_at: 100,
completed_at: 200,
}];
let succeeded_job_run1 = WorkflowJob {
id: 11,
name: interner.intern("Deploy [dev]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Success),
run_id: 1000,
started_at: 100,
completed_at: 200,
};
let failed_files =
tracker.partition_files_for_failed_jobs(&[file_dev, file_prod], &failed_jobs_run1);
assert_eq!(failed_files.len(), 1, "Only prod should be in failed files");
assert!(failed_files.contains(&file_prod));
let succeeded_files =
tracker.partition_files_for_succeeded_jobs(&[file_dev, file_prod], &[&succeeded_job_run1]);
assert_eq!(
succeeded_files.len(),
1,
"Only dev should be in succeeded files"
);
assert!(succeeded_files.contains(&file_dev));
let failures = vec![WorkflowFailure {
run: WorkflowRun {
id: 1000,
name: interner.intern("Deploy Stacks"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
branch: interner.intern("main"),
head_sha: interner.intern("sha_run1"),
created_at: 1000,
},
files: failed_files,
failed_jobs: vec![WorkflowJob {
id: 10,
name: interner.intern("Deploy [prod]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
run_id: 1000,
started_at: 100,
completed_at: 200,
}],
}];
let successes = vec![WorkflowSuccess {
run: WorkflowRun {
id: 1000,
name: interner.intern("Deploy Stacks"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
branch: interner.intern("main"),
head_sha: interner.intern("sha_run1"),
created_at: 1000,
},
files: succeeded_files,
jobs: vec![WorkflowJob {
id: 11,
name: interner.intern("Deploy [dev]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Success),
run_id: 1000,
started_at: 100,
completed_at: 200,
}],
}];
let file_staging = interner.intern("stacks/staging/config.yaml");
let mut current_files = vec![ChangedFile {
path: file_staging,
change_type: ChangeType::Modified,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: FileOrigin {
in_current_changes: true,
in_previous_failure: false,
in_previous_success: false,
},
}];
tracker.merge_failed_files(&mut current_files, &failures);
assert_eq!(
current_files.len(),
2,
"Should have staging (current) + prod (previous failure)"
);
let staging = current_files
.iter()
.find(|f| f.path == file_staging)
.unwrap();
assert!(staging.origin.in_current_changes);
assert!(!staging.origin.in_previous_failure);
let prod = current_files.iter().find(|f| f.path == file_prod).unwrap();
assert!(!prod.origin.in_current_changes);
assert!(prod.origin.in_previous_failure);
assert_eq!(prod.change_type, ChangeType::Unknown);
use lechange_core::coordination::CiDecisionEngine;
let engine = CiDecisionEngine::new();
let decision = engine.compute(¤t_files, &failures, &successes);
assert!(
decision.files_to_rebuild.contains(&file_staging),
"Staging should be in rebuild set (new change)"
);
assert!(
decision.files_to_rebuild.contains(&file_prod),
"Prod should be in rebuild set (previous failure)"
);
assert!(
decision.files_to_skip.contains(&file_dev),
"Dev should be in skip set (previously succeeded)"
);
let rebuild_set: std::collections::HashSet<_> =
decision.files_to_rebuild.iter().cloned().collect();
let skip_set: std::collections::HashSet<_> = decision.files_to_skip.iter().cloned().collect();
let overlap: Vec<_> = rebuild_set.intersection(&skip_set).collect();
assert!(overlap.is_empty(), "rebuild ∩ skip must be empty");
assert!(
decision
.failed_jobs
.contains(&interner.intern("Deploy [prod]")),
"Deploy [prod] should be in failed jobs"
);
assert!(
decision
.successful_jobs
.contains(&interner.intern("Deploy [dev]")),
"Deploy [dev] should be in successful jobs"
);
let prod_reason = decision
.rebuild_reasons
.iter()
.find(|r| r.file == file_prod);
assert!(prod_reason.is_some(), "Prod should have a rebuild reason");
let staging_reason = decision
.rebuild_reasons
.iter()
.find(|r| r.file == file_staging);
assert!(
staging_reason.is_some(),
"Staging should have a rebuild reason"
);
}
#[test]
fn test_job_level_all_stacks_mixed_results() {
use lechange_core::patterns::{loader::PatternGroup, matcher::PatternMatcher};
let interner = StringInterner::new();
let dev_matcher = PatternMatcher::new(&["stacks/dev/**"], &[], true).unwrap();
let staging_matcher = PatternMatcher::new(&["stacks/staging/**"], &[], true).unwrap();
let prod_matcher = PatternMatcher::new(&["stacks/prod/**"], &[], true).unwrap();
let groups = vec![
PatternGroup {
name: "dev".to_string(),
matcher: dev_matcher,
},
PatternGroup {
name: "staging".to_string(),
matcher: staging_matcher,
},
PatternGroup {
name: "prod".to_string(),
matcher: prod_matcher,
},
];
let config = InputConfig {
failure_tracking_level: FailureTrackingLevel::Job,
..Default::default()
};
let api_client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
let tracker = WorkflowTracker::new(api_client, &config, &interner, &groups);
let file_dev = interner.intern("stacks/dev/config.yaml");
let file_staging = interner.intern("stacks/staging/config.yaml");
let file_prod = interner.intern("stacks/prod/config.yaml");
let commit_files = vec![file_dev, file_staging, file_prod];
let failed_jobs = vec![
WorkflowJob {
id: 1,
name: interner.intern("Deploy [staging]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
run_id: 100,
started_at: 0,
completed_at: 0,
},
WorkflowJob {
id: 2,
name: interner.intern("Deploy [prod]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
run_id: 100,
started_at: 0,
completed_at: 0,
},
];
let dev_job = WorkflowJob {
id: 3,
name: interner.intern("Deploy [dev]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Success),
run_id: 100,
started_at: 0,
completed_at: 0,
};
let failed_files = tracker.partition_files_for_failed_jobs(&commit_files, &failed_jobs);
assert_eq!(
failed_files.len(),
2,
"staging + prod should be failed files"
);
assert!(failed_files.contains(&file_staging));
assert!(failed_files.contains(&file_prod));
assert!(!failed_files.contains(&file_dev));
let succeeded_files = tracker.partition_files_for_succeeded_jobs(&commit_files, &[&dev_job]);
assert_eq!(succeeded_files.len(), 1, "Only dev should be succeeded");
assert!(succeeded_files.contains(&file_dev));
}
#[test]
fn test_run_level_tracking_gives_all_files() {
let interner = StringInterner::new();
let config = InputConfig {
failure_tracking_level: FailureTrackingLevel::Run, ..Default::default()
};
let api_client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
let tracker = WorkflowTracker::new(api_client, &config, &interner, &[]);
let file_dev = interner.intern("stacks/dev/config.yaml");
let file_prod = interner.intern("stacks/prod/config.yaml");
let commit_files = vec![file_dev, file_prod];
let failed_jobs = vec![WorkflowJob {
id: 1,
name: interner.intern("Deploy [prod]"),
status: WorkflowStatus::Completed,
conclusion: Some(WorkflowConclusion::Failure),
run_id: 100,
started_at: 0,
completed_at: 0,
}];
let result = tracker.partition_files_for_failed_jobs(&commit_files, &failed_jobs);
assert_eq!(
result.len(),
2,
"Run-level should attribute all files to failure"
);
}