From 570e3c5c9ea45b943bb08c6fa3856f6c6f49caab Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 11 Mar 2026 10:41:28 +0100 Subject: [PATCH] feat: implement Stage 6 issue creation in scan pipeline After scan findings are persisted, Stage 6 now creates issues in the configured tracker (GitHub/GitLab/Gitea/Jira) for new findings with severity >= Medium. Includes fingerprint-based dedup, per-repo token fallback to global config, and formatted markdown issue bodies. Co-Authored-By: Claude Opus 4.6 --- compliance-agent/src/pipeline/orchestrator.rs | 343 +++++++++++++++++- 1 file changed, 341 insertions(+), 2 deletions(-) diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index f43f6dc..3b975d6 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -4,6 +4,7 @@ use mongodb::bson::doc; use tracing::Instrument; use compliance_core::models::*; +use compliance_core::traits::issue_tracker::IssueTracker; use compliance_core::traits::Scanner; use compliance_core::AgentConfig; @@ -18,6 +19,56 @@ use crate::pipeline::lint::LintScanner; use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner}; use crate::pipeline::sbom::SbomScanner; use crate::pipeline::semgrep::SemgrepScanner; +use crate::trackers; + +/// Enum dispatch for issue trackers (async traits aren't dyn-compatible). +enum TrackerDispatch { + GitHub(trackers::github::GitHubTracker), + GitLab(trackers::gitlab::GitLabTracker), + Gitea(trackers::gitea::GiteaTracker), + Jira(trackers::jira::JiraTracker), +} + +impl TrackerDispatch { + fn name(&self) -> &str { + match self { + Self::GitHub(t) => t.name(), + Self::GitLab(t) => t.name(), + Self::Gitea(t) => t.name(), + Self::Jira(t) => t.name(), + } + } + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + match self { + Self::GitHub(t) => t.create_issue(owner, repo, title, body, labels).await, + Self::GitLab(t) => t.create_issue(owner, repo, title, body, labels).await, + Self::Gitea(t) => t.create_issue(owner, repo, title, body, labels).await, + Self::Jira(t) => t.create_issue(owner, repo, title, body, labels).await, + } + } + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, compliance_core::error::CoreError> { + match self { + Self::GitHub(t) => t.find_existing_issue(owner, repo, fingerprint).await, + Self::GitLab(t) => t.find_existing_issue(owner, repo, fingerprint).await, + Self::Gitea(t) => t.find_existing_issue(owner, repo, fingerprint).await, + Self::Jira(t) => t.find_existing_issue(owner, repo, fingerprint).await, + } + } +} /// Context from graph analysis passed to LLM triage for enhanced filtering #[derive(Debug)] @@ -293,6 +344,7 @@ impl PipelineOrchestrator { // Dedup against existing findings and insert new ones let mut new_count = 0u32; + let mut new_findings: Vec = Vec::new(); for mut finding in all_findings { finding.scan_run_id = Some(scan_run_id.to_string()); // Check if fingerprint already exists @@ -302,7 +354,9 @@ impl PipelineOrchestrator { .find_one(doc! { "fingerprint": &finding.fingerprint }) .await?; if existing.is_none() { - self.db.findings().insert_one(&finding).await?; + let result = self.db.findings().insert_one(&finding).await?; + finding.id = result.inserted_id.as_object_id(); + new_findings.push(finding); new_count += 1; } } @@ -351,7 +405,12 @@ impl PipelineOrchestrator { // Stage 6: Issue Creation tracing::info!("[{repo_id}] Stage 6: Issue Creation"); self.update_phase(scan_run_id, "issue_creation").await; - // Issue creation is handled by the trackers module - deferred to agent + if let Err(e) = self + .create_tracker_issues(repo, &repo_id, &new_findings) + .await + { + tracing::warn!("[{repo_id}] Issue creation failed: {e}"); + } // Stage 7: Update repository self.db @@ -477,6 +536,204 @@ impl PipelineOrchestrator { } } + /// Build an issue tracker client from a repository's tracker configuration. + /// Returns `None` if the repo has no tracker configured. + 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))] + 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 { + // Check if an issue already exists for this fingerprint + match tracker + .find_existing_issue(owner, tracker_repo_name, &finding.fingerprint) + .await + { + Ok(Some(existing)) => { + tracing::debug!( + "[{repo_id}] Issue already exists for {}: {}", + finding.fingerprint, + existing.external_url + ); + continue; + } + Ok(None) => {} + Err(e) => { + tracing::warn!("[{repo_id}] Failed to search for existing issue: {e}"); + // Continue and try to create anyway + } + } + + let title = format!( + "[{}] {}: {}", + finding.severity, finding.scanner, finding.title + ); + 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(()) + } + async fn update_phase(&self, scan_run_id: &str, phase: &str) { if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(scan_run_id) { let _ = self @@ -493,3 +750,85 @@ impl PipelineOrchestrator { } } } + +/// Extract the scheme + host from a git URL. +/// e.g. "https://gitea.example.com/owner/repo.git" → "https://gitea.example.com" +/// e.g. "ssh://git@gitea.example.com:22/owner/repo.git" → "https://gitea.example.com" +fn extract_base_url(git_url: &str) -> Option { + if git_url.starts_with("http://") || git_url.starts_with("https://") { + // https://host/path... → take scheme + host + let without_scheme = if git_url.starts_with("https://") { + &git_url[8..] + } else { + &git_url[7..] + }; + let host = without_scheme.split('/').next()?; + let scheme = if git_url.starts_with("https://") { + "https" + } else { + "http" + }; + Some(format!("{scheme}://{host}")) + } else if git_url.starts_with("ssh://") { + // ssh://git@host:port/path → extract host + let after_scheme = &git_url[6..]; + let after_at = after_scheme + .find('@') + .map(|i| &after_scheme[i + 1..]) + .unwrap_or(after_scheme); + let host = after_at.split(&[':', '/'][..]).next()?; + Some(format!("https://{host}")) + } else if let Some(at_pos) = git_url.find('@') { + // SCP-style: git@host:owner/repo.git + let after_at = &git_url[at_pos + 1..]; + let host = after_at.split(':').next()?; + Some(format!("https://{host}")) + } else { + None + } +} + +/// Format a finding into a markdown issue body for the tracker. +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 +}