Skip to main content

cc_audit/feedback/
submitter.rs

1//! False positive report submission.
2
3use super::FalsePositiveReport;
4use std::io::Write;
5use std::process::Command;
6use thiserror::Error;
7
8/// Target for submitting false positive reports.
9#[derive(Debug, Clone)]
10pub enum SubmitTarget {
11    /// Submit to GitHub Issues using gh CLI
12    GitHub {
13        /// Repository in "owner/repo" format
14        repo: String,
15    },
16    /// Submit to a custom endpoint (future use)
17    Endpoint(String),
18    /// Dry run - print to stdout without submitting
19    DryRun,
20}
21
22impl Default for SubmitTarget {
23    fn default() -> Self {
24        Self::GitHub {
25            repo: "ryo-ebata/cc-audit".to_string(),
26        }
27    }
28}
29
30/// Result of a report submission.
31#[derive(Debug)]
32pub struct SubmitResult {
33    /// Whether the submission was successful
34    pub success: bool,
35    /// The issue URL if created
36    pub issue_url: Option<String>,
37    /// Error message if failed
38    pub error: Option<String>,
39}
40
41/// Error type for submission failures.
42#[derive(Debug, Error)]
43pub enum SubmitError {
44    #[error("gh CLI not found. Please install: https://cli.github.com/")]
45    GhNotInstalled,
46
47    #[error("gh CLI authentication required. Run: gh auth login")]
48    GhAuthRequired,
49
50    #[error("Failed to create issue: {0}")]
51    IssueCreationFailed(String),
52
53    #[error("IO error: {0}")]
54    Io(#[from] std::io::Error),
55}
56
57/// Submitter for false positive reports.
58pub struct ReportSubmitter {
59    target: SubmitTarget,
60    labels: Vec<String>,
61}
62
63impl ReportSubmitter {
64    /// Create a new submitter with default GitHub target.
65    pub fn new() -> Self {
66        Self {
67            target: SubmitTarget::default(),
68            labels: vec!["false-positive".to_string(), "triage".to_string()],
69        }
70    }
71
72    /// Set the submission target.
73    pub fn with_target(mut self, target: SubmitTarget) -> Self {
74        self.target = target;
75        self
76    }
77
78    /// Set additional labels.
79    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
80        self.labels = labels;
81        self
82    }
83
84    /// Check if gh CLI is available.
85    pub fn check_gh_cli() -> Result<bool, SubmitError> {
86        let output = Command::new("gh").arg("--version").output();
87
88        match output {
89            Ok(o) if o.status.success() => Ok(true),
90            Ok(_) => Err(SubmitError::GhNotInstalled),
91            Err(_) => Err(SubmitError::GhNotInstalled),
92        }
93    }
94
95    /// Check if gh CLI is authenticated.
96    pub fn check_gh_auth() -> Result<bool, SubmitError> {
97        let output = Command::new("gh")
98            .args(["auth", "status"])
99            .output()
100            .map_err(|_| SubmitError::GhNotInstalled)?;
101
102        if output.status.success() {
103            Ok(true)
104        } else {
105            Err(SubmitError::GhAuthRequired)
106        }
107    }
108
109    /// Submit a false positive report.
110    pub fn submit(&self, report: &FalsePositiveReport) -> Result<SubmitResult, SubmitError> {
111        match &self.target {
112            SubmitTarget::GitHub { repo } => self.submit_to_github(report, repo),
113            SubmitTarget::Endpoint(url) => self.submit_to_endpoint(report, url),
114            SubmitTarget::DryRun => self.dry_run(report),
115        }
116    }
117
118    /// Submit to GitHub Issues.
119    fn submit_to_github(
120        &self,
121        report: &FalsePositiveReport,
122        repo: &str,
123    ) -> Result<SubmitResult, SubmitError> {
124        // Check gh CLI availability
125        Self::check_gh_cli()?;
126        Self::check_gh_auth()?;
127
128        let title = report.to_github_issue_title();
129        let body = report.to_github_issue_body();
130
131        // Build gh command
132        let mut cmd = Command::new("gh");
133        cmd.args(["issue", "create"])
134            .args(["--repo", repo])
135            .args(["--title", &title])
136            .args(["--body", &body]);
137
138        // Add labels
139        for label in &self.labels {
140            cmd.args(["--label", label]);
141        }
142
143        let output = cmd.output()?;
144
145        if output.status.success() {
146            let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
147            Ok(SubmitResult {
148                success: true,
149                issue_url: Some(url),
150                error: None,
151            })
152        } else {
153            let error = String::from_utf8_lossy(&output.stderr).to_string();
154            Err(SubmitError::IssueCreationFailed(error))
155        }
156    }
157
158    /// Submit to a custom endpoint (placeholder for future use).
159    fn submit_to_endpoint(
160        &self,
161        _report: &FalsePositiveReport,
162        url: &str,
163    ) -> Result<SubmitResult, SubmitError> {
164        // Future: implement HTTP POST to custom endpoint
165        Ok(SubmitResult {
166            success: false,
167            issue_url: None,
168            error: Some(format!("Endpoint submission not yet implemented: {}", url)),
169        })
170    }
171
172    /// Dry run - print report without submitting.
173    fn dry_run(&self, report: &FalsePositiveReport) -> Result<SubmitResult, SubmitError> {
174        let title = report.to_github_issue_title();
175        let body = report.to_github_issue_body();
176
177        let mut stdout = std::io::stdout();
178        writeln!(stdout, "=== DRY RUN: GitHub Issue ====")?;
179        writeln!(stdout, "Title: {}", title)?;
180        writeln!(stdout, "Labels: {}", self.labels.join(", "))?;
181        writeln!(stdout, "---")?;
182        writeln!(stdout, "{}", body)?;
183        writeln!(stdout, "=============================")?;
184
185        Ok(SubmitResult {
186            success: true,
187            issue_url: None,
188            error: None,
189        })
190    }
191}
192
193impl Default for ReportSubmitter {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_default_target() {
205        let submitter = ReportSubmitter::new();
206        match submitter.target {
207            SubmitTarget::GitHub { ref repo } => {
208                assert_eq!(repo, "ryo-ebata/cc-audit");
209            }
210            _ => panic!("Expected GitHub target"),
211        }
212    }
213
214    #[test]
215    fn test_dry_run() {
216        let submitter = ReportSubmitter::new().with_target(SubmitTarget::DryRun);
217
218        let report = FalsePositiveReport::new("SL-001")
219            .with_extension("js")
220            .with_description("Test description");
221
222        let result = submitter.submit(&report).unwrap();
223        assert!(result.success);
224        assert!(result.issue_url.is_none());
225    }
226
227    #[test]
228    fn test_custom_labels() {
229        let submitter = ReportSubmitter::new().with_labels(vec![
230            "bug".to_string(),
231            "false-positive".to_string(),
232            "needs-review".to_string(),
233        ]);
234
235        assert_eq!(submitter.labels.len(), 3);
236        assert!(submitter.labels.contains(&"bug".to_string()));
237    }
238
239    #[test]
240    fn test_submit_to_endpoint() {
241        let submitter = ReportSubmitter::new().with_target(SubmitTarget::Endpoint(
242            "https://example.com/api".to_string(),
243        ));
244
245        let report = FalsePositiveReport::new("SL-001")
246            .with_extension("js")
247            .with_description("Test description");
248
249        let result = submitter.submit(&report).unwrap();
250        assert!(!result.success);
251        assert!(result.error.is_some());
252        assert!(result.error.unwrap().contains("not yet implemented"));
253    }
254
255    #[test]
256    fn test_submit_result_fields() {
257        let result = SubmitResult {
258            success: true,
259            issue_url: Some("https://github.com/test/test/issues/1".to_string()),
260            error: None,
261        };
262
263        assert!(result.success);
264        assert!(result.issue_url.is_some());
265        assert!(result.error.is_none());
266    }
267
268    #[test]
269    fn test_submit_error_display() {
270        let err1 = SubmitError::GhNotInstalled;
271        assert!(err1.to_string().contains("gh CLI not found"));
272
273        let err2 = SubmitError::GhAuthRequired;
274        assert!(err2.to_string().contains("gh CLI authentication required"));
275
276        let err3 = SubmitError::IssueCreationFailed("test error".to_string());
277        assert!(err3.to_string().contains("test error"));
278    }
279
280    #[test]
281    fn test_submit_target_github() {
282        let target = SubmitTarget::GitHub {
283            repo: "custom/repo".to_string(),
284        };
285
286        match target {
287            SubmitTarget::GitHub { repo } => assert_eq!(repo, "custom/repo"),
288            _ => panic!("Expected GitHub target"),
289        }
290    }
291
292    #[test]
293    fn test_submit_target_endpoint() {
294        let target = SubmitTarget::Endpoint("https://example.com".to_string());
295
296        match target {
297            SubmitTarget::Endpoint(url) => assert_eq!(url, "https://example.com"),
298            _ => panic!("Expected Endpoint target"),
299        }
300    }
301
302    #[test]
303    fn test_default_submitter() {
304        let submitter = ReportSubmitter::default();
305        assert_eq!(submitter.labels.len(), 2);
306        assert!(submitter.labels.contains(&"false-positive".to_string()));
307        assert!(submitter.labels.contains(&"triage".to_string()));
308    }
309
310    #[test]
311    fn test_with_target_chaining() {
312        let submitter = ReportSubmitter::new()
313            .with_target(SubmitTarget::DryRun)
314            .with_labels(vec!["custom".to_string()]);
315
316        match submitter.target {
317            SubmitTarget::DryRun => {}
318            _ => panic!("Expected DryRun target"),
319        }
320        assert_eq!(submitter.labels.len(), 1);
321    }
322}