1use 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";
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
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
161pub(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
175pub fn start_loader() {
178 if cfg!(any(test, debug_assertions)) {
181 return;
182 }
183
184 tokio::spawn(run_loader(load_from_config, publish, LOAD_RETRY));
185}
186
187async 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 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 pub(crate) fn invalidate(&self) {
258 lock(&self.tokens).clear();
259 }
260
261 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 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 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 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 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 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 .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
481fn 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
535fn 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 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
562fn 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
583pub(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
595pub(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
609pub(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
626pub(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#[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 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 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 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}