use mongodb::bson::doc; use compliance_core::models::*; use super::orchestrator::{extract_base_url, PipelineOrchestrator}; use super::tracker_dispatch::TrackerDispatch; use crate::error::AgentError; use crate::trackers; impl PipelineOrchestrator { /// Build an issue tracker client from a repository's tracker configuration. /// Returns `None` if the repo has no tracker configured. pub(super) fn build_tracker(&self, repo: &TrackedRepository) -> Option { let tracker_type = repo.tracker_type.as_ref()?; // Per-repo token takes precedence, fall back to global config match tracker_type { TrackerType::GitHub => { let token = repo.tracker_token.clone().or_else(|| { self.config.github_token.as_ref().map(|t| { use secrecy::ExposeSecret; t.expose_secret().to_string() }) })?; let secret = secrecy::SecretString::from(token); match trackers::github::GitHubTracker::new(&secret) { Ok(t) => Some(TrackerDispatch::GitHub(t)), Err(e) => { tracing::warn!("Failed to build GitHub tracker: {e}"); None } } } TrackerType::GitLab => { let base_url = self .config .gitlab_url .clone() .unwrap_or_else(|| "https://gitlab.com".to_string()); let token = repo.tracker_token.clone().or_else(|| { self.config.gitlab_token.as_ref().map(|t| { use secrecy::ExposeSecret; t.expose_secret().to_string() }) })?; let secret = secrecy::SecretString::from(token); Some(TrackerDispatch::GitLab( trackers::gitlab::GitLabTracker::new(base_url, secret), )) } TrackerType::Gitea => { let token = repo.tracker_token.clone()?; let base_url = extract_base_url(&repo.git_url)?; let secret = secrecy::SecretString::from(token); Some(TrackerDispatch::Gitea(trackers::gitea::GiteaTracker::new( base_url, secret, ))) } TrackerType::Jira => { let base_url = self.config.jira_url.clone()?; let email = self.config.jira_email.clone()?; let project_key = self.config.jira_project_key.clone()?; let token = repo.tracker_token.clone().or_else(|| { self.config.jira_api_token.as_ref().map(|t| { use secrecy::ExposeSecret; t.expose_secret().to_string() }) })?; let secret = secrecy::SecretString::from(token); Some(TrackerDispatch::Jira(trackers::jira::JiraTracker::new( base_url, email, secret, project_key, ))) } } } /// Create tracker issues for new findings (severity >= Medium). /// Checks for duplicates via fingerprint search before creating. #[tracing::instrument(skip_all, fields(repo_id = %repo_id))] pub(super) async fn create_tracker_issues( &self, repo: &TrackedRepository, repo_id: &str, new_findings: &[Finding], ) -> Result<(), AgentError> { let tracker = match self.build_tracker(repo) { Some(t) => t, None => { tracing::info!("[{repo_id}] No issue tracker configured, skipping"); return Ok(()); } }; let owner = match repo.tracker_owner.as_deref() { Some(o) => o, None => { tracing::warn!("[{repo_id}] tracker_owner not set, skipping issue creation"); return Ok(()); } }; let tracker_repo_name = match repo.tracker_repo.as_deref() { Some(r) => r, None => { tracing::warn!("[{repo_id}] tracker_repo not set, skipping issue creation"); return Ok(()); } }; // Only create issues for medium+ severity findings let actionable: Vec<&Finding> = new_findings .iter() .filter(|f| { matches!( f.severity, Severity::Medium | Severity::High | Severity::Critical ) }) .collect(); if actionable.is_empty() { tracing::info!("[{repo_id}] No medium+ findings, skipping issue creation"); return Ok(()); } tracing::info!( "[{repo_id}] Creating issues for {} findings via {}", actionable.len(), tracker.name() ); let mut created = 0u32; for finding in actionable { let title = format!( "[{}] {}: {}", finding.severity, finding.scanner, finding.title ); // Check if an issue already exists by fingerprint first, then by title let mut found_existing = false; for search_term in [&finding.fingerprint, &title] { match tracker .find_existing_issue(owner, tracker_repo_name, search_term) .await { Ok(Some(existing)) => { tracing::debug!( "[{repo_id}] Issue already exists for '{}': {}", search_term, existing.external_url ); found_existing = true; break; } Ok(None) => {} Err(e) => { tracing::warn!("[{repo_id}] Failed to search for existing issue: {e}"); } } } if found_existing { continue; } let body = format_issue_body(finding); let labels = vec![ format!("severity:{}", finding.severity), format!("scanner:{}", finding.scanner), "compliance-scanner".to_string(), ]; match tracker .create_issue(owner, tracker_repo_name, &title, &body, &labels) .await { Ok(mut issue) => { issue.finding_id = finding .id .as_ref() .map(|id| id.to_hex()) .unwrap_or_default(); // Update the finding with the issue URL if let Some(finding_id) = &finding.id { let _ = self .db .findings() .update_one( doc! { "_id": finding_id }, doc! { "$set": { "tracker_issue_url": &issue.external_url } }, ) .await; } // Store the tracker issue record if let Err(e) = self.db.tracker_issues().insert_one(&issue).await { tracing::warn!("[{repo_id}] Failed to store tracker issue: {e}"); } created += 1; } Err(e) => { tracing::warn!( "[{repo_id}] Failed to create issue for {}: {e}", finding.fingerprint ); } } } tracing::info!("[{repo_id}] Created {created} tracker issues"); Ok(()) } } /// Format a finding into a markdown issue body for the tracker. pub(super) fn format_issue_body(finding: &Finding) -> String { let mut body = String::new(); body.push_str(&format!("## {} Finding\n\n", finding.severity)); body.push_str(&format!("**Scanner:** {}\n", finding.scanner)); body.push_str(&format!("**Severity:** {}\n", finding.severity)); if let Some(rule) = &finding.rule_id { body.push_str(&format!("**Rule:** {}\n", rule)); } if let Some(cwe) = &finding.cwe { body.push_str(&format!("**CWE:** {}\n", cwe)); } body.push_str(&format!("\n### Description\n\n{}\n", finding.description)); if let Some(file_path) = &finding.file_path { body.push_str(&format!("\n### Location\n\n**File:** `{}`", file_path)); if let Some(line) = finding.line_number { body.push_str(&format!(" (line {})", line)); } body.push('\n'); } if let Some(snippet) = &finding.code_snippet { body.push_str(&format!("\n### Code\n\n```\n{}\n```\n", snippet)); } if let Some(remediation) = &finding.remediation { body.push_str(&format!("\n### Remediation\n\n{}\n", remediation)); } if let Some(fix) = &finding.suggested_fix { body.push_str(&format!("\n### Suggested Fix\n\n```\n{}\n```\n", fix)); } body.push_str(&format!( "\n---\n*Fingerprint:* `{}`\n*Generated by compliance-scanner*", finding.fingerprint )); body }