1pub fn extract_yaml_block(text: &str) -> Option<String> {
24 if let Some(start) = text.find("```yaml") {
26 let content_start = start + "```yaml".len();
27 if let Some(end) = text[content_start..].find("```") {
28 return Some(text[content_start..content_start + end].trim().to_string());
29 }
30 }
31
32 if let Some(start) = text.find("```\n") {
34 let content_start = start + "```\n".len();
35 if let Some(end) = text[content_start..].find("```") {
36 let content = text[content_start..content_start + end].trim();
37 if content.starts_with("issues:") || content.starts_with("result:") {
38 return Some(content.to_string());
39 }
40 }
41 }
42
43 None
44}
45
46pub fn parse_review_issues(response: &str) -> Vec<String> {
61 let yaml_content = extract_yaml_block(response);
62
63 if let Some(yaml) = yaml_content
64 && let Ok(parsed) = serde_yaml::from_str::<serde_json::Value>(&yaml)
65 && let Some(issues) = parsed.get("issues").and_then(|v| v.as_array())
66 {
67 return issues
68 .iter()
69 .filter_map(|issue| {
70 let severity = issue.get("severity")?.as_str()?;
71 if severity == "critical" || severity == "major" {
72 let desc = issue
73 .get("description")
74 .and_then(|d| d.as_str())
75 .unwrap_or("Unknown issue");
76 let file = issue
77 .get("file")
78 .and_then(|f| f.as_str())
79 .unwrap_or("unknown");
80 let suggestion = issue
81 .get("suggestion")
82 .and_then(|s| s.as_str())
83 .unwrap_or("");
84 Some(format!(
85 "[{severity}] {file}: {desc}. Suggestion: {suggestion}"
86 ))
87 } else {
88 None
89 }
90 })
91 .collect();
92 }
93
94 Vec::new()
95}
96
97pub fn parse_verification_result(response: &str) -> (u32, Vec<String>) {
113 let yaml_content = extract_yaml_block(response);
114
115 if let Some(yaml) = yaml_content
116 && let Ok(parsed) = serde_yaml::from_str::<serde_json::Value>(&yaml)
117 {
118 let result = parsed
119 .get("result")
120 .and_then(|v| v.as_str())
121 .unwrap_or("unknown");
122
123 if result == "passed" {
124 let total = parsed
125 .get("total_count")
126 .and_then(|v| v.as_u64())
127 .unwrap_or(0) as u32;
128 return (total, Vec::new());
129 }
130
131 let mut failed = Vec::new();
132 if let Some(checks) = parsed.get("checks").and_then(|v| v.as_array()) {
133 let passed = checks
134 .iter()
135 .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("passed"))
136 .count() as u32;
137
138 for check in checks {
139 if check.get("status").and_then(|s| s.as_str()) == Some("failed") {
140 let name = check
141 .get("name")
142 .and_then(|n| n.as_str())
143 .unwrap_or("unknown");
144 let details = check
145 .get("details")
146 .and_then(|d| d.as_str())
147 .unwrap_or("no details");
148 failed.push(format!("{name}: {details}"));
149 }
150 }
151
152 return (passed, failed);
153 }
154 }
155
156 (
159 0,
160 vec![
161 "Unable to parse verification result from agent response. Manual review required."
162 .to_string(),
163 ],
164 )
165}
166
167pub fn extract_pr_url(text: &str) -> Option<String> {
183 for line in text.lines() {
184 if let Some(start) = line.find("https://github.com/") {
185 let url_part = &line[start..];
186 let end = url_part
188 .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')')
189 .unwrap_or(url_part.len());
190 let url = &url_part[..end];
191 if url.contains("/pull/") && extract_pr_number(url).is_some_and(|n| n > 0) {
195 return Some(url.to_string());
196 }
197 }
198 }
199 None
200}
201
202pub fn extract_pr_number(url: &str) -> Option<u32> {
215 url.rsplit('/').next()?.parse().ok()
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
225 fn test_should_find_yaml_block() {
226 let text = "Some text\n```yaml\nissues: []\n```\nMore text";
227 let yaml = extract_yaml_block(text);
228 assert_eq!(yaml, Some("issues: []".to_string()));
229 }
230
231 #[test]
232 fn test_should_return_none_for_no_yaml_block() {
233 let text = "This is plain text with no code blocks at all.";
234 let yaml = extract_yaml_block(text);
235 assert!(yaml.is_none());
236 }
237
238 #[test]
239 fn test_should_extract_first_yaml_block_from_multiple() {
240 let text = r#"
241First block:
242```yaml
243issues:
244 - severity: "critical"
245 description: "First issue"
246```
247
248Second block:
249```yaml
250result: "passed"
251```
252"#;
253
254 let yaml = extract_yaml_block(text);
255 assert!(yaml.is_some());
256 let content = yaml.unwrap();
257 assert!(content.contains("issues:"));
258 }
259
260 #[test]
261 fn test_should_extract_yaml_from_unmarked_code_block() {
262 let text = "Here are results:\n```\nissues:\n - severity: critical\n```\nEnd.";
263 let yaml = extract_yaml_block(text);
264 assert!(yaml.is_some());
265 assert!(yaml.unwrap().contains("issues:"));
266 }
267
268 #[test]
269 fn test_should_return_none_for_text_without_code_blocks() {
270 let text = "result: passed\ntotal_count: 3";
271 let yaml = extract_yaml_block(text);
272 assert!(yaml.is_none());
274 }
275
276 #[test]
279 fn test_should_parse_review_issues_from_yaml() {
280 let response = r#"
281Here are the review findings:
282
283```yaml
284issues:
285 - severity: "critical"
286 file: "src/main.rs"
287 line: 42
288 description: "Use of unwrap in production code"
289 suggestion: "Replace with ? operator"
290 - severity: "minor"
291 file: "src/lib.rs"
292 line: 10
293 description: "Missing doc comment"
294 suggestion: "Add /// documentation"
295```
296"#;
297
298 let issues = parse_review_issues(response);
299 assert_eq!(issues.len(), 1); assert!(issues[0].contains("unwrap"));
301 }
302
303 #[test]
304 fn test_should_return_empty_for_no_issues() {
305 let response = r#"
306```yaml
307issues: []
308```
309"#;
310
311 let issues = parse_review_issues(response);
312 assert!(issues.is_empty());
313 }
314
315 #[test]
316 fn test_should_parse_review_major_issues() {
317 let response = r#"
318```yaml
319issues:
320 - severity: "major"
321 file: "src/db.rs"
322 line: 100
323 description: "SQL injection vulnerability"
324 suggestion: "Use parameterized queries"
325 - severity: "major"
326 file: "src/api.rs"
327 line: 55
328 description: "Missing authentication check"
329 suggestion: "Add auth middleware"
330```
331"#;
332
333 let issues = parse_review_issues(response);
334 assert_eq!(issues.len(), 2);
335 assert!(issues[0].contains("SQL injection"));
336 assert!(issues[1].contains("authentication"));
337 }
338
339 #[test]
340 fn test_should_parse_review_with_no_yaml_structure() {
341 let text = "The code looks good! No issues found.";
342 let issues = parse_review_issues(text);
343 assert!(issues.is_empty());
344 }
345
346 #[test]
349 fn test_should_parse_verification_passed() {
350 let response = r#"
351```yaml
352result: "passed"
353total_count: 5
354checks:
355 - name: "cargo build"
356 status: "passed"
357```
358"#;
359
360 let (passed, failed) = parse_verification_result(response);
361 assert_eq!(passed, 5);
362 assert!(failed.is_empty());
363 }
364
365 #[test]
366 fn test_should_parse_verification_failed() {
367 let response = r#"
368```yaml
369result: "failed"
370checks:
371 - name: "cargo build"
372 status: "passed"
373 - name: "cargo test"
374 status: "failed"
375 details: "2 tests failed"
376failed_count: 1
377total_count: 2
378```
379"#;
380
381 let (passed, failed) = parse_verification_result(response);
382 assert_eq!(passed, 1);
383 assert_eq!(failed.len(), 1);
384 assert!(failed[0].contains("cargo test"));
385 }
386
387 #[test]
388 fn test_should_treat_unparsable_verification_as_failure() {
389 let text = "All tests passed successfully!";
390 let (passed, failed) = parse_verification_result(text);
391 assert_eq!(passed, 0);
392 assert_eq!(failed.len(), 1);
393 assert!(failed[0].contains("Unable to parse"));
394 }
395
396 #[test]
399 fn test_should_extract_pr_url() {
400 let text = "PR created: https://github.com/org/repo/pull/42\nDone!";
401 assert_eq!(
402 extract_pr_url(text),
403 Some("https://github.com/org/repo/pull/42".to_string())
404 );
405 }
406
407 #[test]
408 fn test_should_return_none_when_no_pr_url_found() {
409 let text = "No PR URL here, just some text.";
410 assert!(extract_pr_url(text).is_none());
411 }
412
413 #[test]
414 fn test_should_extract_pr_url_from_markdown_link() {
415 let text = "Created [PR #42](https://github.com/org/repo/pull/42) for review.";
416 let url = extract_pr_url(text);
417 assert_eq!(url, Some("https://github.com/org/repo/pull/42".to_string()));
418 }
419
420 #[test]
421 fn test_should_not_extract_non_pr_github_url() {
422 let text = "See https://github.com/org/repo/issues/10 for details.";
423 assert!(extract_pr_url(text).is_none());
424 }
425
426 #[test]
427 fn test_should_reject_git_push_create_pr_page_url() {
428 let text = "remote: Create a pull request for 'feature/update-doc' on GitHub by visiting:\nremote: https://github.com/lehuagavin/coda/pull/new/feature/update-doc";
431 assert!(extract_pr_url(text).is_none());
432 }
433
434 #[test]
437 fn test_should_extract_pr_number() {
438 assert_eq!(
439 extract_pr_number("https://github.com/org/repo/pull/42"),
440 Some(42)
441 );
442 }
443
444 #[test]
445 fn test_should_extract_pr_number_from_valid_url() {
446 assert_eq!(
447 extract_pr_number("https://github.com/org/repo/pull/123"),
448 Some(123)
449 );
450 assert_eq!(
451 extract_pr_number("https://github.com/my-org/my-repo/pull/1"),
452 Some(1)
453 );
454 }
455
456 #[test]
457 fn test_should_return_none_for_non_numeric_pr_number() {
458 assert_eq!(extract_pr_number("https://github.com/org/repo/pull/"), None);
459 assert_eq!(
460 extract_pr_number("https://github.com/org/repo/pull/abc"),
461 None
462 );
463 }
464}