1use anyhow::{Context, Result, bail};
38use async_trait::async_trait;
39use reqwest::Method;
40use serde::Deserialize;
41
42use crate::config::GithubConfig;
43use crate::repo::normalize_url;
44use crate::source::diffstat::{FileChange, format_diffstat, format_summary};
45use crate::source::{
46 Checkout, ForkRef, Lifecycle, RepoRef, Review, ReviewKey, ReviewKind, ReviewSource,
47};
48
49const DEFAULT_API_BASE: &str = "https://api.github.com";
50const SEARCH_PER_PAGE: u32 = 100;
51const FILES_PER_PAGE: u32 = 100;
54
55pub const NAME: &str = "gh";
57
58pub struct GithubSource {
59 cfg: GithubConfig,
60 client: reqwest::Client,
61 token: Option<String>,
62 base_url: String,
63 search_per_page: u32,
64}
65
66impl GithubSource {
67 pub async fn new(cfg: GithubConfig) -> Result<Self> {
68 let token = resolve_token(&cfg).await?;
69 let base_url = cfg
70 .api_url
71 .clone()
72 .unwrap_or_else(|| DEFAULT_API_BASE.to_string());
73 Ok(Self {
74 cfg,
75 client: reqwest::Client::new(),
76 token,
77 base_url,
78 search_per_page: SEARCH_PER_PAGE,
79 })
80 }
81
82 #[cfg(test)]
83 pub(crate) fn for_test(
84 cfg: GithubConfig,
85 token: Option<String>,
86 base_url: String,
87 search_per_page: u32,
88 ) -> Self {
89 Self {
90 cfg,
91 client: reqwest::Client::new(),
92 token,
93 base_url,
94 search_per_page,
95 }
96 }
97
98 fn build_query(&self) -> String {
99 let mut q = String::from("review-requested:@me type:pr is:open archived:false");
100 for r in &self.cfg.ignore_repos {
101 q.push_str(&format!(" -repo:{r}"));
102 }
103 for a in &self.cfg.ignore_authors {
104 q.push_str(&format!(" -author:{a}"));
105 }
106 for t in &self.cfg.ignore_teams {
107 q.push_str(&format!(" -team-review-requested:{t}"));
108 }
109 if !self.cfg.include_drafts {
110 q.push_str(" draft:false");
111 }
112 q
113 }
114
115 async fn get_json<T: serde::de::DeserializeOwned>(
116 &self,
117 path: &str,
118 query: &[(&str, &str)],
119 ) -> Result<T> {
120 let url = format!("{}{path}", self.base_url);
121 let mut req = self
122 .client
123 .request(Method::GET, url)
124 .query(query)
125 .header("Accept", "application/vnd.github+json")
126 .header("X-GitHub-Api-Version", "2022-11-28")
127 .header("User-Agent", "review-queue");
128 if let Some(token) = &self.token {
129 req = req.header("Authorization", format!("Bearer {token}"));
130 }
131 let resp = req.send().await.with_context(|| format!("GET {path}"))?;
132 let status = resp.status();
133 if !status.is_success() {
134 let body = resp.text().await.unwrap_or_default();
135 if status == reqwest::StatusCode::NOT_FOUND {
136 return Err(NotFound(format!("GET {path} failed: {status} {body}")).into());
137 }
138 bail!("GET {path} failed: {status} {body}");
139 }
140 resp.json::<T>()
141 .await
142 .with_context(|| format!("parsing response from GET {path}"))
143 }
144
145 async fn fetch_pull(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
146 self.get_json(&format!("/repos/{owner}/{repo}/pulls/{number}"), &[])
147 .await
148 }
149
150 async fn review_from_pull(&self, owner: &str, repo: &str, pr: &PullRequest) -> Result<Review> {
151 let base_repo = pr.base.repo.as_ref().context("PR has no base repo")?;
152 let diff_stat = match self.file_changes_for(owner, repo, pr.number).await {
153 Ok(changes) => format_diffstat(&changes),
154 Err(_) => format_summary(pr.changed_files, pr.additions, pr.deletions),
157 };
158 Ok(Review {
159 key: ReviewKey::new(NAME, format!("{owner}/{repo}/{}", pr.number)),
160 title: pr.title.clone(),
161 author: pr.user.login.clone(),
162 url: pr.html_url.clone(),
163 repo: RepoRef {
164 urls: vec![base_repo.clone_url.clone()],
165 display_name: base_repo.full_name.clone(),
166 },
167 version: pr.head.sha.clone(),
168 kind: ReviewKind::Direct,
169 diff_stat: Some(diff_stat),
170 description: pr.body.clone(),
171 ancestors: Vec::new(),
172 })
173 }
174
175 async fn file_changes_for(
178 &self,
179 owner: &str,
180 repo: &str,
181 number: u64,
182 ) -> Result<Vec<FileChange>> {
183 let mut changes = Vec::new();
184 let mut page = 1u32;
185 loop {
186 let per_page_str = FILES_PER_PAGE.to_string();
187 let page_str = page.to_string();
188 let files: Vec<PullFile> = self
189 .get_json(
190 &format!("/repos/{owner}/{repo}/pulls/{number}/files"),
191 &[("per_page", &per_page_str), ("page", &page_str)],
192 )
193 .await?;
194 let count = files.len();
195 changes.extend(files.into_iter().map(|f| FileChange {
196 path: f.filename,
197 additions: f.additions,
198 deletions: f.deletions,
199 }));
200 if count < FILES_PER_PAGE as usize || page >= 30 {
201 break;
202 }
203 page += 1;
204 }
205 Ok(changes)
206 }
207
208 fn fork_of(&self, pr: &PullRequest) -> Result<Option<ForkRef>> {
209 let Some(head_repo) = &pr.head.repo else {
210 return Ok(None);
211 };
212 let base_repo = pr.base.repo.as_ref().context("PR has no base repo")?;
213 let remote_name =
214 if normalize_url(&head_repo.clone_url) == normalize_url(&base_repo.clone_url) {
215 "origin".to_string()
216 } else {
217 head_repo.owner.login.clone()
218 };
219 Ok(Some(ForkRef {
220 remote_name,
221 remote_url: head_repo.clone_url.clone(),
222 branch: pr.head.ref_field.clone(),
223 }))
224 }
225}
226
227#[async_trait]
228impl ReviewSource for GithubSource {
229 fn name(&self) -> &str {
230 NAME
231 }
232
233 async fn fetch_queue(&self) -> Result<Vec<Review>> {
234 if self.token.is_none() {
235 bail!(
236 "GitHub source needs a token to search `@me` - configure `token`/`token_cmd`, \
237 set $GITHUB_TOKEN, or run `gh auth login`"
238 );
239 }
240
241 let q = self.build_query();
242 let mut reviews = Vec::new();
243 let mut page = 1u32;
244 loop {
245 let per_page_str = self.search_per_page.to_string();
246 let page_str = page.to_string();
247 let resp: SearchResponse = self
248 .get_json(
249 "/search/issues",
250 &[("q", &q), ("per_page", &per_page_str), ("page", &page_str)],
251 )
252 .await?;
253 let count = resp.items.len();
254 for item in &resp.items {
255 let (owner, repo, number) = parse_pr_url(&item.html_url)?;
256 let pr = self.fetch_pull(&owner, &repo, number).await?;
257 reviews.push(self.review_from_pull(&owner, &repo, &pr).await?);
258 }
259 if count < self.search_per_page as usize || page >= 10 {
260 break;
261 }
262 page += 1;
263 }
264 Ok(reviews)
265 }
266
267 async fn checkout_spec(
268 &self,
269 review: &Review,
270 _canonical_repo: &std::path::Path,
271 ) -> Result<Checkout> {
272 let (owner, repo, number) = parse_id(&review.key.id)?;
273 let pr = self.fetch_pull(&owner, &repo, number).await?;
274 let fork = self.fork_of(&pr)?;
275 Ok(Checkout::Ref {
276 refspec: format!("refs/pull/{number}/head"),
277 commit: pr.head.sha.clone(),
278 fork,
279 })
280 }
281
282 async fn fetch_status(&self, ids: &[String]) -> Result<Vec<(String, Lifecycle)>> {
283 let mut out = Vec::with_capacity(ids.len());
284 for id in ids {
285 let (owner, repo, number) = parse_id(id)?;
286 let lifecycle = match self.fetch_pull(&owner, &repo, number).await {
287 Ok(pr) if pr.state == "closed" => Lifecycle::Resolved,
288 Ok(_) => Lifecycle::Open,
289 Err(e) if e.is::<NotFound>() => Lifecycle::Resolved,
291 Err(e) => return Err(e),
292 };
293 out.push((id.clone(), lifecycle));
294 }
295 Ok(out)
296 }
297}
298
299#[derive(Debug)]
301struct NotFound(String);
302
303impl std::fmt::Display for NotFound {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 f.write_str(&self.0)
306 }
307}
308
309impl std::error::Error for NotFound {}
310
311async fn resolve_token(cfg: &GithubConfig) -> Result<Option<String>> {
313 if let Some(t) = &cfg.token {
314 return Ok(Some(t.clone()));
315 }
316 if let Some(cmd) = &cfg.token_cmd {
317 let out = tokio::process::Command::new("sh")
318 .arg("-c")
319 .arg(cmd)
320 .output()
321 .await
322 .with_context(|| format!("running token_cmd `{cmd}`"))?;
323 if !out.status.success() {
324 bail!(
325 "token_cmd `{cmd}` failed: {}",
326 String::from_utf8_lossy(&out.stderr).trim()
327 );
328 }
329 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
330 if !token.is_empty() {
331 return Ok(Some(token));
332 }
333 }
334 if let Ok(t) = std::env::var("GITHUB_TOKEN")
335 && !t.is_empty()
336 {
337 return Ok(Some(t));
338 }
339 if let Ok(out) = tokio::process::Command::new("gh")
340 .args(["auth", "token"])
341 .output()
342 .await
343 && out.status.success()
344 {
345 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
346 if !token.is_empty() {
347 return Ok(Some(token));
348 }
349 }
350 Ok(None)
351}
352
353fn parse_id(id: &str) -> Result<(String, String, u64)> {
355 let mut parts = id.splitn(3, '/');
356 let owner = parts
357 .next()
358 .with_context(|| format!("bad github review id `{id}`"))?;
359 let repo = parts
360 .next()
361 .with_context(|| format!("bad github review id `{id}`"))?;
362 let number: u64 = parts
363 .next()
364 .with_context(|| format!("bad github review id `{id}`"))?
365 .parse()
366 .with_context(|| format!("bad github review id `{id}` (PR number)"))?;
367 Ok((owner.to_string(), repo.to_string(), number))
368}
369
370fn parse_pr_url(html_url: &str) -> Result<(String, String, u64)> {
372 let rest = html_url
373 .strip_prefix("https://github.com/")
374 .with_context(|| format!("unexpected PR url `{html_url}`"))?;
375 let mut parts = rest.splitn(4, '/');
376 let owner = parts
377 .next()
378 .with_context(|| format!("unexpected PR url `{html_url}`"))?;
379 let repo = parts
380 .next()
381 .with_context(|| format!("unexpected PR url `{html_url}`"))?;
382 let _pull_literal = parts.next();
383 let number: u64 = parts
384 .next()
385 .with_context(|| format!("unexpected PR url `{html_url}`"))?
386 .parse()
387 .with_context(|| format!("unexpected PR url `{html_url}` (PR number)"))?;
388 Ok((owner.to_string(), repo.to_string(), number))
389}
390
391#[derive(Deserialize)]
392struct SearchResponse {
393 items: Vec<SearchItem>,
394}
395
396#[derive(Deserialize)]
397struct SearchItem {
398 html_url: String,
399}
400
401#[derive(Deserialize)]
402struct PullRequest {
403 number: u64,
404 title: String,
405 html_url: String,
406 state: String,
407 #[serde(default)]
408 body: Option<String>,
409 user: GhUser,
410 head: PrSide,
411 base: PrSide,
412 additions: u64,
415 deletions: u64,
416 changed_files: u64,
417}
418
419#[derive(Deserialize)]
420struct GhUser {
421 login: String,
422}
423
424#[derive(Deserialize)]
425struct PrSide {
426 #[serde(rename = "ref")]
427 ref_field: String,
428 sha: String,
429 repo: Option<GhRepo>,
430}
431
432#[derive(Deserialize)]
433struct GhRepo {
434 clone_url: String,
435 full_name: String,
436 owner: GhUser,
437}
438
439#[derive(Deserialize)]
440struct PullFile {
441 filename: String,
442 additions: u64,
443 deletions: u64,
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use wiremock::matchers::{method, path, path_regex, query_param};
450 use wiremock::{Mock, MockServer, ResponseTemplate};
451
452 fn cfg() -> GithubConfig {
453 GithubConfig {
454 api_url: None,
455 token: Some("t".into()),
456 token_cmd: None,
457 ignore_repos: vec![],
458 ignore_authors: vec![],
459 ignore_teams: vec![],
460 include_drafts: false,
461 }
462 }
463
464 fn pull_json(
465 owner: &str,
466 repo: &str,
467 number: u64,
468 fork_owner: Option<&str>,
469 ) -> serde_json::Value {
470 let head_repo = match fork_owner {
471 Some(fo) => serde_json::json!({
472 "clone_url": format!("https://github.com/{fo}/{repo}.git"),
473 "full_name": format!("{fo}/{repo}"),
474 "owner": {"login": fo},
475 }),
476 None => serde_json::Value::Null,
477 };
478 serde_json::json!({
479 "number": number,
480 "title": "Fix the thing",
481 "html_url": format!("https://github.com/{owner}/{repo}/pull/{number}"),
482 "state": "open",
483 "user": {"login": "author"},
484 "head": {"ref": "feature", "sha": "deadbeef", "repo": head_repo},
485 "base": {"ref": "main", "sha": "cafef00d", "repo": {
486 "clone_url": format!("https://github.com/{owner}/{repo}.git"),
487 "full_name": format!("{owner}/{repo}"),
488 "owner": {"login": owner},
489 }},
490 "additions": 3,
491 "deletions": 1,
492 "changed_files": 2,
493 })
494 }
495
496 #[tokio::test]
497 async fn parses_id_round_trip() {
498 assert_eq!(
499 parse_id("mozilla/gecko-dev/123").unwrap(),
500 ("mozilla".into(), "gecko-dev".into(), 123)
501 );
502 assert!(parse_id("not-enough-parts").is_err());
503 assert!(parse_id("owner/repo/not-a-number").is_err());
504 }
505
506 #[tokio::test]
507 async fn parses_pr_url() {
508 assert_eq!(
509 parse_pr_url("https://github.com/mozilla/gecko-dev/pull/123").unwrap(),
510 ("mozilla".into(), "gecko-dev".into(), 123)
511 );
512 assert!(parse_pr_url("https://example.com/not-github").is_err());
513 }
514
515 #[tokio::test]
516 async fn build_query_folds_in_config_filters() {
517 let mut c = cfg();
518 c.ignore_repos = vec!["mozilla/noisy".into()];
519 c.ignore_authors = vec!["bot".into()];
520 c.ignore_teams = vec!["mozilla/reviewers".into()];
521 c.include_drafts = true;
522 let src = GithubSource::for_test(
523 c,
524 Some("t".into()),
525 DEFAULT_API_BASE.into(),
526 SEARCH_PER_PAGE,
527 );
528 let q = src.build_query();
529 assert!(q.contains("review-requested:@me"));
530 assert!(q.contains("-repo:mozilla/noisy"));
531 assert!(q.contains("-author:bot"));
532 assert!(q.contains("-team-review-requested:mozilla/reviewers"));
533 assert!(
534 !q.contains("draft:false"),
535 "include_drafts=true should not add draft:false"
536 );
537 }
538
539 #[tokio::test]
540 async fn fetch_queue_builds_review_from_search_and_pull() {
541 let server = MockServer::start().await;
542 Mock::given(method("GET"))
543 .and(path("/search/issues"))
544 .and(query_param("page", "1"))
545 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
546 "items": [{"html_url": "https://github.com/mozilla/gecko-dev/pull/123"}]
547 })))
548 .mount(&server)
549 .await;
550 Mock::given(method("GET"))
551 .and(path("/repos/mozilla/gecko-dev/pulls/123"))
552 .respond_with(ResponseTemplate::new(200).set_body_json(pull_json(
553 "mozilla",
554 "gecko-dev",
555 123,
556 None,
557 )))
558 .mount(&server)
559 .await;
560
561 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
562 let reviews = src.fetch_queue().await.unwrap();
563
564 assert_eq!(reviews.len(), 1);
565 let r = &reviews[0];
566 assert_eq!(r.key, ReviewKey::new("gh", "mozilla/gecko-dev/123"));
567 assert_eq!(r.title, "Fix the thing");
568 assert_eq!(r.author, "author");
569 assert_eq!(r.version, "deadbeef");
570 assert_eq!(
571 r.repo.urls,
572 vec!["https://github.com/mozilla/gecko-dev.git"]
573 );
574 assert_eq!(
579 r.diff_stat.as_deref(),
580 Some("2 files changed, 3 insertions(+), 1 deletion(-)")
581 );
582 }
583
584 #[tokio::test]
585 async fn diff_stat_prefers_the_full_per_file_breakdown() {
586 let server = MockServer::start().await;
587 Mock::given(method("GET"))
588 .and(path("/repos/mozilla/gecko-dev/pulls/123/files"))
589 .and(query_param("page", "1"))
590 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
591 {"filename": "src/main.rs", "additions": 5, "deletions": 2},
592 ])))
593 .mount(&server)
594 .await;
595
596 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
597 let review = src
598 .review_from_pull(
599 "mozilla",
600 "gecko-dev",
601 &serde_json::from_value(pull_json("mozilla", "gecko-dev", 123, None)).unwrap(),
602 )
603 .await
604 .unwrap();
605
606 let stat = review.diff_stat.unwrap();
607 assert!(stat.contains("src/main.rs"));
608 assert!(stat.ends_with("1 file changed, 5 insertions(+), 2 deletions(-)"));
611 }
612
613 #[tokio::test]
614 async fn diff_stat_falls_back_to_the_aggregate_when_the_files_endpoint_fails() {
615 let server = MockServer::start().await;
616 Mock::given(method("GET"))
617 .and(path("/repos/mozilla/gecko-dev/pulls/123/files"))
618 .respond_with(ResponseTemplate::new(500))
619 .mount(&server)
620 .await;
621
622 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
623 let review = src
624 .review_from_pull(
625 "mozilla",
626 "gecko-dev",
627 &serde_json::from_value(pull_json("mozilla", "gecko-dev", 123, None)).unwrap(),
628 )
629 .await
630 .unwrap();
631
632 assert_eq!(
633 review.diff_stat.as_deref(),
634 Some("2 files changed, 3 insertions(+), 1 deletion(-)")
635 );
636 }
637
638 #[tokio::test]
639 async fn fetch_queue_paginates() {
640 let server = MockServer::start().await;
641 Mock::given(method("GET"))
642 .and(path("/search/issues"))
643 .and(query_param("page", "1"))
644 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
645 "items": [{"html_url": "https://github.com/mozilla/gecko-dev/pull/1"}]
646 })))
647 .mount(&server)
648 .await;
649 Mock::given(method("GET"))
650 .and(path("/search/issues"))
651 .and(query_param("page", "2"))
652 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
653 "items": [{"html_url": "https://github.com/mozilla/gecko-dev/pull/2"}]
654 })))
655 .mount(&server)
656 .await;
657 Mock::given(method("GET"))
658 .and(path("/search/issues"))
659 .and(query_param("page", "3"))
660 .respond_with(
661 ResponseTemplate::new(200).set_body_json(serde_json::json!({"items": []})),
662 )
663 .mount(&server)
664 .await;
665 Mock::given(method("GET"))
666 .and(path_regex(r"^/repos/mozilla/gecko-dev/pulls/\d+$"))
667 .respond_with(|req: &wiremock::Request| {
668 let number: u64 = req
669 .url
670 .path_segments()
671 .unwrap()
672 .next_back()
673 .unwrap()
674 .parse()
675 .unwrap();
676 ResponseTemplate::new(200).set_body_json(pull_json(
677 "mozilla",
678 "gecko-dev",
679 number,
680 None,
681 ))
682 })
683 .mount(&server)
684 .await;
685
686 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), 1);
688 let reviews = src.fetch_queue().await.unwrap();
689 assert_eq!(reviews.len(), 2);
690 }
691
692 #[tokio::test]
693 async fn checkout_spec_names_fork_remote_after_owner() {
694 let server = MockServer::start().await;
695 Mock::given(method("GET"))
696 .and(path("/repos/mozilla/gecko-dev/pulls/123"))
697 .respond_with(ResponseTemplate::new(200).set_body_json(pull_json(
698 "mozilla",
699 "gecko-dev",
700 123,
701 Some("alice"),
702 )))
703 .mount(&server)
704 .await;
705
706 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
707 let review = Review {
708 key: ReviewKey::new("gh", "mozilla/gecko-dev/123"),
709 title: "x".into(),
710 author: "author".into(),
711 url: "https://github.com/mozilla/gecko-dev/pull/123".into(),
712 repo: RepoRef {
713 urls: vec![],
714 display_name: "mozilla/gecko-dev".into(),
715 },
716 version: "deadbeef".into(),
717 kind: ReviewKind::Direct,
718 diff_stat: None,
719 description: None,
720 ancestors: Vec::new(),
721 };
722 let checkout = src
723 .checkout_spec(&review, std::path::Path::new("/tmp/unused"))
724 .await
725 .unwrap();
726 match checkout {
727 Checkout::Ref {
728 refspec,
729 commit,
730 fork,
731 } => {
732 assert_eq!(refspec, "refs/pull/123/head");
733 assert_eq!(commit, "deadbeef");
734 let fork = fork.unwrap();
735 assert_eq!(fork.remote_name, "alice");
736 assert_eq!(fork.branch, "feature");
737 }
738 _ => panic!("expected a Ref checkout"),
739 }
740 }
741
742 #[tokio::test]
743 async fn checkout_spec_uses_origin_for_same_repo_branch_pr() {
744 let server = MockServer::start().await;
745 Mock::given(method("GET"))
747 .and(path("/repos/mozilla/gecko-dev/pulls/123"))
748 .respond_with(ResponseTemplate::new(200).set_body_json(pull_json(
749 "mozilla",
750 "gecko-dev",
751 123,
752 Some("mozilla"),
753 )))
754 .mount(&server)
755 .await;
756
757 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
758 let review = Review {
759 key: ReviewKey::new("gh", "mozilla/gecko-dev/123"),
760 title: "x".into(),
761 author: "author".into(),
762 url: "https://github.com/mozilla/gecko-dev/pull/123".into(),
763 repo: RepoRef {
764 urls: vec![],
765 display_name: "mozilla/gecko-dev".into(),
766 },
767 version: "deadbeef".into(),
768 kind: ReviewKind::Direct,
769 diff_stat: None,
770 description: None,
771 ancestors: Vec::new(),
772 };
773 let Checkout::Ref { fork, .. } = src
774 .checkout_spec(&review, std::path::Path::new("/tmp/unused"))
775 .await
776 .unwrap()
777 else {
778 panic!("expected Ref")
779 };
780 assert_eq!(fork.unwrap().remote_name, "origin");
781 }
782
783 #[tokio::test]
784 async fn checkout_spec_handles_deleted_fork() {
785 let server = MockServer::start().await;
786 Mock::given(method("GET"))
787 .and(path("/repos/mozilla/gecko-dev/pulls/123"))
788 .respond_with(ResponseTemplate::new(200).set_body_json(pull_json(
789 "mozilla",
790 "gecko-dev",
791 123,
792 None,
793 )))
794 .mount(&server)
795 .await;
796
797 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
798 let review = Review {
799 key: ReviewKey::new("gh", "mozilla/gecko-dev/123"),
800 title: "x".into(),
801 author: "author".into(),
802 url: "https://github.com/mozilla/gecko-dev/pull/123".into(),
803 repo: RepoRef {
804 urls: vec![],
805 display_name: "mozilla/gecko-dev".into(),
806 },
807 version: "deadbeef".into(),
808 kind: ReviewKind::Direct,
809 diff_stat: None,
810 description: None,
811 ancestors: Vec::new(),
812 };
813 let Checkout::Ref { fork, .. } = src
814 .checkout_spec(&review, std::path::Path::new("/tmp/unused"))
815 .await
816 .unwrap()
817 else {
818 panic!("expected Ref")
819 };
820 assert!(fork.is_none());
821 }
822
823 #[tokio::test]
824 async fn fetch_status_maps_closed_to_resolved() {
825 let server = MockServer::start().await;
826 Mock::given(method("GET"))
827 .and(path("/repos/mozilla/gecko-dev/pulls/1"))
828 .respond_with(ResponseTemplate::new(200).set_body_json(pull_json(
829 "mozilla",
830 "gecko-dev",
831 1,
832 None,
833 )))
834 .mount(&server)
835 .await;
836 Mock::given(method("GET"))
837 .and(path("/repos/mozilla/gecko-dev/pulls/2"))
838 .respond_with(ResponseTemplate::new(200).set_body_json({
839 let mut v = pull_json("mozilla", "gecko-dev", 2, None);
840 v["state"] = serde_json::json!("closed");
841 v
842 }))
843 .mount(&server)
844 .await;
845
846 let src = GithubSource::for_test(cfg(), Some("t".into()), server.uri(), SEARCH_PER_PAGE);
847 let ids = vec![
848 "mozilla/gecko-dev/1".to_string(),
849 "mozilla/gecko-dev/2".to_string(),
850 ];
851 let statuses = src.fetch_status(&ids).await.unwrap();
852
853 assert_eq!(
854 statuses[0],
855 ("mozilla/gecko-dev/1".to_string(), Lifecycle::Open)
856 );
857 assert_eq!(
858 statuses[1],
859 ("mozilla/gecko-dev/2".to_string(), Lifecycle::Resolved)
860 );
861 }
862
863 #[tokio::test]
864 async fn fetch_queue_without_token_is_an_error() {
865 let src = GithubSource::for_test(cfg(), None, DEFAULT_API_BASE.into(), SEARCH_PER_PAGE);
866 let err = src.fetch_queue().await.unwrap_err();
867 assert!(
868 err.to_string().contains("needs a token"),
869 "unexpected error: {err}"
870 );
871 }
872}