1use 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";
87const 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
108pub(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
123pub(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 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
167impl 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
183pub(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
195pub fn start_loader() {
198 if cfg!(any(test, debug_assertions)) {
201 return;
202 }
203
204 tokio::spawn(run_loader(load_from_config, publish, LOAD_RETRY));
205}
206
207async 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 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 pub(crate) fn invalidate(&self) {
278 lock(&self.tokens).clear();
279 }
280
281 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 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 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 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 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 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 .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
501fn 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
555fn 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 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
582fn 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
603pub(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
615pub(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
629pub(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
646pub(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#[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 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 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 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}