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