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, RwLock};
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
62const REVIEW_CHECK: &str = "orch/review";
63// GitHub lists at most 3000 files of a pull request, 30 pages of 100
64const PR_FILE_PAGES: u32 = 30;
65
66pub(crate) enum CheckRunState<'a> {
67    InProgress,
68    Completed { approved: bool, summary: &'a str },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub(crate) struct InlineComment {
73    pub path: String,
74    pub line: u32,
75    pub body: String,
76}
77
78#[derive(Deserialize)]
79struct PrFile {
80    filename: String,
81    patch: Option<String>,
82}
83
84/// Right-side `(first, last)` line of each hunk, from headers such as `@@ -10,4 +12,6 @@`
85pub(crate) fn right_side_ranges(patch: &str) -> Vec<(u32, u32)> {
86    patch
87        .lines()
88        .filter_map(|line| line.strip_prefix("@@ "))
89        .filter_map(|header| header.split_whitespace().find(|part| part.starts_with('+')))
90        .filter_map(|right| {
91            let mut parts = right.trim_start_matches('+').split(',');
92            let start: u32 = parts.next()?.parse().ok()?;
93            let count: u32 = parts.next().map_or(Some(1), |c| c.parse().ok())?;
94            (count > 0).then(|| (start, start + count - 1))
95        })
96        .collect()
97}
98
99/// Split issues into inline comments on a hunk line and the rest, which GitHub would refuse
100pub(crate) fn split_inline(
101    issues: &[crate::engine::runner::response::ReviewIssue],
102    hunks: &HashMap<String, Vec<(u32, u32)>>,
103) -> (Vec<InlineComment>, Vec<String>) {
104    let mut inline = Vec::new();
105    let mut rest = Vec::new();
106
107    for issue in issues {
108        let body = format!("**{}:** {}", issue.severity, issue.description);
109        let in_hunk = issue.line.filter(|line| {
110            hunks
111                .get(&issue.file)
112                .is_some_and(|ranges| ranges.iter().any(|(a, b)| (a..=b).contains(&line)))
113        });
114
115        match in_hunk {
116            Some(line) => inline.push(InlineComment {
117                path: issue.file.clone(),
118                line,
119                body,
120            }),
121            None => rest.push(match issue.line {
122                Some(line) => format!("- `{}:{line}` {body}", issue.file),
123                None => format!("- `{}` {body}", issue.file),
124            }),
125        }
126    }
127
128    (inline, rest)
129}
130
131pub(crate) struct AppAuth {
132    app_id: String,
133    pem: String,
134    // `None` means the App is not installed on that repository, so it uses the owner token
135    installations: Mutex<HashMap<String, Option<u64>>>,
136    tokens: Mutex<HashMap<u64, CachedToken>>,
137    login: Mutex<Option<String>>,
138    refresh: tokio::sync::Mutex<()>,
139    http: Client,
140    api_base: String,
141}
142
143// Keeps the key and the token out of any `{:?}` that reaches a log
144impl std::fmt::Debug for AppAuth {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("AppAuth")
147            .field("app_id", &self.app_id)
148            .finish_non_exhaustive()
149    }
150}
151
152fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
153    mutex.lock().unwrap_or_else(|err| err.into_inner())
154}
155
156static SHARED: RwLock<Option<Arc<AppAuth>>> = RwLock::new(None);
157const LOAD_RETRY: StdDuration = StdDuration::from_secs(300);
158
159/// Process-wide App client, `None` until the loader has read the key or when no App is set up
160pub(crate) fn shared() -> Option<Arc<AppAuth>> {
161    SHARED.read().unwrap_or_else(|err| err.into_inner()).clone()
162}
163
164fn publish(app: Arc<AppAuth>) {
165    *SHARED.write().unwrap_or_else(|err| err.into_inner()) = Some(app);
166}
167
168/// Reads the App key in the background, once per daemon start. A passbox key waits on Touch
169/// ID, so the engine runs on the owner token and comment reviews until the read succeeds
170pub fn start_loader() {
171    // `cfg(test)` is unset when tests/*.rs link the library, so check debug builds too, at run
172    // time, which keeps the release path compiled and type-checked in every build
173    if cfg!(any(test, debug_assertions)) {
174        return;
175    }
176
177    tokio::spawn(run_loader(load_from_config, publish, LOAD_RETRY));
178}
179
180// One read at a time, so at most one passbox prompt is ever pending
181async fn run_loader<L, P>(load: L, publish: P, retry: StdDuration)
182where
183    L: Fn() -> anyhow::Result<Option<AppAuth>> + Clone + Send + 'static,
184    P: Fn(Arc<AppAuth>),
185{
186    let mut warned = false;
187
188    loop {
189        let attempt = load.clone();
190        let result = tokio::task::spawn_blocking(attempt)
191            .await
192            .map_err(anyhow::Error::from)
193            .and_then(|r| r);
194
195        match result {
196            Ok(Some(app)) => {
197                tracing::info!(app_id = %app.app_id, "GitHub App loaded, App reviews are on");
198                publish(Arc::new(app));
199                return;
200            }
201            Ok(None) => return,
202            Err(err) if !warned => {
203                tracing::warn!(error = %err, "GitHub App unavailable, using the owner token and comment reviews, retrying every 5 minutes");
204                warned = true;
205            }
206            Err(_) => {}
207        }
208
209        tokio::time::sleep(retry).await;
210    }
211}
212
213fn load_from_config() -> anyhow::Result<Option<AppAuth>> {
214    let Some(id) = nonempty_config("gh.app.id") else {
215        return Ok(None);
216    };
217    AppAuth::from_config(id).map(Some)
218}
219
220impl AppAuth {
221    fn from_config(app_id: String) -> anyhow::Result<Self> {
222        if !app_id.chars().all(|c| c.is_ascii_digit()) {
223            anyhow::bail!("gh.app.id must be digits");
224        }
225        let pem = read_key()?;
226        // Fail at startup on a bad key, not on the first request
227        sign_jwt(&app_id, &pem, Utc::now().timestamp())?;
228        Ok(Self::new(app_id, pem, "https://api.github.com".to_string()))
229    }
230
231    pub(crate) fn new(app_id: String, pem: String, api_base: String) -> Self {
232        let http = Client::builder()
233            .user_agent(USER_AGENT)
234            .timeout(StdDuration::from_secs(30))
235            .build()
236            .unwrap_or_default();
237        Self {
238            app_id,
239            pem,
240            installations: Mutex::new(HashMap::new()),
241            tokens: Mutex::new(HashMap::new()),
242            login: Mutex::new(None),
243            refresh: tokio::sync::Mutex::new(()),
244            http,
245            api_base,
246        }
247    }
248
249    /// Drop the cached installation tokens so the next request exchanges new ones
250    pub(crate) fn invalidate(&self) {
251        lock(&self.tokens).clear();
252    }
253
254    /// The installation token for `repo`, `None` when the App is not installed on it
255    pub(crate) async fn token_for_repo(&self, repo: &str) -> anyhow::Result<Option<String>> {
256        let Some(id) = self.installation_for(repo).await? else {
257            return Ok(None);
258        };
259        self.installation_token(id).await.map(Some)
260    }
261
262    // An account and each organization are separate installations, so the lookup is per repo
263    async fn installation_for(&self, repo: &str) -> anyhow::Result<Option<u64>> {
264        if let Some(found) = lock(&self.installations).get(repo) {
265            return Ok(*found);
266        }
267        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
268        let url = format!("{}/repos/{repo}/installation", self.api_base);
269        let found = match self.send_status(self.http.get(&url), &jwt).await? {
270            (status, body) if status.is_success() => {
271                let installation: Installation = serde_json::from_str(&body)
272                    .map_err(|_| anyhow::anyhow!("GitHub App installation could not be read"))?;
273                Some(installation.id)
274            }
275            (StatusCode::NOT_FOUND, _) => None,
276            (status, _) => {
277                anyhow::bail!("GitHub App installation lookup for {repo} failed: {status}")
278            }
279        };
280        lock(&self.installations).insert(repo.to_string(), found);
281        Ok(found)
282    }
283
284    async fn installation_token(&self, id: u64) -> anyhow::Result<String> {
285        if let Some(token) = self.cached_fresh_token(id) {
286            return Ok(token);
287        }
288        let _guard = self.refresh.lock().await;
289        if let Some(token) = self.cached_fresh_token(id) {
290            return Ok(token);
291        }
292        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
293        let url = format!("{}/app/installations/{id}/access_tokens", self.api_base);
294        let body = self.send(self.http.post(&url), &jwt).await?;
295        let parsed: AccessTokenResponse = serde_json::from_str(&body)
296            .map_err(|_| anyhow::anyhow!("GitHub installation token response could not be read"))?;
297        if parsed.token.is_empty() {
298            anyhow::bail!("GitHub installation token response has no token");
299        }
300        lock(&self.tokens).insert(
301            id,
302            CachedToken {
303                token: parsed.token.clone(),
304                expires_at: parsed.expires_at,
305            },
306        );
307        Ok(parsed.token)
308    }
309
310    fn cached_fresh_token(&self, id: u64) -> Option<String> {
311        let tokens = lock(&self.tokens);
312        let cached = tokens.get(&id)?;
313        token_is_fresh(cached.expires_at, Utc::now()).then(|| cached.token.clone())
314    }
315
316    /// The App's user login, `<slug>[bot]`. `GET /user` rejects installation tokens, so read
317    /// the slug from `GET /app` with the JWT
318    pub(crate) async fn login(&self) -> anyhow::Result<String> {
319        if let Some(login) = lock(&self.login).clone() {
320            return Ok(login);
321        }
322        let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
323        let url = format!("{}/app", self.api_base);
324        let body = self.send(self.http.get(&url), &jwt).await?;
325        let info: AppInfo = serde_json::from_str(&body)
326            .map_err(|_| anyhow::anyhow!("GitHub App info could not be read"))?;
327        if info.slug.is_empty() {
328            anyhow::bail!("GitHub App info has no slug");
329        }
330        let login = format!("{}[bot]", info.slug);
331        *lock(&self.login) = Some(login.clone());
332        Ok(login)
333    }
334
335    /// Submit a pull request review as the App on the commit the review agent read
336    pub(crate) async fn submit_review(
337        &self,
338        repo: &str,
339        pr_number: u64,
340        event: ReviewEvent,
341        body: &str,
342        commit_id: &str,
343        comments: &[InlineComment],
344    ) -> anyhow::Result<()> {
345        let token = self.repo_token(repo).await?;
346        let url = format!("{}/repos/{repo}/pulls/{pr_number}/reviews", self.api_base);
347        let comments: Vec<serde_json::Value> = comments
348            .iter()
349            .map(|c| {
350                serde_json::json!({
351                    "path": c.path,
352                    "line": c.line,
353                    "side": "RIGHT",
354                    "body": super::http::redact_posted_body(&c.body),
355                })
356            })
357            .collect();
358        let payload = serde_json::json!({
359            "event": event.as_str(),
360            "body": super::http::redact_posted_body(body),
361            "commit_id": commit_id,
362            "comments": comments,
363        });
364        let result = self.send(self.http.post(&url).json(&payload), &token).await;
365        if result
366            .as_ref()
367            .is_err_and(|err| err.to_string().contains(StatusCode::UNAUTHORIZED.as_str()))
368        {
369            self.invalidate();
370        }
371        result.map(|_| ())
372    }
373
374    async fn repo_token(&self, repo: &str) -> anyhow::Result<String> {
375        match self.token_for_repo(repo).await? {
376            Some(token) => Ok(token),
377            None => anyhow::bail!("the GitHub App is not installed on {repo}"),
378        }
379    }
380
381    /// Post the `orch/review` check run on `head_sha`. The Checks API takes only an App token
382    pub(crate) async fn post_check_run(
383        &self,
384        repo: &str,
385        head_sha: &str,
386        state: CheckRunState<'_>,
387    ) -> anyhow::Result<()> {
388        let token = self.repo_token(repo).await?;
389        let url = format!("{}/repos/{repo}/check-runs", self.api_base);
390        let payload = match state {
391            CheckRunState::InProgress => serde_json::json!({
392                "name": REVIEW_CHECK,
393                "head_sha": head_sha,
394                "status": "in_progress",
395            }),
396            CheckRunState::Completed { approved, summary } => serde_json::json!({
397                "name": REVIEW_CHECK,
398                "head_sha": head_sha,
399                "status": "completed",
400                "conclusion": if approved { "success" } else { "failure" },
401                "output": {
402                    "title": if approved { "Approved by automated review" } else { "Automated review requested changes" },
403                    "summary": super::http::redact_posted_body(summary),
404                },
405            }),
406        };
407        self.send(self.http.post(&url).json(&payload), &token)
408            .await
409            .map(|_| ())
410    }
411
412    /// Right-side line ranges of every hunk in the pull request, per file path
413    pub(crate) async fn pull_request_hunks(
414        &self,
415        repo: &str,
416        pr_number: u64,
417    ) -> anyhow::Result<HashMap<String, Vec<(u32, u32)>>> {
418        let token = self.repo_token(repo).await?;
419        let mut hunks = HashMap::new();
420
421        for page in 1..=PR_FILE_PAGES {
422            let url = format!(
423                "{}/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}",
424                self.api_base
425            );
426            let body = self.send(self.http.get(&url), &token).await?;
427            let files: Vec<PrFile> = serde_json::from_str(&body)
428                .map_err(|_| anyhow::anyhow!("pull request files could not be read"))?;
429            let last = files.len() < 100;
430
431            for file in files {
432                hunks.insert(
433                    file.filename,
434                    right_side_ranges(file.patch.as_deref().unwrap_or("")),
435                );
436            }
437
438            if last {
439                break;
440            }
441        }
442
443        Ok(hunks)
444    }
445
446    async fn send(&self, builder: reqwest::RequestBuilder, bearer: &str) -> anyhow::Result<String> {
447        let (status, body) = self.send_status(builder, bearer).await?;
448        if status.is_success() {
449            return Ok(body);
450        }
451        let message = error_message(&body);
452        anyhow::bail!("GitHub App API {status}: {message}")
453    }
454
455    async fn send_status(
456        &self,
457        builder: reqwest::RequestBuilder,
458        bearer: &str,
459    ) -> anyhow::Result<(StatusCode, String)> {
460        let resp = builder
461            .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
462            .header(header::ACCEPT, "application/vnd.github+json")
463            .header("X-GitHub-Api-Version", "2022-11-28")
464            .send()
465            .await
466            // The reqwest error can carry the URL, never the headers
467            .map_err(|err| anyhow::anyhow!("GitHub App request failed to send: {err}"))?;
468        let status = resp.status();
469        let body = resp.text().await.unwrap_or_default();
470        Ok((status, body))
471    }
472}
473
474/// The `message` of an error body plus its `errors` entries, which hold the real reason of a 422
475fn error_message(body: &str) -> String {
476    let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
477        return String::new();
478    };
479    let safe = |text: String| (!crate::security::has_leaks(&text)).then_some(text);
480    let message = value
481        .get("message")
482        .and_then(|msg| msg.as_str())
483        .map(str::to_owned)
484        .and_then(safe)
485        .unwrap_or_default();
486    let details: Vec<String> = value
487        .get("errors")
488        .and_then(|errors| errors.as_array())
489        .into_iter()
490        .flatten()
491        .filter_map(error_entry)
492        .filter_map(safe)
493        .collect();
494
495    if details.is_empty() {
496        message
497    } else {
498        format!("{message} ({})", details.join("; "))
499    }
500}
501
502fn error_entry(entry: &serde_json::Value) -> Option<String> {
503    if let Some(text) = entry.as_str() {
504        return Some(text.to_owned());
505    }
506    let field = |name: &str| entry.get(name).and_then(|v| v.as_str());
507    if let Some(text) = field("message") {
508        return Some(text.to_owned());
509    }
510    let code = field("code")?;
511    let place = [field("resource"), field("field")]
512        .into_iter()
513        .flatten()
514        .collect::<Vec<_>>()
515        .join(".");
516
517    Some(if place.is_empty() {
518        code.to_owned()
519    } else {
520        format!("{place}: {code}")
521    })
522}
523
524fn token_is_fresh(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
525    expires_at > now + Duration::minutes(10)
526}
527
528// The global config only: a project file must never point the App's key or id elsewhere
529fn nonempty_config(key: &str) -> Option<String> {
530    crate::config::get_global(key)
531        .ok()
532        .map(|value| value.trim().to_string())
533        .filter(|value| !value.is_empty())
534}
535
536fn read_key() -> anyhow::Result<String> {
537    // `passbox:<name>` keeps the key out of every file an agent can read
538    if let Some(reference) = nonempty_config("gh.app.private_key") {
539        return normalize_pem(&crate::secret_ref::resolve(&reference)?);
540    }
541    if let Some(path) = nonempty_config("gh.app.private_key_path") {
542        let raw = std::fs::read_to_string(&path)
543            .map_err(|err| anyhow::anyhow!("failed to read GitHub App private key file: {err}"))?;
544        return normalize_pem(&raw);
545    }
546    if let Some(name) = nonempty_config("gh.app.private_key_env") {
547        let raw = std::env::var(&name).unwrap_or_default();
548        return normalize_pem(&raw).map_err(|_| {
549            anyhow::anyhow!("GitHub App private key variable {name} is unset or empty")
550        });
551    }
552    anyhow::bail!("set gh.app.private_key, gh.app.private_key_env or gh.app.private_key_path")
553}
554
555// A key injected through an environment variable often carries literal `\n`
556fn normalize_pem(raw: &str) -> anyhow::Result<String> {
557    let pem = raw.replace("\\n", "\n").trim().to_string();
558    if pem.is_empty() {
559        anyhow::bail!("GitHub App private key is empty");
560    }
561    Ok(pem)
562}
563
564fn sign_jwt(app_id: &str, pem: &str, now: i64) -> anyhow::Result<String> {
565    let claims = Claims {
566        iss: app_id.to_string(),
567        iat: now - JWT_IAT_SKEW_SECS,
568        exp: now + JWT_EXP_FROM_NOW_SECS,
569    };
570    let key = EncodingKey::from_rsa_pem(pem.as_bytes())
571        .map_err(|_| anyhow::anyhow!("GitHub App private key is not a usable RSA PEM"))?;
572    encode(&Header::new(Algorithm::RS256), &claims, &key)
573        .map_err(|_| anyhow::anyhow!("failed to sign GitHub App JWT"))
574}
575
576/// The App login, `None` when the App is not configured or its login cannot be read
577pub(crate) async fn bot_login() -> Option<String> {
578    let app = shared()?;
579    match app.login().await {
580        Ok(login) => Some(login),
581        Err(err) => {
582            tracing::warn!(error = %err, "GitHub App login unreadable, review gate reads comments only");
583            None
584        }
585    }
586}
587
588// REST names a bot `slug[bot]`, GraphQL names it `slug`
589pub(crate) fn same_login(left: &str, right: &str) -> bool {
590    let strip = |login: &str| login.strip_suffix("[bot]").unwrap_or(login).to_string();
591    strip(left).eq_ignore_ascii_case(&strip(right))
592}
593
594fn on_head(review: &GitHubReview, head_sha: &str) -> bool {
595    !head_sha.is_empty()
596        && review
597            .commit_id
598            .as_deref()
599            .is_some_and(|sha| sha.eq_ignore_ascii_case(head_sha))
600}
601
602/// Keep every non-App review. Keep an App review only when its commit is the current head
603pub(crate) fn reviews_for_gate(
604    reviews: &[GitHubReview],
605    head_sha: &str,
606    app_login: Option<&str>,
607) -> Vec<GitHubReview> {
608    reviews
609        .iter()
610        .filter(|review| {
611            app_login.is_none_or(|login| {
612                !same_login(&review.user.login, login) || on_head(review, head_sha)
613            })
614        })
615        .cloned()
616        .collect()
617}
618
619/// An App approval or change request on the current head replaces the comment decision
620pub(crate) fn effective_comment_flags(
621    reviews: &[GitHubReview],
622    head_sha: &str,
623    app_login: Option<&str>,
624    comment_approved: bool,
625    comment_changes_requested: bool,
626) -> (bool, bool) {
627    let replaced = app_login.is_some_and(|login| {
628        reviews.iter().any(|review| {
629            same_login(&review.user.login, login)
630                && on_head(review, head_sha)
631                && (review.state == "APPROVED" || review.state == "CHANGES_REQUESTED")
632        })
633    });
634    if replaced {
635        (false, false)
636    } else {
637        (comment_approved, comment_changes_requested)
638    }
639}
640
641/// A throwaway RSA key pair, private then public PEM, made once per test process
642#[cfg(test)]
643fn test_keys() -> &'static (String, String) {
644    use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
645    static KEYS: std::sync::OnceLock<(String, String)> = std::sync::OnceLock::new();
646    KEYS.get_or_init(|| {
647        let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).expect("rsa");
648        let private = key.to_pkcs8_pem(LineEnding::LF).expect("pem").to_string();
649        let public = rsa::RsaPublicKey::from(&key)
650            .to_public_key_pem(LineEnding::LF)
651            .expect("public pem");
652        (private, public)
653    })
654}
655
656#[cfg(test)]
657pub(crate) fn test_pem() -> &'static str {
658    &test_keys().0
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::github::types::GitHubUser;
665    use jsonwebtoken::{decode, DecodingKey, Validation};
666    use std::sync::atomic::{AtomicUsize, Ordering};
667    use wiremock::matchers::{body_partial_json, header as header_is, method, path};
668    use wiremock::{Mock, MockServer, Request, ResponseTemplate};
669
670    fn app(server: &MockServer) -> AppAuth {
671        AppAuth::new("12345".to_string(), test_pem().to_string(), server.uri())
672    }
673
674    async fn installed(server: &MockServer, repo: &str, id: u64) {
675        Mock::given(method("GET"))
676            .and(path(format!("/repos/{repo}/installation")))
677            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": id})))
678            .mount(server)
679            .await;
680    }
681
682    fn review(login: &str, state: &str, sha: &str, at: &str) -> GitHubReview {
683        GitHubReview {
684            id: 1,
685            user: GitHubUser {
686                login: login.to_string(),
687            },
688            body: None,
689            state: state.to_string(),
690            html_url: None,
691            submitted_at: at.to_string(),
692            commit_id: Some(sha.to_string()),
693        }
694    }
695
696    fn decode_claims(jwt: &str) -> Claims {
697        let mut validation = Validation::new(Algorithm::RS256);
698        validation.validate_exp = false;
699        let key = DecodingKey::from_rsa_pem(test_keys().1.as_bytes()).expect("decode key");
700        decode::<Claims>(jwt, &key, &validation)
701            .expect("signed with the app key")
702            .claims
703    }
704
705    #[test]
706    fn jwt_has_the_app_claims_and_is_signed_with_the_key() {
707        let now = 1_700_000_000;
708        let jwt = sign_jwt("12345", test_pem(), now).expect("sign");
709        let claims = decode_claims(&jwt);
710        assert_eq!(claims.iss, "12345");
711        assert_eq!(claims.iat, now - 60);
712        assert!(claims.exp > now && claims.exp - now <= 600);
713        assert!(
714            crate::security::has_leaks(&jwt),
715            "a jwt must trip the leak scan"
716        );
717        assert!(
718            crate::security::has_leaks(test_pem()),
719            "a key must trip the leak scan"
720        );
721    }
722
723    #[test]
724    fn error_message_includes_the_errors_entries() {
725        let body = serde_json::json!({
726            "message": "Unprocessable Entity",
727            "errors": [
728                "Can not approve your own pull request",
729                {"message": "line must be part of the diff"},
730                {"resource": "PullRequestReview", "field": "commit_id", "code": "invalid"}
731            ]
732        })
733        .to_string();
734        assert_eq!(
735            error_message(&body),
736            "Unprocessable Entity (Can not approve your own pull request; line must be part of \
737             the diff; PullRequestReview.commit_id: invalid)"
738        );
739    }
740
741    #[test]
742    fn error_message_drops_an_entry_that_trips_the_leak_scan() {
743        let body = serde_json::json!({
744            "message": "Unprocessable Entity",
745            "errors": ["still readable", test_pem()]
746        })
747        .to_string();
748        let message = error_message(&body);
749        assert_eq!(message, "Unprocessable Entity (still readable)");
750        assert!(!message.contains("PRIVATE"));
751        assert_eq!(error_message("not json"), "");
752    }
753
754    #[test]
755    fn a_bad_key_is_rejected_without_echoing_it() {
756        let pem = format!("-----{} {}-----\nMARKERMATERIAL\n", "BEGIN", "PRIVATE KEY");
757        let err = sign_jwt("12345", &pem, 0).expect_err("bad key").to_string();
758        assert!(!err.contains("MARKERMATERIAL"), "{err}");
759    }
760
761    #[test]
762    fn normalize_pem_expands_escaped_newlines() {
763        let pem = normalize_pem("  A\\nB\\nC ").expect("pem");
764        assert_eq!(pem, "A\nB\nC");
765        assert!(normalize_pem("   ").is_err());
766    }
767
768    #[tokio::test]
769    async fn installation_token_is_cached_and_refreshed_before_expiry() {
770        let server = MockServer::start().await;
771        installed(&server, "o/r", 42).await;
772        let posts = Arc::new(AtomicUsize::new(0));
773        let counter = posts.clone();
774        Mock::given(method("POST"))
775            .and(path("/app/installations/42/access_tokens"))
776            .respond_with(move |_req: &Request| {
777                let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
778                ResponseTemplate::new(201).set_body_json(serde_json::json!({
779                    "token": format!("inst-{n}"),
780                    "expires_at": "2099-01-01T00:00:00Z",
781                }))
782            })
783            .mount(&server)
784            .await;
785        let app = app(&server);
786
787        assert_eq!(
788            app.token_for_repo("o/r").await.unwrap().as_deref(),
789            Some("inst-1")
790        );
791        assert_eq!(
792            app.token_for_repo("o/r").await.unwrap().as_deref(),
793            Some("inst-1")
794        );
795        assert_eq!(posts.load(Ordering::SeqCst), 1);
796
797        let requests = server.received_requests().await.expect("requests");
798        let auth = requests[0]
799            .headers
800            .get("authorization")
801            .and_then(|value| value.to_str().ok())
802            .unwrap_or_default();
803        let claims = decode_claims(auth.trim_start_matches("Bearer "));
804        assert_eq!(claims.iss, "12345");
805
806        // Inside the 10 minute window the token is refreshed
807        lock(&app.tokens).get_mut(&42).unwrap().expires_at = Utc::now() + Duration::minutes(9);
808        assert_eq!(
809            app.token_for_repo("o/r").await.unwrap().as_deref(),
810            Some("inst-2")
811        );
812        assert_eq!(posts.load(Ordering::SeqCst), 2);
813    }
814
815    #[tokio::test]
816    async fn a_repo_without_the_app_gets_no_token_and_is_looked_up_once() {
817        let server = MockServer::start().await;
818        Mock::given(method("GET"))
819            .and(path("/repos/other-org/x/installation"))
820            .respond_with(ResponseTemplate::new(404))
821            .expect(1)
822            .mount(&server)
823            .await;
824        let app = app(&server);
825        assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
826        assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
827    }
828
829    #[tokio::test]
830    async fn an_account_and_an_organization_get_their_own_installation_tokens() {
831        let server = MockServer::start().await;
832        installed(&server, "me/a", 42).await;
833        installed(&server, "org/b", 77).await;
834        for (id, token) in [(42, "inst-me"), (77, "inst-org")] {
835            Mock::given(method("POST"))
836                .and(path(format!("/app/installations/{id}/access_tokens")))
837                .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
838                    "token": token,
839                    "expires_at": "2099-01-01T00:00:00Z",
840                })))
841                .expect(1)
842                .mount(&server)
843                .await;
844        }
845        let app = app(&server);
846        assert_eq!(
847            app.token_for_repo("me/a").await.unwrap().as_deref(),
848            Some("inst-me")
849        );
850        assert_eq!(
851            app.token_for_repo("org/b").await.unwrap().as_deref(),
852            Some("inst-org")
853        );
854    }
855
856    #[tokio::test]
857    async fn a_failed_lookup_is_an_error_and_is_not_cached() {
858        let server = MockServer::start().await;
859        Mock::given(method("GET"))
860            .and(path("/repos/o/r/installation"))
861            .respond_with(ResponseTemplate::new(500))
862            .mount(&server)
863            .await;
864        let app = app(&server);
865        assert!(app.token_for_repo("o/r").await.is_err());
866        assert!(lock(&app.installations).get("o/r").is_none());
867    }
868
869    #[tokio::test]
870    async fn login_is_the_slug_with_the_bot_suffix() {
871        let server = MockServer::start().await;
872        Mock::given(method("GET"))
873            .and(path("/app"))
874            .respond_with(
875                ResponseTemplate::new(200).set_body_json(serde_json::json!({"slug": "orch-app"})),
876            )
877            .expect(1)
878            .mount(&server)
879            .await;
880        let app = app(&server);
881        assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
882        assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
883    }
884
885    #[tokio::test]
886    async fn a_review_is_submitted_on_the_head_sha_with_the_installation_token() {
887        let server = MockServer::start().await;
888        installed(&server, "o/r", 42).await;
889        Mock::given(method("POST"))
890            .and(path("/app/installations/42/access_tokens"))
891            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
892                "token": "inst-1",
893                "expires_at": "2099-01-01T00:00:00Z",
894            })))
895            .mount(&server)
896            .await;
897        Mock::given(method("POST"))
898            .and(path("/repos/o/r/pulls/9/reviews"))
899            .and(header_is("authorization", "Bearer inst-1"))
900            .and(body_partial_json(serde_json::json!({
901                "event": "APPROVE",
902                "commit_id": "abc123",
903                "body": "looks good",
904            })))
905            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
906            .expect(1)
907            .mount(&server)
908            .await;
909        app(&server)
910            .submit_review("o/r", 9, ReviewEvent::Approve, "looks good", "abc123", &[])
911            .await
912            .expect("review submitted");
913    }
914
915    #[tokio::test]
916    async fn a_review_on_a_repo_without_the_app_fails_clearly() {
917        let server = MockServer::start().await;
918        Mock::given(method("GET"))
919            .and(path("/repos/org/b/installation"))
920            .respond_with(ResponseTemplate::new(404))
921            .mount(&server)
922            .await;
923        let err = app(&server)
924            .submit_review("org/b", 9, ReviewEvent::Approve, "ok", "abc123", &[])
925            .await
926            .unwrap_err()
927            .to_string();
928        assert!(err.contains("not installed on org/b"), "{err}");
929    }
930
931    #[test]
932    fn an_app_approval_on_an_older_sha_does_not_pass_the_gate() {
933        let reviews = vec![review(
934            "orch-app[bot]",
935            "APPROVED",
936            "old",
937            "2026-01-01T00:00:00Z",
938        )];
939        assert!(reviews_for_gate(&reviews, "new", Some("orch-app[bot]")).is_empty());
940        assert_eq!(
941            reviews_for_gate(&reviews, "old", Some("orch-app[bot]")).len(),
942            1
943        );
944        // GraphQL drops the suffix
945        assert!(reviews_for_gate(&reviews, "new", Some("orch-app")).is_empty());
946    }
947
948    #[test]
949    fn human_reviews_stay_and_no_app_keeps_everything() {
950        let reviews = vec![
951            review("human", "CHANGES_REQUESTED", "old", "2026-01-01T00:00:00Z"),
952            review("orch-app[bot]", "APPROVED", "old", "2026-01-02T00:00:00Z"),
953        ];
954        let kept = reviews_for_gate(&reviews, "new", Some("orch-app[bot]"));
955        assert_eq!(kept.len(), 1);
956        assert_eq!(kept[0].user.login, "human");
957        assert_eq!(reviews_for_gate(&reviews, "new", None).len(), 2);
958    }
959
960    #[test]
961    fn an_app_review_on_the_head_replaces_the_comment_decision() {
962        let on_head = vec![review(
963            "orch-app",
964            "CHANGES_REQUESTED",
965            "head",
966            "2026-01-01T00:00:00Z",
967        )];
968        assert_eq!(
969            effective_comment_flags(&on_head, "head", Some("orch-app[bot]"), true, false),
970            (false, false)
971        );
972        // An old App review leaves the comment fallback in charge
973        assert_eq!(
974            effective_comment_flags(&on_head, "new", Some("orch-app[bot]"), true, false),
975            (true, false)
976        );
977        assert_eq!(
978            effective_comment_flags(&on_head, "head", None, true, false),
979            (true, false)
980        );
981    }
982
983    #[test]
984    fn hunk_headers_give_the_right_side_line_ranges() {
985        let patch =
986            "@@ -1,2 +10,3 @@\n a\n+b\n@@ -40 +44 @@ fn x()\n-c\n+d\n@@ -60,3 +70,0 @@\n-gone";
987        assert_eq!(right_side_ranges(patch), vec![(10, 12), (44, 44)]);
988        assert!(right_side_ranges("").is_empty());
989        assert!(right_side_ranges("@@ garbage @@").is_empty());
990    }
991
992    #[test]
993    fn issues_on_a_hunk_line_go_inline_and_the_rest_are_listed() {
994        use crate::engine::runner::response::ReviewIssue;
995        let issue = |file: &str, line: Option<u32>| ReviewIssue {
996            file: file.to_string(),
997            line,
998            severity: "warning".to_string(),
999            description: "check".to_string(),
1000        };
1001        let hunks = HashMap::from([("a.rs".to_string(), vec![(10, 12)])]);
1002        let (inline, rest) = split_inline(
1003            &[
1004                issue("a.rs", Some(12)),
1005                issue("a.rs", Some(13)),
1006                issue("b.rs", None),
1007            ],
1008            &hunks,
1009        );
1010        assert_eq!(
1011            inline,
1012            vec![InlineComment {
1013                path: "a.rs".to_string(),
1014                line: 12,
1015                body: "**warning:** check".to_string(),
1016            }]
1017        );
1018        assert_eq!(
1019            rest,
1020            vec![
1021                "- `a.rs:13` **warning:** check",
1022                "- `b.rs` **warning:** check"
1023            ]
1024        );
1025    }
1026
1027    #[tokio::test]
1028    async fn a_failed_key_read_retries_and_a_later_read_publishes_the_app() {
1029        let calls = Arc::new(AtomicUsize::new(0));
1030        let counter = Arc::clone(&calls);
1031        let load = move || {
1032            if counter.fetch_add(1, Ordering::SeqCst) == 0 {
1033                anyhow::bail!("nobody answered the prompt");
1034            }
1035            Ok(Some(AppAuth::new(
1036                "12345".to_string(),
1037                test_pem().to_string(),
1038                "http://127.0.0.1:9".to_string(),
1039            )))
1040        };
1041        let published = Arc::new(Mutex::new(Vec::new()));
1042        let sink = Arc::clone(&published);
1043
1044        run_loader(
1045            load,
1046            move |app| lock(&sink).push(app.app_id.clone()),
1047            StdDuration::from_millis(5),
1048        )
1049        .await;
1050        assert_eq!(calls.load(Ordering::SeqCst), 2);
1051        assert_eq!(*lock(&published), vec!["12345".to_string()]);
1052    }
1053
1054    #[tokio::test]
1055    async fn no_app_configured_stops_the_loader_without_publishing() {
1056        let published = Arc::new(Mutex::new(0));
1057        let sink = Arc::clone(&published);
1058        run_loader(
1059            || Ok(None),
1060            move |_| *lock(&sink) += 1,
1061            StdDuration::from_millis(5),
1062        )
1063        .await;
1064        assert_eq!(*lock(&published), 0);
1065    }
1066}