Skip to main content

orch/github/
app.rs

1//! GitHub App authentication and the review gate that reads App reviews.
2//!
3//! The owner token resolver stays in `token.rs`. This module signs a short-lived
4//! JWT, exchanges it for an installation token, and decides which reviews count.
5
6use super::types::GitHubReview;
7use chrono::{DateTime, Duration, Utc};
8use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
9use reqwest::{header, Client, StatusCode};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex, OnceLock};
13use std::time::Duration as StdDuration;
14
15const JWT_IAT_SKEW_SECS: i64 = 60;
16const JWT_EXP_FROM_NOW_SECS: i64 = 9 * 60;
17const USER_AGENT: &str = "orch/0.1 (reqwest)";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub(crate) enum ReviewEvent {
21    Approve,
22    RequestChanges,
23}
24
25impl ReviewEvent {
26    fn as_str(self) -> &'static str {
27        match self {
28            Self::Approve => "APPROVE",
29            Self::RequestChanges => "REQUEST_CHANGES",
30        }
31    }
32}
33
34struct CachedToken {
35    token: String,
36    expires_at: DateTime<Utc>,
37}
38
39#[derive(Serialize, Deserialize)]
40struct Claims {
41    iss: String,
42    iat: i64,
43    exp: i64,
44}
45
46#[derive(Deserialize)]
47struct Installation {
48    id: u64,
49}
50
51#[derive(Deserialize)]
52struct AccessTokenResponse {
53    token: String,
54    expires_at: DateTime<Utc>,
55}
56
57#[derive(Deserialize)]
58struct AppInfo {
59    slug: String,
60}
61
62pub(crate) struct AppAuth {
63    app_id: String,
64    pem: String,
65    // `None` means the App is not installed on that repository, so it uses the owner token
66    installations: Mutex<HashMap<String, Option<u64>>>,
67    tokens: Mutex<HashMap<u64, CachedToken>>,
68    login: Mutex<Option<String>>,
69    refresh: tokio::sync::Mutex<()>,
70    http: Client,
71    api_base: String,
72}
73
74// Keeps the key and the token out of any `{:?}` that reaches a log
75impl std::fmt::Debug for AppAuth {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("AppAuth")
78            .field("app_id", &self.app_id)
79            .finish_non_exhaustive()
80    }
81}
82
83fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
84    mutex.lock().unwrap_or_else(|err| err.into_inner())
85}
86
87/// Process-wide App client, `None` when `gh.app.id` is unset or the App config is unusable
88pub(crate) fn shared() -> Option<Arc<AppAuth>> {
89    static SHARED: OnceLock<Option<Arc<AppAuth>>> = OnceLock::new();
90    SHARED.get_or_init(load_shared).clone()
91}
92
93fn load_shared() -> Option<Arc<AppAuth>> {
94    // `cfg(test)` is unset when tests/*.rs link the library, so check debug builds too, at run
95    // time, which keeps the release path compiled and type-checked in every build
96    if cfg!(any(test, debug_assertions)) {
97        return None;
98    }
99    let id = nonempty_config("gh.app.id")?;
100    match AppAuth::from_config(id) {
101        Ok(app) => Some(Arc::new(app)),
102        Err(err) => {
103            tracing::error!(error = %err, "GitHub App config unusable, orch uses the owner token");
104            None
105        }
106    }
107}
108
109impl AppAuth {
110    fn from_config(app_id: String) -> anyhow::Result<Self> {
111        if !app_id.chars().all(|c| c.is_ascii_digit()) {
112            anyhow::bail!("gh.app.id must be digits");
113        }
114        let pem = read_key()?;
115        // Fail at startup on a bad key, not on the first request
116        sign_jwt(&app_id, &pem, Utc::now().timestamp())?;
117        Ok(Self::new(app_id, pem, "https://api.github.com".to_string()))
118    }
119
120    pub(crate) fn new(app_id: String, pem: String, api_base: String) -> Self {
121        let http = Client::builder()
122            .user_agent(USER_AGENT)
123            .timeout(StdDuration::from_secs(30))
124            .build()
125            .unwrap_or_default();
126        Self {
127            app_id,
128            pem,
129            installations: Mutex::new(HashMap::new()),
130            tokens: Mutex::new(HashMap::new()),
131            login: Mutex::new(None),
132            refresh: tokio::sync::Mutex::new(()),
133            http,
134            api_base,
135        }
136    }
137
138    /// Drop the cached installation tokens so the next request exchanges new ones
139    pub(crate) fn invalidate(&self) {
140        lock(&self.tokens).clear();
141    }
142
143    /// The installation token for `repo`, `None` when the App is not installed on it
144    pub(crate) async fn token_for_repo(&self, repo: &str) -> anyhow::Result<Option<String>> {
145        let Some(id) = self.installation_for(repo).await? else {
146            return Ok(None);
147        };
148        self.installation_token(id).await.map(Some)
149    }
150
151    // An account and each organization are separate installations, so the lookup is per repo
152    async fn installation_for(&self, repo: &str) -> anyhow::Result<Option<u64>> {
153        if let Some(found) = lock(&self.installations).get(repo) {
154            return Ok(*found);
155        }
156        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
157        let url = format!("{}/repos/{repo}/installation", self.api_base);
158        let found = match self.send_status(self.http.get(&url), &jwt).await? {
159            (status, body) if status.is_success() => {
160                let installation: Installation = serde_json::from_str(&body)
161                    .map_err(|_| anyhow::anyhow!("GitHub App installation could not be read"))?;
162                Some(installation.id)
163            }
164            (StatusCode::NOT_FOUND, _) => None,
165            (status, _) => {
166                anyhow::bail!("GitHub App installation lookup for {repo} failed: {status}")
167            }
168        };
169        lock(&self.installations).insert(repo.to_string(), found);
170        Ok(found)
171    }
172
173    async fn installation_token(&self, id: u64) -> anyhow::Result<String> {
174        if let Some(token) = self.cached_fresh_token(id) {
175            return Ok(token);
176        }
177        let _guard = self.refresh.lock().await;
178        if let Some(token) = self.cached_fresh_token(id) {
179            return Ok(token);
180        }
181        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
182        let url = format!("{}/app/installations/{id}/access_tokens", self.api_base);
183        let body = self.send(self.http.post(&url), &jwt).await?;
184        let parsed: AccessTokenResponse = serde_json::from_str(&body)
185            .map_err(|_| anyhow::anyhow!("GitHub installation token response could not be read"))?;
186        if parsed.token.is_empty() {
187            anyhow::bail!("GitHub installation token response has no token");
188        }
189        lock(&self.tokens).insert(
190            id,
191            CachedToken {
192                token: parsed.token.clone(),
193                expires_at: parsed.expires_at,
194            },
195        );
196        Ok(parsed.token)
197    }
198
199    fn cached_fresh_token(&self, id: u64) -> Option<String> {
200        let tokens = lock(&self.tokens);
201        let cached = tokens.get(&id)?;
202        token_is_fresh(cached.expires_at, Utc::now()).then(|| cached.token.clone())
203    }
204
205    /// The App's user login, `<slug>[bot]`. `GET /user` rejects installation tokens, so read
206    /// the slug from `GET /app` with the JWT
207    pub(crate) async fn login(&self) -> anyhow::Result<String> {
208        if let Some(login) = lock(&self.login).clone() {
209            return Ok(login);
210        }
211        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
212        let url = format!("{}/app", self.api_base);
213        let body = self.send(self.http.get(&url), &jwt).await?;
214        let info: AppInfo = serde_json::from_str(&body)
215            .map_err(|_| anyhow::anyhow!("GitHub App info could not be read"))?;
216        if info.slug.is_empty() {
217            anyhow::bail!("GitHub App info has no slug");
218        }
219        let login = format!("{}[bot]", info.slug);
220        *lock(&self.login) = Some(login.clone());
221        Ok(login)
222    }
223
224    /// Submit a pull request review as the App on the commit the review agent read
225    pub(crate) async fn submit_review(
226        &self,
227        repo: &str,
228        pr_number: u64,
229        event: ReviewEvent,
230        body: &str,
231        commit_id: &str,
232    ) -> anyhow::Result<()> {
233        let Some(token) = self.token_for_repo(repo).await? else {
234            anyhow::bail!("the GitHub App is not installed on {repo}");
235        };
236        let url = format!("{}/repos/{repo}/pulls/{pr_number}/reviews", self.api_base);
237        let payload = serde_json::json!({
238            "event": event.as_str(),
239            "body": super::http::redact_posted_body(body),
240            "commit_id": commit_id,
241        });
242        let result = self.send(self.http.post(&url).json(&payload), &token).await;
243        if result
244            .as_ref()
245            .is_err_and(|err| err.to_string().contains(StatusCode::UNAUTHORIZED.as_str()))
246        {
247            self.invalidate();
248        }
249        result.map(|_| ())
250    }
251
252    async fn send(&self, builder: reqwest::RequestBuilder, bearer: &str) -> anyhow::Result<String> {
253        let (status, body) = self.send_status(builder, bearer).await?;
254        if status.is_success() {
255            return Ok(body);
256        }
257        let message = serde_json::from_str::<serde_json::Value>(&body)
258            .ok()
259            .and_then(|value| value.get("message")?.as_str().map(str::to_owned))
260            .filter(|msg| !crate::security::has_leaks(msg))
261            .unwrap_or_default();
262        anyhow::bail!("GitHub App API {status}: {message}")
263    }
264
265    async fn send_status(
266        &self,
267        builder: reqwest::RequestBuilder,
268        bearer: &str,
269    ) -> anyhow::Result<(StatusCode, String)> {
270        let resp = builder
271            .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
272            .header(header::ACCEPT, "application/vnd.github+json")
273            .header("X-GitHub-Api-Version", "2022-11-28")
274            .send()
275            .await
276            // The reqwest error can carry the URL, never the headers
277            .map_err(|err| anyhow::anyhow!("GitHub App request failed to send: {err}"))?;
278        let status = resp.status();
279        let body = resp.text().await.unwrap_or_default();
280        Ok((status, body))
281    }
282}
283
284fn token_is_fresh(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
285    expires_at > now + Duration::minutes(10)
286}
287
288fn nonempty_config(key: &str) -> Option<String> {
289    crate::config::get(key)
290        .ok()
291        .map(|value| value.trim().to_string())
292        .filter(|value| !value.is_empty())
293}
294
295fn read_key() -> anyhow::Result<String> {
296    if let Some(path) = nonempty_config("gh.app.private_key_path") {
297        let raw = std::fs::read_to_string(&path)
298            .map_err(|err| anyhow::anyhow!("failed to read GitHub App private key file: {err}"))?;
299        return normalize_pem(&raw);
300    }
301    if let Some(name) = nonempty_config("gh.app.private_key_env") {
302        let raw = std::env::var(&name).unwrap_or_default();
303        return normalize_pem(&raw).map_err(|_| {
304            anyhow::anyhow!("GitHub App private key variable {name} is unset or empty")
305        });
306    }
307    anyhow::bail!("set gh.app.private_key_path or gh.app.private_key_env")
308}
309
310// A key injected through an environment variable often carries literal `\n`
311fn normalize_pem(raw: &str) -> anyhow::Result<String> {
312    let pem = raw.replace("\\n", "\n").trim().to_string();
313    if pem.is_empty() {
314        anyhow::bail!("GitHub App private key is empty");
315    }
316    Ok(pem)
317}
318
319fn sign_jwt(app_id: &str, pem: &str, now: i64) -> anyhow::Result<String> {
320    let claims = Claims {
321        iss: app_id.to_string(),
322        iat: now - JWT_IAT_SKEW_SECS,
323        exp: now + JWT_EXP_FROM_NOW_SECS,
324    };
325    let key = EncodingKey::from_rsa_pem(pem.as_bytes())
326        .map_err(|_| anyhow::anyhow!("GitHub App private key is not a usable RSA PEM"))?;
327    encode(&Header::new(Algorithm::RS256), &claims, &key)
328        .map_err(|_| anyhow::anyhow!("failed to sign GitHub App JWT"))
329}
330
331/// The App login, `None` when the App is not configured or its login cannot be read
332pub(crate) async fn bot_login() -> Option<String> {
333    let app = shared()?;
334    match app.login().await {
335        Ok(login) => Some(login),
336        Err(err) => {
337            tracing::warn!(error = %err, "GitHub App login unreadable, review gate reads comments only");
338            None
339        }
340    }
341}
342
343// REST names a bot `slug[bot]`, GraphQL names it `slug`
344pub(crate) fn same_login(left: &str, right: &str) -> bool {
345    let strip = |login: &str| login.strip_suffix("[bot]").unwrap_or(login).to_string();
346    strip(left).eq_ignore_ascii_case(&strip(right))
347}
348
349fn on_head(review: &GitHubReview, head_sha: &str) -> bool {
350    !head_sha.is_empty()
351        && review
352            .commit_id
353            .as_deref()
354            .is_some_and(|sha| sha.eq_ignore_ascii_case(head_sha))
355}
356
357/// Keep every non-App review. Keep an App review only when its commit is the current head
358pub(crate) fn reviews_for_gate(
359    reviews: &[GitHubReview],
360    head_sha: &str,
361    app_login: Option<&str>,
362) -> Vec<GitHubReview> {
363    reviews
364        .iter()
365        .filter(|review| {
366            app_login.is_none_or(|login| {
367                !same_login(&review.user.login, login) || on_head(review, head_sha)
368            })
369        })
370        .cloned()
371        .collect()
372}
373
374/// An App approval or change request on the current head replaces the comment decision
375pub(crate) fn effective_comment_flags(
376    reviews: &[GitHubReview],
377    head_sha: &str,
378    app_login: Option<&str>,
379    comment_approved: bool,
380    comment_changes_requested: bool,
381) -> (bool, bool) {
382    let replaced = app_login.is_some_and(|login| {
383        reviews.iter().any(|review| {
384            same_login(&review.user.login, login)
385                && on_head(review, head_sha)
386                && (review.state == "APPROVED" || review.state == "CHANGES_REQUESTED")
387        })
388    });
389    if replaced {
390        (false, false)
391    } else {
392        (comment_approved, comment_changes_requested)
393    }
394}
395
396/// A throwaway RSA key pair, private then public PEM, made once per test process
397#[cfg(test)]
398fn test_keys() -> &'static (String, String) {
399    use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
400    static KEYS: OnceLock<(String, String)> = OnceLock::new();
401    KEYS.get_or_init(|| {
402        let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).expect("rsa");
403        let private = key.to_pkcs8_pem(LineEnding::LF).expect("pem").to_string();
404        let public = rsa::RsaPublicKey::from(&key)
405            .to_public_key_pem(LineEnding::LF)
406            .expect("public pem");
407        (private, public)
408    })
409}
410
411#[cfg(test)]
412pub(crate) fn test_pem() -> &'static str {
413    &test_keys().0
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::github::types::GitHubUser;
420    use jsonwebtoken::{decode, DecodingKey, Validation};
421    use std::sync::atomic::{AtomicUsize, Ordering};
422    use wiremock::matchers::{body_partial_json, header as header_is, method, path};
423    use wiremock::{Mock, MockServer, Request, ResponseTemplate};
424
425    fn app(server: &MockServer) -> AppAuth {
426        AppAuth::new("12345".to_string(), test_pem().to_string(), server.uri())
427    }
428
429    async fn installed(server: &MockServer, repo: &str, id: u64) {
430        Mock::given(method("GET"))
431            .and(path(format!("/repos/{repo}/installation")))
432            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": id})))
433            .mount(server)
434            .await;
435    }
436
437    fn review(login: &str, state: &str, sha: &str, at: &str) -> GitHubReview {
438        GitHubReview {
439            id: 1,
440            user: GitHubUser {
441                login: login.to_string(),
442            },
443            body: None,
444            state: state.to_string(),
445            html_url: None,
446            submitted_at: at.to_string(),
447            commit_id: Some(sha.to_string()),
448        }
449    }
450
451    fn decode_claims(jwt: &str) -> Claims {
452        let mut validation = Validation::new(Algorithm::RS256);
453        validation.validate_exp = false;
454        let key = DecodingKey::from_rsa_pem(test_keys().1.as_bytes()).expect("decode key");
455        decode::<Claims>(jwt, &key, &validation)
456            .expect("signed with the app key")
457            .claims
458    }
459
460    #[test]
461    fn jwt_has_the_app_claims_and_is_signed_with_the_key() {
462        let now = 1_700_000_000;
463        let jwt = sign_jwt("12345", test_pem(), now).expect("sign");
464        let claims = decode_claims(&jwt);
465        assert_eq!(claims.iss, "12345");
466        assert_eq!(claims.iat, now - 60);
467        assert!(claims.exp > now && claims.exp - now <= 600);
468        assert!(
469            crate::security::has_leaks(&jwt),
470            "a jwt must trip the leak scan"
471        );
472        assert!(
473            crate::security::has_leaks(test_pem()),
474            "a key must trip the leak scan"
475        );
476    }
477
478    #[test]
479    fn a_bad_key_is_rejected_without_echoing_it() {
480        let pem = format!("-----{} {}-----\nMARKERMATERIAL\n", "BEGIN", "PRIVATE KEY");
481        let err = sign_jwt("12345", &pem, 0).expect_err("bad key").to_string();
482        assert!(!err.contains("MARKERMATERIAL"), "{err}");
483    }
484
485    #[test]
486    fn normalize_pem_expands_escaped_newlines() {
487        let pem = normalize_pem("  A\\nB\\nC ").expect("pem");
488        assert_eq!(pem, "A\nB\nC");
489        assert!(normalize_pem("   ").is_err());
490    }
491
492    #[tokio::test]
493    async fn installation_token_is_cached_and_refreshed_before_expiry() {
494        let server = MockServer::start().await;
495        installed(&server, "o/r", 42).await;
496        let posts = Arc::new(AtomicUsize::new(0));
497        let counter = posts.clone();
498        Mock::given(method("POST"))
499            .and(path("/app/installations/42/access_tokens"))
500            .respond_with(move |_req: &Request| {
501                let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
502                ResponseTemplate::new(201).set_body_json(serde_json::json!({
503                    "token": format!("inst-{n}"),
504                    "expires_at": "2099-01-01T00:00:00Z",
505                }))
506            })
507            .mount(&server)
508            .await;
509        let app = app(&server);
510
511        assert_eq!(
512            app.token_for_repo("o/r").await.unwrap().as_deref(),
513            Some("inst-1")
514        );
515        assert_eq!(
516            app.token_for_repo("o/r").await.unwrap().as_deref(),
517            Some("inst-1")
518        );
519        assert_eq!(posts.load(Ordering::SeqCst), 1);
520
521        let requests = server.received_requests().await.expect("requests");
522        let auth = requests[0]
523            .headers
524            .get("authorization")
525            .and_then(|value| value.to_str().ok())
526            .unwrap_or_default();
527        let claims = decode_claims(auth.trim_start_matches("Bearer "));
528        assert_eq!(claims.iss, "12345");
529
530        // Inside the 10 minute window the token is refreshed
531        lock(&app.tokens).get_mut(&42).unwrap().expires_at = Utc::now() + Duration::minutes(9);
532        assert_eq!(
533            app.token_for_repo("o/r").await.unwrap().as_deref(),
534            Some("inst-2")
535        );
536        assert_eq!(posts.load(Ordering::SeqCst), 2);
537    }
538
539    #[tokio::test]
540    async fn a_repo_without_the_app_gets_no_token_and_is_looked_up_once() {
541        let server = MockServer::start().await;
542        Mock::given(method("GET"))
543            .and(path("/repos/other-org/x/installation"))
544            .respond_with(ResponseTemplate::new(404))
545            .expect(1)
546            .mount(&server)
547            .await;
548        let app = app(&server);
549        assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
550        assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
551    }
552
553    #[tokio::test]
554    async fn an_account_and_an_organization_get_their_own_installation_tokens() {
555        let server = MockServer::start().await;
556        installed(&server, "me/a", 42).await;
557        installed(&server, "org/b", 77).await;
558        for (id, token) in [(42, "inst-me"), (77, "inst-org")] {
559            Mock::given(method("POST"))
560                .and(path(format!("/app/installations/{id}/access_tokens")))
561                .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
562                    "token": token,
563                    "expires_at": "2099-01-01T00:00:00Z",
564                })))
565                .expect(1)
566                .mount(&server)
567                .await;
568        }
569        let app = app(&server);
570        assert_eq!(
571            app.token_for_repo("me/a").await.unwrap().as_deref(),
572            Some("inst-me")
573        );
574        assert_eq!(
575            app.token_for_repo("org/b").await.unwrap().as_deref(),
576            Some("inst-org")
577        );
578    }
579
580    #[tokio::test]
581    async fn a_failed_lookup_is_an_error_and_is_not_cached() {
582        let server = MockServer::start().await;
583        Mock::given(method("GET"))
584            .and(path("/repos/o/r/installation"))
585            .respond_with(ResponseTemplate::new(500))
586            .mount(&server)
587            .await;
588        let app = app(&server);
589        assert!(app.token_for_repo("o/r").await.is_err());
590        assert!(lock(&app.installations).get("o/r").is_none());
591    }
592
593    #[tokio::test]
594    async fn login_is_the_slug_with_the_bot_suffix() {
595        let server = MockServer::start().await;
596        Mock::given(method("GET"))
597            .and(path("/app"))
598            .respond_with(
599                ResponseTemplate::new(200).set_body_json(serde_json::json!({"slug": "orch-app"})),
600            )
601            .expect(1)
602            .mount(&server)
603            .await;
604        let app = app(&server);
605        assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
606        assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
607    }
608
609    #[tokio::test]
610    async fn a_review_is_submitted_on_the_head_sha_with_the_installation_token() {
611        let server = MockServer::start().await;
612        installed(&server, "o/r", 42).await;
613        Mock::given(method("POST"))
614            .and(path("/app/installations/42/access_tokens"))
615            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
616                "token": "inst-1",
617                "expires_at": "2099-01-01T00:00:00Z",
618            })))
619            .mount(&server)
620            .await;
621        Mock::given(method("POST"))
622            .and(path("/repos/o/r/pulls/9/reviews"))
623            .and(header_is("authorization", "Bearer inst-1"))
624            .and(body_partial_json(serde_json::json!({
625                "event": "APPROVE",
626                "commit_id": "abc123",
627                "body": "looks good",
628            })))
629            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
630            .expect(1)
631            .mount(&server)
632            .await;
633        app(&server)
634            .submit_review("o/r", 9, ReviewEvent::Approve, "looks good", "abc123")
635            .await
636            .expect("review submitted");
637    }
638
639    #[tokio::test]
640    async fn a_review_on_a_repo_without_the_app_fails_clearly() {
641        let server = MockServer::start().await;
642        Mock::given(method("GET"))
643            .and(path("/repos/org/b/installation"))
644            .respond_with(ResponseTemplate::new(404))
645            .mount(&server)
646            .await;
647        let err = app(&server)
648            .submit_review("org/b", 9, ReviewEvent::Approve, "ok", "abc123")
649            .await
650            .unwrap_err()
651            .to_string();
652        assert!(err.contains("not installed on org/b"), "{err}");
653    }
654
655    #[test]
656    fn an_app_approval_on_an_older_sha_does_not_pass_the_gate() {
657        let reviews = vec![review(
658            "orch-app[bot]",
659            "APPROVED",
660            "old",
661            "2026-01-01T00:00:00Z",
662        )];
663        assert!(reviews_for_gate(&reviews, "new", Some("orch-app[bot]")).is_empty());
664        assert_eq!(
665            reviews_for_gate(&reviews, "old", Some("orch-app[bot]")).len(),
666            1
667        );
668        // GraphQL drops the suffix
669        assert!(reviews_for_gate(&reviews, "new", Some("orch-app")).is_empty());
670    }
671
672    #[test]
673    fn human_reviews_stay_and_no_app_keeps_everything() {
674        let reviews = vec![
675            review("human", "CHANGES_REQUESTED", "old", "2026-01-01T00:00:00Z"),
676            review("orch-app[bot]", "APPROVED", "old", "2026-01-02T00:00:00Z"),
677        ];
678        let kept = reviews_for_gate(&reviews, "new", Some("orch-app[bot]"));
679        assert_eq!(kept.len(), 1);
680        assert_eq!(kept[0].user.login, "human");
681        assert_eq!(reviews_for_gate(&reviews, "new", None).len(), 2);
682    }
683
684    #[test]
685    fn an_app_review_on_the_head_replaces_the_comment_decision() {
686        let on_head = vec![review(
687            "orch-app",
688            "CHANGES_REQUESTED",
689            "head",
690            "2026-01-01T00:00:00Z",
691        )];
692        assert_eq!(
693            effective_comment_flags(&on_head, "head", Some("orch-app[bot]"), true, false),
694            (false, false)
695        );
696        // An old App review leaves the comment fallback in charge
697        assert_eq!(
698            effective_comment_flags(&on_head, "new", Some("orch-app[bot]"), true, false),
699            (true, false)
700        );
701        assert_eq!(
702            effective_comment_flags(&on_head, "head", None, true, false),
703            (true, false)
704        );
705    }
706}