1use 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";
63const 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
84pub(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
99pub(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 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
143impl 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
159pub(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
168pub fn start_loader() {
171 if cfg!(any(test, debug_assertions)) {
174 return;
175 }
176
177 tokio::spawn(run_loader(load_from_config, publish, LOAD_RETRY));
178}
179
180async 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 and App quota 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 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 pub(crate) fn invalidate(&self) {
251 lock(&self.tokens).clear();
252 }
253
254 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 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 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 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 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 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 = serde_json::from_str::<serde_json::Value>(&body)
452 .ok()
453 .and_then(|value| value.get("message")?.as_str().map(str::to_owned))
454 .filter(|msg| !crate::security::has_leaks(msg))
455 .unwrap_or_default();
456 anyhow::bail!("GitHub App API {status}: {message}")
457 }
458
459 async fn send_status(
460 &self,
461 builder: reqwest::RequestBuilder,
462 bearer: &str,
463 ) -> anyhow::Result<(StatusCode, String)> {
464 let resp = builder
465 .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
466 .header(header::ACCEPT, "application/vnd.github+json")
467 .header("X-GitHub-Api-Version", "2022-11-28")
468 .send()
469 .await
470 .map_err(|err| anyhow::anyhow!("GitHub App request failed to send: {err}"))?;
472 let status = resp.status();
473 let body = resp.text().await.unwrap_or_default();
474 Ok((status, body))
475 }
476}
477
478fn token_is_fresh(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
479 expires_at > now + Duration::minutes(10)
480}
481
482fn nonempty_config(key: &str) -> Option<String> {
483 crate::config::get(key)
484 .ok()
485 .map(|value| value.trim().to_string())
486 .filter(|value| !value.is_empty())
487}
488
489fn read_key() -> anyhow::Result<String> {
490 if let Some(reference) = nonempty_config("gh.app.private_key") {
492 return normalize_pem(&crate::secret_ref::resolve(&reference)?);
493 }
494 if let Some(path) = nonempty_config("gh.app.private_key_path") {
495 let raw = std::fs::read_to_string(&path)
496 .map_err(|err| anyhow::anyhow!("failed to read GitHub App private key file: {err}"))?;
497 return normalize_pem(&raw);
498 }
499 if let Some(name) = nonempty_config("gh.app.private_key_env") {
500 let raw = std::env::var(&name).unwrap_or_default();
501 return normalize_pem(&raw).map_err(|_| {
502 anyhow::anyhow!("GitHub App private key variable {name} is unset or empty")
503 });
504 }
505 anyhow::bail!("set gh.app.private_key, gh.app.private_key_env or gh.app.private_key_path")
506}
507
508fn normalize_pem(raw: &str) -> anyhow::Result<String> {
510 let pem = raw.replace("\\n", "\n").trim().to_string();
511 if pem.is_empty() {
512 anyhow::bail!("GitHub App private key is empty");
513 }
514 Ok(pem)
515}
516
517fn sign_jwt(app_id: &str, pem: &str, now: i64) -> anyhow::Result<String> {
518 let claims = Claims {
519 iss: app_id.to_string(),
520 iat: now - JWT_IAT_SKEW_SECS,
521 exp: now + JWT_EXP_FROM_NOW_SECS,
522 };
523 let key = EncodingKey::from_rsa_pem(pem.as_bytes())
524 .map_err(|_| anyhow::anyhow!("GitHub App private key is not a usable RSA PEM"))?;
525 encode(&Header::new(Algorithm::RS256), &claims, &key)
526 .map_err(|_| anyhow::anyhow!("failed to sign GitHub App JWT"))
527}
528
529pub(crate) async fn bot_login() -> Option<String> {
531 let app = shared()?;
532 match app.login().await {
533 Ok(login) => Some(login),
534 Err(err) => {
535 tracing::warn!(error = %err, "GitHub App login unreadable, review gate reads comments only");
536 None
537 }
538 }
539}
540
541pub(crate) fn same_login(left: &str, right: &str) -> bool {
543 let strip = |login: &str| login.strip_suffix("[bot]").unwrap_or(login).to_string();
544 strip(left).eq_ignore_ascii_case(&strip(right))
545}
546
547fn on_head(review: &GitHubReview, head_sha: &str) -> bool {
548 !head_sha.is_empty()
549 && review
550 .commit_id
551 .as_deref()
552 .is_some_and(|sha| sha.eq_ignore_ascii_case(head_sha))
553}
554
555pub(crate) fn reviews_for_gate(
557 reviews: &[GitHubReview],
558 head_sha: &str,
559 app_login: Option<&str>,
560) -> Vec<GitHubReview> {
561 reviews
562 .iter()
563 .filter(|review| {
564 app_login.is_none_or(|login| {
565 !same_login(&review.user.login, login) || on_head(review, head_sha)
566 })
567 })
568 .cloned()
569 .collect()
570}
571
572pub(crate) fn effective_comment_flags(
574 reviews: &[GitHubReview],
575 head_sha: &str,
576 app_login: Option<&str>,
577 comment_approved: bool,
578 comment_changes_requested: bool,
579) -> (bool, bool) {
580 let replaced = app_login.is_some_and(|login| {
581 reviews.iter().any(|review| {
582 same_login(&review.user.login, login)
583 && on_head(review, head_sha)
584 && (review.state == "APPROVED" || review.state == "CHANGES_REQUESTED")
585 })
586 });
587 if replaced {
588 (false, false)
589 } else {
590 (comment_approved, comment_changes_requested)
591 }
592}
593
594#[cfg(test)]
596fn test_keys() -> &'static (String, String) {
597 use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
598 static KEYS: std::sync::OnceLock<(String, String)> = std::sync::OnceLock::new();
599 KEYS.get_or_init(|| {
600 let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).expect("rsa");
601 let private = key.to_pkcs8_pem(LineEnding::LF).expect("pem").to_string();
602 let public = rsa::RsaPublicKey::from(&key)
603 .to_public_key_pem(LineEnding::LF)
604 .expect("public pem");
605 (private, public)
606 })
607}
608
609#[cfg(test)]
610pub(crate) fn test_pem() -> &'static str {
611 &test_keys().0
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::github::types::GitHubUser;
618 use jsonwebtoken::{decode, DecodingKey, Validation};
619 use std::sync::atomic::{AtomicUsize, Ordering};
620 use wiremock::matchers::{body_partial_json, header as header_is, method, path};
621 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
622
623 fn app(server: &MockServer) -> AppAuth {
624 AppAuth::new("12345".to_string(), test_pem().to_string(), server.uri())
625 }
626
627 async fn installed(server: &MockServer, repo: &str, id: u64) {
628 Mock::given(method("GET"))
629 .and(path(format!("/repos/{repo}/installation")))
630 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": id})))
631 .mount(server)
632 .await;
633 }
634
635 fn review(login: &str, state: &str, sha: &str, at: &str) -> GitHubReview {
636 GitHubReview {
637 id: 1,
638 user: GitHubUser {
639 login: login.to_string(),
640 },
641 body: None,
642 state: state.to_string(),
643 html_url: None,
644 submitted_at: at.to_string(),
645 commit_id: Some(sha.to_string()),
646 }
647 }
648
649 fn decode_claims(jwt: &str) -> Claims {
650 let mut validation = Validation::new(Algorithm::RS256);
651 validation.validate_exp = false;
652 let key = DecodingKey::from_rsa_pem(test_keys().1.as_bytes()).expect("decode key");
653 decode::<Claims>(jwt, &key, &validation)
654 .expect("signed with the app key")
655 .claims
656 }
657
658 #[test]
659 fn jwt_has_the_app_claims_and_is_signed_with_the_key() {
660 let now = 1_700_000_000;
661 let jwt = sign_jwt("12345", test_pem(), now).expect("sign");
662 let claims = decode_claims(&jwt);
663 assert_eq!(claims.iss, "12345");
664 assert_eq!(claims.iat, now - 60);
665 assert!(claims.exp > now && claims.exp - now <= 600);
666 assert!(
667 crate::security::has_leaks(&jwt),
668 "a jwt must trip the leak scan"
669 );
670 assert!(
671 crate::security::has_leaks(test_pem()),
672 "a key must trip the leak scan"
673 );
674 }
675
676 #[test]
677 fn a_bad_key_is_rejected_without_echoing_it() {
678 let pem = format!("-----{} {}-----\nMARKERMATERIAL\n", "BEGIN", "PRIVATE KEY");
679 let err = sign_jwt("12345", &pem, 0).expect_err("bad key").to_string();
680 assert!(!err.contains("MARKERMATERIAL"), "{err}");
681 }
682
683 #[test]
684 fn normalize_pem_expands_escaped_newlines() {
685 let pem = normalize_pem(" A\\nB\\nC ").expect("pem");
686 assert_eq!(pem, "A\nB\nC");
687 assert!(normalize_pem(" ").is_err());
688 }
689
690 #[tokio::test]
691 async fn installation_token_is_cached_and_refreshed_before_expiry() {
692 let server = MockServer::start().await;
693 installed(&server, "o/r", 42).await;
694 let posts = Arc::new(AtomicUsize::new(0));
695 let counter = posts.clone();
696 Mock::given(method("POST"))
697 .and(path("/app/installations/42/access_tokens"))
698 .respond_with(move |_req: &Request| {
699 let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
700 ResponseTemplate::new(201).set_body_json(serde_json::json!({
701 "token": format!("inst-{n}"),
702 "expires_at": "2099-01-01T00:00:00Z",
703 }))
704 })
705 .mount(&server)
706 .await;
707 let app = app(&server);
708
709 assert_eq!(
710 app.token_for_repo("o/r").await.unwrap().as_deref(),
711 Some("inst-1")
712 );
713 assert_eq!(
714 app.token_for_repo("o/r").await.unwrap().as_deref(),
715 Some("inst-1")
716 );
717 assert_eq!(posts.load(Ordering::SeqCst), 1);
718
719 let requests = server.received_requests().await.expect("requests");
720 let auth = requests[0]
721 .headers
722 .get("authorization")
723 .and_then(|value| value.to_str().ok())
724 .unwrap_or_default();
725 let claims = decode_claims(auth.trim_start_matches("Bearer "));
726 assert_eq!(claims.iss, "12345");
727
728 lock(&app.tokens).get_mut(&42).unwrap().expires_at = Utc::now() + Duration::minutes(9);
730 assert_eq!(
731 app.token_for_repo("o/r").await.unwrap().as_deref(),
732 Some("inst-2")
733 );
734 assert_eq!(posts.load(Ordering::SeqCst), 2);
735 }
736
737 #[tokio::test]
738 async fn a_repo_without_the_app_gets_no_token_and_is_looked_up_once() {
739 let server = MockServer::start().await;
740 Mock::given(method("GET"))
741 .and(path("/repos/other-org/x/installation"))
742 .respond_with(ResponseTemplate::new(404))
743 .expect(1)
744 .mount(&server)
745 .await;
746 let app = app(&server);
747 assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
748 assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
749 }
750
751 #[tokio::test]
752 async fn an_account_and_an_organization_get_their_own_installation_tokens() {
753 let server = MockServer::start().await;
754 installed(&server, "me/a", 42).await;
755 installed(&server, "org/b", 77).await;
756 for (id, token) in [(42, "inst-me"), (77, "inst-org")] {
757 Mock::given(method("POST"))
758 .and(path(format!("/app/installations/{id}/access_tokens")))
759 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
760 "token": token,
761 "expires_at": "2099-01-01T00:00:00Z",
762 })))
763 .expect(1)
764 .mount(&server)
765 .await;
766 }
767 let app = app(&server);
768 assert_eq!(
769 app.token_for_repo("me/a").await.unwrap().as_deref(),
770 Some("inst-me")
771 );
772 assert_eq!(
773 app.token_for_repo("org/b").await.unwrap().as_deref(),
774 Some("inst-org")
775 );
776 }
777
778 #[tokio::test]
779 async fn a_failed_lookup_is_an_error_and_is_not_cached() {
780 let server = MockServer::start().await;
781 Mock::given(method("GET"))
782 .and(path("/repos/o/r/installation"))
783 .respond_with(ResponseTemplate::new(500))
784 .mount(&server)
785 .await;
786 let app = app(&server);
787 assert!(app.token_for_repo("o/r").await.is_err());
788 assert!(lock(&app.installations).get("o/r").is_none());
789 }
790
791 #[tokio::test]
792 async fn login_is_the_slug_with_the_bot_suffix() {
793 let server = MockServer::start().await;
794 Mock::given(method("GET"))
795 .and(path("/app"))
796 .respond_with(
797 ResponseTemplate::new(200).set_body_json(serde_json::json!({"slug": "orch-app"})),
798 )
799 .expect(1)
800 .mount(&server)
801 .await;
802 let app = app(&server);
803 assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
804 assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
805 }
806
807 #[tokio::test]
808 async fn a_review_is_submitted_on_the_head_sha_with_the_installation_token() {
809 let server = MockServer::start().await;
810 installed(&server, "o/r", 42).await;
811 Mock::given(method("POST"))
812 .and(path("/app/installations/42/access_tokens"))
813 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
814 "token": "inst-1",
815 "expires_at": "2099-01-01T00:00:00Z",
816 })))
817 .mount(&server)
818 .await;
819 Mock::given(method("POST"))
820 .and(path("/repos/o/r/pulls/9/reviews"))
821 .and(header_is("authorization", "Bearer inst-1"))
822 .and(body_partial_json(serde_json::json!({
823 "event": "APPROVE",
824 "commit_id": "abc123",
825 "body": "looks good",
826 })))
827 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
828 .expect(1)
829 .mount(&server)
830 .await;
831 app(&server)
832 .submit_review("o/r", 9, ReviewEvent::Approve, "looks good", "abc123", &[])
833 .await
834 .expect("review submitted");
835 }
836
837 #[tokio::test]
838 async fn a_review_on_a_repo_without_the_app_fails_clearly() {
839 let server = MockServer::start().await;
840 Mock::given(method("GET"))
841 .and(path("/repos/org/b/installation"))
842 .respond_with(ResponseTemplate::new(404))
843 .mount(&server)
844 .await;
845 let err = app(&server)
846 .submit_review("org/b", 9, ReviewEvent::Approve, "ok", "abc123", &[])
847 .await
848 .unwrap_err()
849 .to_string();
850 assert!(err.contains("not installed on org/b"), "{err}");
851 }
852
853 #[test]
854 fn an_app_approval_on_an_older_sha_does_not_pass_the_gate() {
855 let reviews = vec![review(
856 "orch-app[bot]",
857 "APPROVED",
858 "old",
859 "2026-01-01T00:00:00Z",
860 )];
861 assert!(reviews_for_gate(&reviews, "new", Some("orch-app[bot]")).is_empty());
862 assert_eq!(
863 reviews_for_gate(&reviews, "old", Some("orch-app[bot]")).len(),
864 1
865 );
866 assert!(reviews_for_gate(&reviews, "new", Some("orch-app")).is_empty());
868 }
869
870 #[test]
871 fn human_reviews_stay_and_no_app_keeps_everything() {
872 let reviews = vec![
873 review("human", "CHANGES_REQUESTED", "old", "2026-01-01T00:00:00Z"),
874 review("orch-app[bot]", "APPROVED", "old", "2026-01-02T00:00:00Z"),
875 ];
876 let kept = reviews_for_gate(&reviews, "new", Some("orch-app[bot]"));
877 assert_eq!(kept.len(), 1);
878 assert_eq!(kept[0].user.login, "human");
879 assert_eq!(reviews_for_gate(&reviews, "new", None).len(), 2);
880 }
881
882 #[test]
883 fn an_app_review_on_the_head_replaces_the_comment_decision() {
884 let on_head = vec![review(
885 "orch-app",
886 "CHANGES_REQUESTED",
887 "head",
888 "2026-01-01T00:00:00Z",
889 )];
890 assert_eq!(
891 effective_comment_flags(&on_head, "head", Some("orch-app[bot]"), true, false),
892 (false, false)
893 );
894 assert_eq!(
896 effective_comment_flags(&on_head, "new", Some("orch-app[bot]"), true, false),
897 (true, false)
898 );
899 assert_eq!(
900 effective_comment_flags(&on_head, "head", None, true, false),
901 (true, false)
902 );
903 }
904
905 #[test]
906 fn hunk_headers_give_the_right_side_line_ranges() {
907 let patch =
908 "@@ -1,2 +10,3 @@\n a\n+b\n@@ -40 +44 @@ fn x()\n-c\n+d\n@@ -60,3 +70,0 @@\n-gone";
909 assert_eq!(right_side_ranges(patch), vec![(10, 12), (44, 44)]);
910 assert!(right_side_ranges("").is_empty());
911 assert!(right_side_ranges("@@ garbage @@").is_empty());
912 }
913
914 #[test]
915 fn issues_on_a_hunk_line_go_inline_and_the_rest_are_listed() {
916 use crate::engine::runner::response::ReviewIssue;
917 let issue = |file: &str, line: Option<u32>| ReviewIssue {
918 file: file.to_string(),
919 line,
920 severity: "warning".to_string(),
921 description: "check".to_string(),
922 };
923 let hunks = HashMap::from([("a.rs".to_string(), vec![(10, 12)])]);
924 let (inline, rest) = split_inline(
925 &[
926 issue("a.rs", Some(12)),
927 issue("a.rs", Some(13)),
928 issue("b.rs", None),
929 ],
930 &hunks,
931 );
932 assert_eq!(
933 inline,
934 vec![InlineComment {
935 path: "a.rs".to_string(),
936 line: 12,
937 body: "**warning:** check".to_string(),
938 }]
939 );
940 assert_eq!(
941 rest,
942 vec![
943 "- `a.rs:13` **warning:** check",
944 "- `b.rs` **warning:** check"
945 ]
946 );
947 }
948
949 #[tokio::test]
950 async fn a_failed_key_read_retries_and_a_later_read_publishes_the_app() {
951 let calls = Arc::new(AtomicUsize::new(0));
952 let counter = Arc::clone(&calls);
953 let load = move || {
954 if counter.fetch_add(1, Ordering::SeqCst) == 0 {
955 anyhow::bail!("nobody answered the prompt");
956 }
957 Ok(Some(AppAuth::new(
958 "12345".to_string(),
959 test_pem().to_string(),
960 "http://127.0.0.1:9".to_string(),
961 )))
962 };
963 let published = Arc::new(Mutex::new(Vec::new()));
964 let sink = Arc::clone(&published);
965
966 run_loader(
967 load,
968 move |app| lock(&sink).push(app.app_id.clone()),
969 StdDuration::from_millis(5),
970 )
971 .await;
972 assert_eq!(calls.load(Ordering::SeqCst), 2);
973 assert_eq!(*lock(&published), vec!["12345".to_string()]);
974 }
975
976 #[tokio::test]
977 async fn no_app_configured_stops_the_loader_without_publishing() {
978 let published = Arc::new(Mutex::new(0));
979 let sink = Arc::clone(&published);
980 run_loader(
981 || Ok(None),
982 move |_| *lock(&sink) += 1,
983 StdDuration::from_millis(5),
984 )
985 .await;
986 assert_eq!(*lock(&published), 0);
987 }
988}