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