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, OnceLock};
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
62pub(crate) struct AppAuth {
63 app_id: String,
64 pem: String,
65 installations: Mutex<HashMap<String, Option<u64>>>,
67 tokens: Mutex<HashMap<u64, CachedToken>>,
68 login: Mutex<Option<String>>,
69 refresh: tokio::sync::Mutex<()>,
70 http: Client,
71 api_base: String,
72}
73
74impl std::fmt::Debug for AppAuth {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("AppAuth")
78 .field("app_id", &self.app_id)
79 .finish_non_exhaustive()
80 }
81}
82
83fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
84 mutex.lock().unwrap_or_else(|err| err.into_inner())
85}
86
87pub(crate) fn shared() -> Option<Arc<AppAuth>> {
89 static SHARED: OnceLock<Option<Arc<AppAuth>>> = OnceLock::new();
90 SHARED.get_or_init(load_shared).clone()
91}
92
93fn load_shared() -> Option<Arc<AppAuth>> {
94 if cfg!(any(test, debug_assertions)) {
97 return None;
98 }
99 let id = nonempty_config("gh.app.id")?;
100 match AppAuth::from_config(id) {
101 Ok(app) => Some(Arc::new(app)),
102 Err(err) => {
103 tracing::error!(error = %err, "GitHub App config unusable, orch uses the owner token");
104 None
105 }
106 }
107}
108
109impl AppAuth {
110 fn from_config(app_id: String) -> anyhow::Result<Self> {
111 if !app_id.chars().all(|c| c.is_ascii_digit()) {
112 anyhow::bail!("gh.app.id must be digits");
113 }
114 let pem = read_key()?;
115 sign_jwt(&app_id, &pem, Utc::now().timestamp())?;
117 Ok(Self::new(app_id, pem, "https://api.github.com".to_string()))
118 }
119
120 pub(crate) fn new(app_id: String, pem: String, api_base: String) -> Self {
121 let http = Client::builder()
122 .user_agent(USER_AGENT)
123 .timeout(StdDuration::from_secs(30))
124 .build()
125 .unwrap_or_default();
126 Self {
127 app_id,
128 pem,
129 installations: Mutex::new(HashMap::new()),
130 tokens: Mutex::new(HashMap::new()),
131 login: Mutex::new(None),
132 refresh: tokio::sync::Mutex::new(()),
133 http,
134 api_base,
135 }
136 }
137
138 pub(crate) fn invalidate(&self) {
140 lock(&self.tokens).clear();
141 }
142
143 pub(crate) async fn token_for_repo(&self, repo: &str) -> anyhow::Result<Option<String>> {
145 let Some(id) = self.installation_for(repo).await? else {
146 return Ok(None);
147 };
148 self.installation_token(id).await.map(Some)
149 }
150
151 async fn installation_for(&self, repo: &str) -> anyhow::Result<Option<u64>> {
153 if let Some(found) = lock(&self.installations).get(repo) {
154 return Ok(*found);
155 }
156 let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
157 let url = format!("{}/repos/{repo}/installation", self.api_base);
158 let found = match self.send_status(self.http.get(&url), &jwt).await? {
159 (status, body) if status.is_success() => {
160 let installation: Installation = serde_json::from_str(&body)
161 .map_err(|_| anyhow::anyhow!("GitHub App installation could not be read"))?;
162 Some(installation.id)
163 }
164 (StatusCode::NOT_FOUND, _) => None,
165 (status, _) => {
166 anyhow::bail!("GitHub App installation lookup for {repo} failed: {status}")
167 }
168 };
169 lock(&self.installations).insert(repo.to_string(), found);
170 Ok(found)
171 }
172
173 async fn installation_token(&self, id: u64) -> anyhow::Result<String> {
174 if let Some(token) = self.cached_fresh_token(id) {
175 return Ok(token);
176 }
177 let _guard = self.refresh.lock().await;
178 if let Some(token) = self.cached_fresh_token(id) {
179 return Ok(token);
180 }
181 let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
182 let url = format!("{}/app/installations/{id}/access_tokens", self.api_base);
183 let body = self.send(self.http.post(&url), &jwt).await?;
184 let parsed: AccessTokenResponse = serde_json::from_str(&body)
185 .map_err(|_| anyhow::anyhow!("GitHub installation token response could not be read"))?;
186 if parsed.token.is_empty() {
187 anyhow::bail!("GitHub installation token response has no token");
188 }
189 lock(&self.tokens).insert(
190 id,
191 CachedToken {
192 token: parsed.token.clone(),
193 expires_at: parsed.expires_at,
194 },
195 );
196 Ok(parsed.token)
197 }
198
199 fn cached_fresh_token(&self, id: u64) -> Option<String> {
200 let tokens = lock(&self.tokens);
201 let cached = tokens.get(&id)?;
202 token_is_fresh(cached.expires_at, Utc::now()).then(|| cached.token.clone())
203 }
204
205 pub(crate) async fn login(&self) -> anyhow::Result<String> {
208 if let Some(login) = lock(&self.login).clone() {
209 return Ok(login);
210 }
211 let jwt = sign_jwt(&self.app_id, &self.pem, Utc::now().timestamp())?;
212 let url = format!("{}/app", self.api_base);
213 let body = self.send(self.http.get(&url), &jwt).await?;
214 let info: AppInfo = serde_json::from_str(&body)
215 .map_err(|_| anyhow::anyhow!("GitHub App info could not be read"))?;
216 if info.slug.is_empty() {
217 anyhow::bail!("GitHub App info has no slug");
218 }
219 let login = format!("{}[bot]", info.slug);
220 *lock(&self.login) = Some(login.clone());
221 Ok(login)
222 }
223
224 pub(crate) async fn submit_review(
226 &self,
227 repo: &str,
228 pr_number: u64,
229 event: ReviewEvent,
230 body: &str,
231 commit_id: &str,
232 ) -> anyhow::Result<()> {
233 let Some(token) = self.token_for_repo(repo).await? else {
234 anyhow::bail!("the GitHub App is not installed on {repo}");
235 };
236 let url = format!("{}/repos/{repo}/pulls/{pr_number}/reviews", self.api_base);
237 let payload = serde_json::json!({
238 "event": event.as_str(),
239 "body": super::http::redact_posted_body(body),
240 "commit_id": commit_id,
241 });
242 let result = self.send(self.http.post(&url).json(&payload), &token).await;
243 if result
244 .as_ref()
245 .is_err_and(|err| err.to_string().contains(StatusCode::UNAUTHORIZED.as_str()))
246 {
247 self.invalidate();
248 }
249 result.map(|_| ())
250 }
251
252 async fn send(&self, builder: reqwest::RequestBuilder, bearer: &str) -> anyhow::Result<String> {
253 let (status, body) = self.send_status(builder, bearer).await?;
254 if status.is_success() {
255 return Ok(body);
256 }
257 let message = serde_json::from_str::<serde_json::Value>(&body)
258 .ok()
259 .and_then(|value| value.get("message")?.as_str().map(str::to_owned))
260 .filter(|msg| !crate::security::has_leaks(msg))
261 .unwrap_or_default();
262 anyhow::bail!("GitHub App API {status}: {message}")
263 }
264
265 async fn send_status(
266 &self,
267 builder: reqwest::RequestBuilder,
268 bearer: &str,
269 ) -> anyhow::Result<(StatusCode, String)> {
270 let resp = builder
271 .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
272 .header(header::ACCEPT, "application/vnd.github+json")
273 .header("X-GitHub-Api-Version", "2022-11-28")
274 .send()
275 .await
276 .map_err(|err| anyhow::anyhow!("GitHub App request failed to send: {err}"))?;
278 let status = resp.status();
279 let body = resp.text().await.unwrap_or_default();
280 Ok((status, body))
281 }
282}
283
284fn token_is_fresh(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
285 expires_at > now + Duration::minutes(10)
286}
287
288fn nonempty_config(key: &str) -> Option<String> {
289 crate::config::get(key)
290 .ok()
291 .map(|value| value.trim().to_string())
292 .filter(|value| !value.is_empty())
293}
294
295fn read_key() -> anyhow::Result<String> {
296 if let Some(path) = nonempty_config("gh.app.private_key_path") {
297 let raw = std::fs::read_to_string(&path)
298 .map_err(|err| anyhow::anyhow!("failed to read GitHub App private key file: {err}"))?;
299 return normalize_pem(&raw);
300 }
301 if let Some(name) = nonempty_config("gh.app.private_key_env") {
302 let raw = std::env::var(&name).unwrap_or_default();
303 return normalize_pem(&raw).map_err(|_| {
304 anyhow::anyhow!("GitHub App private key variable {name} is unset or empty")
305 });
306 }
307 anyhow::bail!("set gh.app.private_key_path or gh.app.private_key_env")
308}
309
310fn normalize_pem(raw: &str) -> anyhow::Result<String> {
312 let pem = raw.replace("\\n", "\n").trim().to_string();
313 if pem.is_empty() {
314 anyhow::bail!("GitHub App private key is empty");
315 }
316 Ok(pem)
317}
318
319fn sign_jwt(app_id: &str, pem: &str, now: i64) -> anyhow::Result<String> {
320 let claims = Claims {
321 iss: app_id.to_string(),
322 iat: now - JWT_IAT_SKEW_SECS,
323 exp: now + JWT_EXP_FROM_NOW_SECS,
324 };
325 let key = EncodingKey::from_rsa_pem(pem.as_bytes())
326 .map_err(|_| anyhow::anyhow!("GitHub App private key is not a usable RSA PEM"))?;
327 encode(&Header::new(Algorithm::RS256), &claims, &key)
328 .map_err(|_| anyhow::anyhow!("failed to sign GitHub App JWT"))
329}
330
331pub(crate) async fn bot_login() -> Option<String> {
333 let app = shared()?;
334 match app.login().await {
335 Ok(login) => Some(login),
336 Err(err) => {
337 tracing::warn!(error = %err, "GitHub App login unreadable, review gate reads comments only");
338 None
339 }
340 }
341}
342
343pub(crate) fn same_login(left: &str, right: &str) -> bool {
345 let strip = |login: &str| login.strip_suffix("[bot]").unwrap_or(login).to_string();
346 strip(left).eq_ignore_ascii_case(&strip(right))
347}
348
349fn on_head(review: &GitHubReview, head_sha: &str) -> bool {
350 !head_sha.is_empty()
351 && review
352 .commit_id
353 .as_deref()
354 .is_some_and(|sha| sha.eq_ignore_ascii_case(head_sha))
355}
356
357pub(crate) fn reviews_for_gate(
359 reviews: &[GitHubReview],
360 head_sha: &str,
361 app_login: Option<&str>,
362) -> Vec<GitHubReview> {
363 reviews
364 .iter()
365 .filter(|review| {
366 app_login.is_none_or(|login| {
367 !same_login(&review.user.login, login) || on_head(review, head_sha)
368 })
369 })
370 .cloned()
371 .collect()
372}
373
374pub(crate) fn effective_comment_flags(
376 reviews: &[GitHubReview],
377 head_sha: &str,
378 app_login: Option<&str>,
379 comment_approved: bool,
380 comment_changes_requested: bool,
381) -> (bool, bool) {
382 let replaced = app_login.is_some_and(|login| {
383 reviews.iter().any(|review| {
384 same_login(&review.user.login, login)
385 && on_head(review, head_sha)
386 && (review.state == "APPROVED" || review.state == "CHANGES_REQUESTED")
387 })
388 });
389 if replaced {
390 (false, false)
391 } else {
392 (comment_approved, comment_changes_requested)
393 }
394}
395
396#[cfg(test)]
398fn test_keys() -> &'static (String, String) {
399 use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
400 static KEYS: OnceLock<(String, String)> = OnceLock::new();
401 KEYS.get_or_init(|| {
402 let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).expect("rsa");
403 let private = key.to_pkcs8_pem(LineEnding::LF).expect("pem").to_string();
404 let public = rsa::RsaPublicKey::from(&key)
405 .to_public_key_pem(LineEnding::LF)
406 .expect("public pem");
407 (private, public)
408 })
409}
410
411#[cfg(test)]
412pub(crate) fn test_pem() -> &'static str {
413 &test_keys().0
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::github::types::GitHubUser;
420 use jsonwebtoken::{decode, DecodingKey, Validation};
421 use std::sync::atomic::{AtomicUsize, Ordering};
422 use wiremock::matchers::{body_partial_json, header as header_is, method, path};
423 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
424
425 fn app(server: &MockServer) -> AppAuth {
426 AppAuth::new("12345".to_string(), test_pem().to_string(), server.uri())
427 }
428
429 async fn installed(server: &MockServer, repo: &str, id: u64) {
430 Mock::given(method("GET"))
431 .and(path(format!("/repos/{repo}/installation")))
432 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": id})))
433 .mount(server)
434 .await;
435 }
436
437 fn review(login: &str, state: &str, sha: &str, at: &str) -> GitHubReview {
438 GitHubReview {
439 id: 1,
440 user: GitHubUser {
441 login: login.to_string(),
442 },
443 body: None,
444 state: state.to_string(),
445 html_url: None,
446 submitted_at: at.to_string(),
447 commit_id: Some(sha.to_string()),
448 }
449 }
450
451 fn decode_claims(jwt: &str) -> Claims {
452 let mut validation = Validation::new(Algorithm::RS256);
453 validation.validate_exp = false;
454 let key = DecodingKey::from_rsa_pem(test_keys().1.as_bytes()).expect("decode key");
455 decode::<Claims>(jwt, &key, &validation)
456 .expect("signed with the app key")
457 .claims
458 }
459
460 #[test]
461 fn jwt_has_the_app_claims_and_is_signed_with_the_key() {
462 let now = 1_700_000_000;
463 let jwt = sign_jwt("12345", test_pem(), now).expect("sign");
464 let claims = decode_claims(&jwt);
465 assert_eq!(claims.iss, "12345");
466 assert_eq!(claims.iat, now - 60);
467 assert!(claims.exp > now && claims.exp - now <= 600);
468 assert!(
469 crate::security::has_leaks(&jwt),
470 "a jwt must trip the leak scan"
471 );
472 assert!(
473 crate::security::has_leaks(test_pem()),
474 "a key must trip the leak scan"
475 );
476 }
477
478 #[test]
479 fn a_bad_key_is_rejected_without_echoing_it() {
480 let pem = format!("-----{} {}-----\nMARKERMATERIAL\n", "BEGIN", "PRIVATE KEY");
481 let err = sign_jwt("12345", &pem, 0).expect_err("bad key").to_string();
482 assert!(!err.contains("MARKERMATERIAL"), "{err}");
483 }
484
485 #[test]
486 fn normalize_pem_expands_escaped_newlines() {
487 let pem = normalize_pem(" A\\nB\\nC ").expect("pem");
488 assert_eq!(pem, "A\nB\nC");
489 assert!(normalize_pem(" ").is_err());
490 }
491
492 #[tokio::test]
493 async fn installation_token_is_cached_and_refreshed_before_expiry() {
494 let server = MockServer::start().await;
495 installed(&server, "o/r", 42).await;
496 let posts = Arc::new(AtomicUsize::new(0));
497 let counter = posts.clone();
498 Mock::given(method("POST"))
499 .and(path("/app/installations/42/access_tokens"))
500 .respond_with(move |_req: &Request| {
501 let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
502 ResponseTemplate::new(201).set_body_json(serde_json::json!({
503 "token": format!("inst-{n}"),
504 "expires_at": "2099-01-01T00:00:00Z",
505 }))
506 })
507 .mount(&server)
508 .await;
509 let app = app(&server);
510
511 assert_eq!(
512 app.token_for_repo("o/r").await.unwrap().as_deref(),
513 Some("inst-1")
514 );
515 assert_eq!(
516 app.token_for_repo("o/r").await.unwrap().as_deref(),
517 Some("inst-1")
518 );
519 assert_eq!(posts.load(Ordering::SeqCst), 1);
520
521 let requests = server.received_requests().await.expect("requests");
522 let auth = requests[0]
523 .headers
524 .get("authorization")
525 .and_then(|value| value.to_str().ok())
526 .unwrap_or_default();
527 let claims = decode_claims(auth.trim_start_matches("Bearer "));
528 assert_eq!(claims.iss, "12345");
529
530 lock(&app.tokens).get_mut(&42).unwrap().expires_at = Utc::now() + Duration::minutes(9);
532 assert_eq!(
533 app.token_for_repo("o/r").await.unwrap().as_deref(),
534 Some("inst-2")
535 );
536 assert_eq!(posts.load(Ordering::SeqCst), 2);
537 }
538
539 #[tokio::test]
540 async fn a_repo_without_the_app_gets_no_token_and_is_looked_up_once() {
541 let server = MockServer::start().await;
542 Mock::given(method("GET"))
543 .and(path("/repos/other-org/x/installation"))
544 .respond_with(ResponseTemplate::new(404))
545 .expect(1)
546 .mount(&server)
547 .await;
548 let app = app(&server);
549 assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
550 assert_eq!(app.token_for_repo("other-org/x").await.unwrap(), None);
551 }
552
553 #[tokio::test]
554 async fn an_account_and_an_organization_get_their_own_installation_tokens() {
555 let server = MockServer::start().await;
556 installed(&server, "me/a", 42).await;
557 installed(&server, "org/b", 77).await;
558 for (id, token) in [(42, "inst-me"), (77, "inst-org")] {
559 Mock::given(method("POST"))
560 .and(path(format!("/app/installations/{id}/access_tokens")))
561 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
562 "token": token,
563 "expires_at": "2099-01-01T00:00:00Z",
564 })))
565 .expect(1)
566 .mount(&server)
567 .await;
568 }
569 let app = app(&server);
570 assert_eq!(
571 app.token_for_repo("me/a").await.unwrap().as_deref(),
572 Some("inst-me")
573 );
574 assert_eq!(
575 app.token_for_repo("org/b").await.unwrap().as_deref(),
576 Some("inst-org")
577 );
578 }
579
580 #[tokio::test]
581 async fn a_failed_lookup_is_an_error_and_is_not_cached() {
582 let server = MockServer::start().await;
583 Mock::given(method("GET"))
584 .and(path("/repos/o/r/installation"))
585 .respond_with(ResponseTemplate::new(500))
586 .mount(&server)
587 .await;
588 let app = app(&server);
589 assert!(app.token_for_repo("o/r").await.is_err());
590 assert!(lock(&app.installations).get("o/r").is_none());
591 }
592
593 #[tokio::test]
594 async fn login_is_the_slug_with_the_bot_suffix() {
595 let server = MockServer::start().await;
596 Mock::given(method("GET"))
597 .and(path("/app"))
598 .respond_with(
599 ResponseTemplate::new(200).set_body_json(serde_json::json!({"slug": "orch-app"})),
600 )
601 .expect(1)
602 .mount(&server)
603 .await;
604 let app = app(&server);
605 assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
606 assert_eq!(app.login().await.unwrap(), "orch-app[bot]");
607 }
608
609 #[tokio::test]
610 async fn a_review_is_submitted_on_the_head_sha_with_the_installation_token() {
611 let server = MockServer::start().await;
612 installed(&server, "o/r", 42).await;
613 Mock::given(method("POST"))
614 .and(path("/app/installations/42/access_tokens"))
615 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
616 "token": "inst-1",
617 "expires_at": "2099-01-01T00:00:00Z",
618 })))
619 .mount(&server)
620 .await;
621 Mock::given(method("POST"))
622 .and(path("/repos/o/r/pulls/9/reviews"))
623 .and(header_is("authorization", "Bearer inst-1"))
624 .and(body_partial_json(serde_json::json!({
625 "event": "APPROVE",
626 "commit_id": "abc123",
627 "body": "looks good",
628 })))
629 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
630 .expect(1)
631 .mount(&server)
632 .await;
633 app(&server)
634 .submit_review("o/r", 9, ReviewEvent::Approve, "looks good", "abc123")
635 .await
636 .expect("review submitted");
637 }
638
639 #[tokio::test]
640 async fn a_review_on_a_repo_without_the_app_fails_clearly() {
641 let server = MockServer::start().await;
642 Mock::given(method("GET"))
643 .and(path("/repos/org/b/installation"))
644 .respond_with(ResponseTemplate::new(404))
645 .mount(&server)
646 .await;
647 let err = app(&server)
648 .submit_review("org/b", 9, ReviewEvent::Approve, "ok", "abc123")
649 .await
650 .unwrap_err()
651 .to_string();
652 assert!(err.contains("not installed on org/b"), "{err}");
653 }
654
655 #[test]
656 fn an_app_approval_on_an_older_sha_does_not_pass_the_gate() {
657 let reviews = vec![review(
658 "orch-app[bot]",
659 "APPROVED",
660 "old",
661 "2026-01-01T00:00:00Z",
662 )];
663 assert!(reviews_for_gate(&reviews, "new", Some("orch-app[bot]")).is_empty());
664 assert_eq!(
665 reviews_for_gate(&reviews, "old", Some("orch-app[bot]")).len(),
666 1
667 );
668 assert!(reviews_for_gate(&reviews, "new", Some("orch-app")).is_empty());
670 }
671
672 #[test]
673 fn human_reviews_stay_and_no_app_keeps_everything() {
674 let reviews = vec![
675 review("human", "CHANGES_REQUESTED", "old", "2026-01-01T00:00:00Z"),
676 review("orch-app[bot]", "APPROVED", "old", "2026-01-02T00:00:00Z"),
677 ];
678 let kept = reviews_for_gate(&reviews, "new", Some("orch-app[bot]"));
679 assert_eq!(kept.len(), 1);
680 assert_eq!(kept[0].user.login, "human");
681 assert_eq!(reviews_for_gate(&reviews, "new", None).len(), 2);
682 }
683
684 #[test]
685 fn an_app_review_on_the_head_replaces_the_comment_decision() {
686 let on_head = vec![review(
687 "orch-app",
688 "CHANGES_REQUESTED",
689 "head",
690 "2026-01-01T00:00:00Z",
691 )];
692 assert_eq!(
693 effective_comment_flags(&on_head, "head", Some("orch-app[bot]"), true, false),
694 (false, false)
695 );
696 assert_eq!(
698 effective_comment_flags(&on_head, "new", Some("orch-app[bot]"), true, false),
699 (true, false)
700 );
701 assert_eq!(
702 effective_comment_flags(&on_head, "head", None, true, false),
703 (true, false)
704 );
705 }
706}