Skip to main content

rs_guard/
github.rs

1//! GitHub API interaction for submitting reviews and dismissing stale blockers.
2//!
3//! Provides [`submit_review`] with automatic permission-fallback to `COMMENT`,
4//! and [`dismiss_previous_reviews`] for cleaning up outdated bot reviews.
5
6use 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
12/// HTTP request timeout for GitHub API calls.
13const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
14
15/// HTML comment signature used to identify rs-guard bot reviews.
16const BOT_SIGNATURE: &str = "<!-- rs-guard-bot -->";
17
18/// GitHub's maximum character limit for review body.
19const GITHUB_REVIEW_BODY_LIMIT: usize = 65536;
20
21/// Submits a review to a GitHub Pull Request without permission fallback.
22async 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            // Explicit handling for 422 "body is too long" error
71            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
91/// Submits a review to a GitHub Pull Request with automatic permission fallback.
92///
93/// If the initial review state (e.g. `APPROVE` or `REQUEST_CHANGES`) fails due
94/// to insufficient permissions (HTTP 403), the function retries with `COMMENT`
95/// and prepends `[Bot fallback from {state}]` to the message.
96///
97/// The `base_url` is validated against an allowlist before any request is made.
98///
99/// # Arguments
100///
101/// * `base_url` — GitHub API base URL (e.g. `"https://api.github.com"`).
102/// * `owner` — Repository owner.
103/// * `repo` — Repository name.
104/// * `pr_number` — Pull request number.
105/// * `state` — Desired review state.
106/// * `message` — Review body text.
107/// * `token` — GitHub authentication token.
108pub 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    // Validate review body length before submission
120    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
158/// Dismisses previous rs-guard `CHANGES_REQUESTED` reviews on a Pull Request.
159///
160/// Queries all reviews on the PR, identifies those with state `CHANGES_REQUESTED`
161/// that contain the `BOT_SIGNATURE` marker, and dismisses each one with the
162/// message "Outdated — new review submitted".
163///
164/// Individual dismissal failures are logged as warnings but do not cause this
165/// function to return an error.
166///
167/// The `base_url` is validated against an allowlist before any request is made.
168///
169/// # Arguments
170///
171/// * `base_url` — GitHub API base URL (e.g. `"https://api.github.com"`).
172/// * `owner` — Repository owner.
173/// * `repo` — Repository name.
174/// * `pr_number` — Pull request number.
175/// * `token` — GitHub authentication token.
176pub 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    /// Regression test for the request body sent to `POST /repos/.../reviews`.
322    ///
323    /// Asserts that `ReviewState::RequestChanges` is serialised as the request
324    /// `event` value `"REQUEST_CHANGES"` (the GitHub REST API spec), not
325    /// `"CHANGES_REQUESTED"` (which GitHub returns on the response side and
326    /// would cause a 422 with `Variable $event of type PullRequestReviewEvent
327    /// was provided invalid value`).
328    #[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 that only matches when the request body contains
335        // `"event": "REQUEST_CHANGES"`. If the bug regresses, this mock will
336        // not match and the test will fail with a 404 from wiremock.
337        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    /// Companion test for `Approve` — ensures the correct `event` value is sent
364    /// and that no regression inverts the value to something like
365    /// `"APPROVED"` (the response-side form).
366    #[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        // First request fails with 503
403        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        // Second request succeeds
411        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        // First call: APPROVE fails with 403
436        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        // Second call: should be COMMENT fallback — verify via the mock server
444        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        // First call: APPROVE fails with 422 "not permitted" (GitHub Actions restriction)
469        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        // Second call: should be COMMENT fallback
480        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        // Create a message that exceeds the limit
565        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        // Create a message that is exactly at the limit (minus signature)
596        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) // retries up to 3 times + initial
754            .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}