1use crate::error::RsGuardError;
7use crate::http::{build_github_http_client, github_headers, validate_github_base_url};
8use crate::retry::with_retry_simple;
9use crate::verdict::ReviewState;
10use serde_json::json;
11
12const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
14
15const BOT_SIGNATURE: &str = "<!-- rs-guard-bot -->";
17
18const GITHUB_REVIEW_BODY_LIMIT: usize = 65536;
20
21async fn submit_review_inner(
23 base_url: &str,
24 owner: &str,
25 repo: &str,
26 pr_number: u64,
27 state: &ReviewState,
28 message: &str,
29 token: &str,
30) -> Result<(), RsGuardError> {
31 let client = build_github_http_client(REQUEST_TIMEOUT)?;
32
33 let url = format!(
34 "{}/repos/{}/{}/pulls/{}/reviews",
35 base_url.trim_end_matches('/'),
36 owner,
37 repo,
38 pr_number
39 );
40
41 let headers = github_headers(token)?;
42
43 let body = json!({
44 "body": format!("{}\n\n{}", message, BOT_SIGNATURE),
45 "event": state.as_github_state(),
46 });
47
48 with_retry_simple(|| async {
49 let resp = client
50 .post(&url)
51 .headers(headers.clone())
52 .json(&body)
53 .send()
54 .await
55 .map_err(|e| {
56 let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
57 RsGuardError::GitHubApi {
58 status,
59 message: e.to_string(),
60 }
61 })?;
62
63 let status = resp.status();
64 if !status.is_success() {
65 let body_text = resp
66 .text()
67 .await
68 .unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
69
70 if status.as_u16() == 422 && body_text.contains("body is too long") {
72 return Err(RsGuardError::GitHubApi {
73 status: status.as_u16(),
74 message: "Review body exceeds GitHub's character limit. \
75 Consider using a shorter prompt or chunking the diff."
76 .to_string(),
77 });
78 }
79
80 return Err(RsGuardError::GitHubApi {
81 status: status.as_u16(),
82 message: body_text,
83 });
84 }
85
86 Ok(())
87 })
88 .await
89}
90
91pub async fn submit_review(
109 base_url: &str,
110 owner: &str,
111 repo: &str,
112 pr_number: u64,
113 state: ReviewState,
114 message: &str,
115 token: &str,
116) -> Result<(), RsGuardError> {
117 validate_github_base_url(base_url)?;
118
119 let full_body = format!("{}\n\n{}", message, BOT_SIGNATURE);
121 if full_body.len() > GITHUB_REVIEW_BODY_LIMIT {
122 return Err(RsGuardError::GitHubApi {
123 status: 0,
124 message: format!(
125 "Review body exceeds GitHub's character limit ({} chars). \
126 Consider using a shorter prompt or chunking the diff.",
127 GITHUB_REVIEW_BODY_LIMIT
128 ),
129 });
130 }
131
132 let result =
133 submit_review_inner(base_url, owner, repo, pr_number, &state, message, token).await;
134
135 match result {
136 Ok(()) => Ok(()),
137 Err(err) if err.is_permission_denied() && state != ReviewState::Comment => {
138 log::warn!(
139 "Permission denied for {}. Falling back to COMMENT...",
140 state
141 );
142 let fallback_msg = format!("[Bot fallback from {}]\n\n{}", state, message);
143 submit_review_inner(
144 base_url,
145 owner,
146 repo,
147 pr_number,
148 &ReviewState::Comment,
149 &fallback_msg,
150 token,
151 )
152 .await
153 }
154 Err(err) => Err(err),
155 }
156}
157
158pub async fn dismiss_previous_reviews(
177 base_url: &str,
178 owner: &str,
179 repo: &str,
180 pr_number: u64,
181 token: &str,
182) -> Result<(), RsGuardError> {
183 validate_github_base_url(base_url)?;
184
185 let client = build_github_http_client(REQUEST_TIMEOUT)?;
186
187 let url = format!(
188 "{}/repos/{}/{}/pulls/{}/reviews",
189 base_url.trim_end_matches('/'),
190 owner,
191 repo,
192 pr_number
193 );
194
195 let headers = github_headers(token)?;
196
197 let reviews: Vec<serde_json::Value> = with_retry_simple(|| async {
198 let resp = client
199 .get(&url)
200 .headers(headers.clone())
201 .send()
202 .await
203 .map_err(|e| {
204 let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
205 RsGuardError::GitHubApi {
206 status,
207 message: e.to_string(),
208 }
209 })?;
210
211 let status = resp.status();
212 if !status.is_success() {
213 let body = resp
214 .text()
215 .await
216 .unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
217 return Err(RsGuardError::GitHubApi {
218 status: status.as_u16(),
219 message: body,
220 });
221 }
222
223 resp.json().await.map_err(|e| RsGuardError::GitHubApi {
224 status: 0,
225 message: e.to_string(),
226 })
227 })
228 .await?;
229
230 for review in reviews {
231 let state = review["state"].as_str().unwrap_or("");
232 let body = review["body"].as_str().unwrap_or("");
233 let review_id = review["id"].as_u64();
234
235 if state == "CHANGES_REQUESTED" && body.contains(BOT_SIGNATURE) {
236 if let Some(id) = review_id {
237 let dismiss_url = format!(
238 "{}/repos/{}/{}/pulls/{}/reviews/{}/dismissals",
239 base_url.trim_end_matches('/'),
240 owner,
241 repo,
242 pr_number,
243 id
244 );
245
246 let dismiss_body = json!({
247 "message": "Outdated — new review submitted",
248 });
249
250 if let Err(e) = with_retry_simple(|| async {
251 let resp = client
252 .put(&dismiss_url)
253 .headers(headers.clone())
254 .json(&dismiss_body)
255 .send()
256 .await
257 .map_err(|e| {
258 let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
259 RsGuardError::GitHubApi {
260 status,
261 message: e.to_string(),
262 }
263 })?;
264
265 let status = resp.status();
266 if !status.is_success() {
267 let body = resp
268 .text()
269 .await
270 .unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
271 return Err(RsGuardError::GitHubApi {
272 status: status.as_u16(),
273 message: body,
274 });
275 }
276
277 Ok(())
278 })
279 .await
280 {
281 log::warn!("Failed to dismiss review {}: {}", id, e);
282 }
283 }
284 }
285 }
286
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use serde_json::json;
294 use wiremock::matchers::{method, path_regex};
295 use wiremock::{Mock, MockServer, ResponseTemplate};
296
297 #[tokio::test]
298 async fn test_submit_review_success() {
299 let mock_server = MockServer::start().await;
300
301 Mock::given(method("POST"))
302 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
303 .respond_with(ResponseTemplate::new(200))
304 .mount(&mock_server)
305 .await;
306
307 let result = submit_review(
308 &mock_server.uri(),
309 "owner",
310 "repo",
311 1,
312 ReviewState::Approve,
313 "looks good",
314 "token",
315 )
316 .await;
317
318 assert!(result.is_ok());
319 }
320
321 #[tokio::test]
329 async fn test_submit_review_request_changes_sends_correct_event() {
330 use wiremock::matchers::body_partial_json;
331
332 let mock_server = MockServer::start().await;
333
334 Mock::given(method("POST"))
338 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
339 .and(body_partial_json(json!({"event": "REQUEST_CHANGES"})))
340 .respond_with(ResponseTemplate::new(200))
341 .expect(1)
342 .mount(&mock_server)
343 .await;
344
345 let result = submit_review(
346 &mock_server.uri(),
347 "owner",
348 "repo",
349 1,
350 ReviewState::RequestChanges,
351 "please fix",
352 "token",
353 )
354 .await;
355
356 assert!(
357 result.is_ok(),
358 "submit_review(RequestChanges) failed: {:?}",
359 result
360 );
361 }
362
363 #[tokio::test]
367 async fn test_submit_review_approve_sends_correct_event() {
368 use wiremock::matchers::body_partial_json;
369
370 let mock_server = MockServer::start().await;
371
372 Mock::given(method("POST"))
373 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
374 .and(body_partial_json(json!({"event": "APPROVE"})))
375 .respond_with(ResponseTemplate::new(200))
376 .expect(1)
377 .mount(&mock_server)
378 .await;
379
380 let result = submit_review(
381 &mock_server.uri(),
382 "owner",
383 "repo",
384 1,
385 ReviewState::Approve,
386 "lgtm",
387 "token",
388 )
389 .await;
390
391 assert!(
392 result.is_ok(),
393 "submit_review(Approve) failed: {:?}",
394 result
395 );
396 }
397
398 #[tokio::test]
399 async fn test_submit_review_retryable_then_success() {
400 let mock_server = MockServer::start().await;
401
402 Mock::given(method("POST"))
404 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
405 .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
406 .up_to_n_times(1)
407 .mount(&mock_server)
408 .await;
409
410 Mock::given(method("POST"))
412 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
413 .respond_with(ResponseTemplate::new(200))
414 .mount(&mock_server)
415 .await;
416
417 let result = submit_review(
418 &mock_server.uri(),
419 "owner",
420 "repo",
421 1,
422 ReviewState::Comment,
423 "ok",
424 "token",
425 )
426 .await;
427
428 assert!(result.is_ok());
429 }
430
431 #[tokio::test]
432 async fn test_submit_review_permission_fallback_to_comment() {
433 let mock_server = MockServer::start().await;
434
435 Mock::given(method("POST"))
437 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
438 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
439 .up_to_n_times(1)
440 .mount(&mock_server)
441 .await;
442
443 Mock::given(method("POST"))
445 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
446 .respond_with(ResponseTemplate::new(200))
447 .mount(&mock_server)
448 .await;
449
450 let result = submit_review(
451 &mock_server.uri(),
452 "owner",
453 "repo",
454 1,
455 ReviewState::Approve,
456 "my review",
457 "token",
458 )
459 .await;
460
461 assert!(result.is_ok());
462 }
463
464 #[tokio::test]
465 async fn test_submit_review_422_not_permitted_fallback_to_comment() {
466 let mock_server = MockServer::start().await;
467
468 Mock::given(method("POST"))
470 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
471 .respond_with(
472 ResponseTemplate::new(422)
473 .set_body_string(r#"{"message":"Unprocessable Entity","errors":["GitHub Actions is not permitted to approve pull requests."]}"#),
474 )
475 .up_to_n_times(1)
476 .mount(&mock_server)
477 .await;
478
479 Mock::given(method("POST"))
481 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
482 .respond_with(ResponseTemplate::new(200))
483 .mount(&mock_server)
484 .await;
485
486 let result = submit_review(
487 &mock_server.uri(),
488 "owner",
489 "repo",
490 1,
491 ReviewState::Approve,
492 "my review",
493 "token",
494 )
495 .await;
496
497 assert!(result.is_ok());
498 }
499
500 #[tokio::test]
501 async fn test_submit_review_no_fallback_on_permission_denied_for_comment() {
502 let mock_server = MockServer::start().await;
503
504 Mock::given(method("POST"))
505 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
506 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
507 .mount(&mock_server)
508 .await;
509
510 let result = submit_review(
511 &mock_server.uri(),
512 "owner",
513 "repo",
514 1,
515 ReviewState::Comment,
516 "my comment",
517 "token",
518 )
519 .await;
520
521 assert!(result.is_err());
522 assert!(result.unwrap_err().is_permission_denied());
523 }
524
525 #[tokio::test]
526 async fn test_submit_review_invalid_base_url() {
527 let result = submit_review(
528 "https://evil.example.com",
529 "owner",
530 "repo",
531 1,
532 ReviewState::Comment,
533 "msg",
534 "token",
535 )
536 .await;
537
538 assert!(result.is_err());
539 let err = result.unwrap_err();
540 assert!(err.to_string().contains("allowlist"));
541 }
542
543 #[tokio::test]
544 async fn test_submit_review_invalid_token() {
545 let result = submit_review(
546 "http://127.0.0.1:1",
547 "owner",
548 "repo",
549 1,
550 ReviewState::Comment,
551 "msg",
552 "token\x00withnull",
553 )
554 .await;
555
556 assert!(result.is_err());
557 assert!(result.unwrap_err().to_string().contains("token"));
558 }
559
560 #[tokio::test]
561 async fn test_submit_review_body_too_long() {
562 let mock_server = MockServer::start().await;
563
564 let long_message = "x".repeat(GITHUB_REVIEW_BODY_LIMIT + 100);
566
567 let result = submit_review(
568 &mock_server.uri(),
569 "owner",
570 "repo",
571 1,
572 ReviewState::Comment,
573 &long_message,
574 "token",
575 )
576 .await;
577
578 assert!(result.is_err());
579 assert!(result
580 .unwrap_err()
581 .to_string()
582 .contains("exceeds GitHub's character limit"));
583 }
584
585 #[tokio::test]
586 async fn test_submit_review_body_at_limit() {
587 let mock_server = MockServer::start().await;
588
589 Mock::given(method("POST"))
590 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
591 .respond_with(ResponseTemplate::new(200))
592 .mount(&mock_server)
593 .await;
594
595 let message = "x".repeat(GITHUB_REVIEW_BODY_LIMIT - BOT_SIGNATURE.len() - 2);
597
598 let result = submit_review(
599 &mock_server.uri(),
600 "owner",
601 "repo",
602 1,
603 ReviewState::Comment,
604 &message,
605 "token",
606 )
607 .await;
608
609 assert!(result.is_ok());
610 }
611
612 #[tokio::test]
613 async fn test_submit_review_422_body_too_long_error() {
614 let mock_server = MockServer::start().await;
615
616 Mock::given(method("POST"))
617 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
618 .respond_with(ResponseTemplate::new(422).set_body_string(
619 r#"{"message":"Unprocessable Entity","errors":["body is too long"]}"#,
620 ))
621 .mount(&mock_server)
622 .await;
623
624 let result = submit_review(
625 &mock_server.uri(),
626 "owner",
627 "repo",
628 1,
629 ReviewState::Comment,
630 "test message",
631 "token",
632 )
633 .await;
634
635 assert!(result.is_err());
636 assert!(result
637 .unwrap_err()
638 .to_string()
639 .contains("exceeds GitHub's character limit"));
640 }
641
642 #[tokio::test]
643 async fn test_dismiss_previous_reviews_no_reviews() {
644 let mock_server = MockServer::start().await;
645
646 Mock::given(method("GET"))
647 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
648 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
649 .mount(&mock_server)
650 .await;
651
652 let result =
653 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
654
655 assert!(result.is_ok());
656 }
657
658 #[tokio::test]
659 async fn test_dismiss_previous_reviews_dismisses_bot_request_changes() {
660 let mock_server = MockServer::start().await;
661
662 let bot_review = json!({
663 "id": 42,
664 "state": "CHANGES_REQUESTED",
665 "body": "Some review\n\n<!-- rs-guard-bot -->"
666 });
667
668 Mock::given(method("GET"))
669 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
670 .respond_with(ResponseTemplate::new(200).set_body_json(json!([bot_review])))
671 .mount(&mock_server)
672 .await;
673
674 Mock::given(method("PUT"))
675 .and(path_regex(
676 r"/repos/owner/repo/pulls/\d+/reviews/\d+/dismissals",
677 ))
678 .respond_with(ResponseTemplate::new(200))
679 .mount(&mock_server)
680 .await;
681
682 let result =
683 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
684
685 assert!(result.is_ok());
686 }
687
688 #[tokio::test]
689 async fn test_dismiss_previous_reviews_skips_non_bot_reviews() {
690 let mock_server = MockServer::start().await;
691
692 let human_review = json!({
693 "id": 99,
694 "state": "CHANGES_REQUESTED",
695 "body": "Please fix this issue"
696 });
697
698 Mock::given(method("GET"))
699 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
700 .respond_with(ResponseTemplate::new(200).set_body_json(json!([human_review])))
701 .mount(&mock_server)
702 .await;
703
704 let result =
705 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
706
707 assert!(result.is_ok());
708 }
709
710 #[tokio::test]
711 async fn test_dismiss_previous_reviews_skips_approved_reviews() {
712 let mock_server = MockServer::start().await;
713
714 let approved_review = json!({
715 "id": 55,
716 "state": "APPROVED",
717 "body": "<!-- rs-guard-bot -->\nLGTM"
718 });
719
720 Mock::given(method("GET"))
721 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
722 .respond_with(ResponseTemplate::new(200).set_body_json(json!([approved_review])))
723 .mount(&mock_server)
724 .await;
725
726 let result =
727 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
728
729 assert!(result.is_ok());
730 }
731
732 #[tokio::test]
733 async fn test_dismiss_previous_reviews_handles_dismissal_error() {
734 let mock_server = MockServer::start().await;
735
736 let bot_review = json!({
737 "id": 42,
738 "state": "CHANGES_REQUESTED",
739 "body": "<!-- rs-guard-bot -->\nReview"
740 });
741
742 Mock::given(method("GET"))
743 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
744 .respond_with(ResponseTemplate::new(200).set_body_json(json!([bot_review])))
745 .mount(&mock_server)
746 .await;
747
748 Mock::given(method("PUT"))
749 .and(path_regex(
750 r"/repos/owner/repo/pulls/\d+/reviews/\d+/dismissals",
751 ))
752 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server"))
753 .up_to_n_times(4) .mount(&mock_server)
755 .await;
756
757 let result =
758 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
759
760 assert!(result.is_ok());
761 }
762
763 #[tokio::test]
764 async fn test_dismiss_previous_reviews_invalid_base_url() {
765 let result =
766 dismiss_previous_reviews("https://evil.example.com", "owner", "repo", 1, "token").await;
767
768 assert!(result.is_err());
769 assert!(result.unwrap_err().to_string().contains("allowlist"));
770 }
771
772 #[tokio::test]
773 async fn test_dismiss_previous_reviews_handles_get_error() {
774 let mock_server = MockServer::start().await;
775
776 Mock::given(method("GET"))
777 .and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
778 .respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
779 .mount(&mock_server)
780 .await;
781
782 let result =
783 dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
784
785 assert!(result.is_err());
786 assert!(result.unwrap_err().to_string().contains("500"));
787 }
788}