1use super::FalsePositiveReport;
4use std::io::Write;
5use std::process::Command;
6use thiserror::Error;
7
8#[derive(Debug, Clone)]
10pub enum SubmitTarget {
11 GitHub {
13 repo: String,
15 },
16 Endpoint(String),
18 DryRun,
20}
21
22impl Default for SubmitTarget {
23 fn default() -> Self {
24 Self::GitHub {
25 repo: "anthropics/cc-audit".to_string(),
26 }
27 }
28}
29
30#[derive(Debug)]
32pub struct SubmitResult {
33 pub success: bool,
35 pub issue_url: Option<String>,
37 pub error: Option<String>,
39}
40
41#[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
57pub struct ReportSubmitter {
59 target: SubmitTarget,
60 labels: Vec<String>,
61}
62
63impl ReportSubmitter {
64 pub fn new() -> Self {
66 Self {
67 target: SubmitTarget::default(),
68 labels: vec!["false-positive".to_string(), "triage".to_string()],
69 }
70 }
71
72 pub fn with_target(mut self, target: SubmitTarget) -> Self {
74 self.target = target;
75 self
76 }
77
78 pub fn with_labels(mut self, labels: Vec<String>) -> Self {
80 self.labels = labels;
81 self
82 }
83
84 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 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 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 fn submit_to_github(
120 &self,
121 report: &FalsePositiveReport,
122 repo: &str,
123 ) -> Result<SubmitResult, SubmitError> {
124 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 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 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 fn submit_to_endpoint(
160 &self,
161 _report: &FalsePositiveReport,
162 url: &str,
163 ) -> Result<SubmitResult, SubmitError> {
164 Ok(SubmitResult {
166 success: false,
167 issue_url: None,
168 error: Some(format!("Endpoint submission not yet implemented: {}", url)),
169 })
170 }
171
172 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, "anthropics/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}