Skip to main content

review_queue/source/
github.rs

1//! GitHub review source.
2//!
3//! Auth: config `token`, then `token_cmd`, then `$GITHUB_TOKEN`, then `gh auth token`. GitHub's
4//! search API requires auth to use the `@me` qualifier, so a missing token is a hard error here
5//! (unlike myqonly, which runs unauthenticated and accepts the rate limits).
6//!
7//! Queue: `GET /search/issues?q=review-requested:@me type:pr is:open archived:false`, plus the
8//! `ignore_repos`/`ignore_authors`/`ignore_teams`/`include_drafts` filters from
9//! [`crate::config::GithubConfig`] folded directly into `q` (mirroring myqonly's approach of
10//! letting GitHub's own search syntax do the filtering). Paginated. Each result's `html_url`
11//! (`https://github.com/{owner}/{repo}/pull/{number}`) gives an unambiguous review id -
12//! `{owner}/{repo}/{number}` - since owner/repo names can't themselves contain `/`. Then `GET
13//! /repos/{owner}/{repo}/pulls/{number}` for `head.sha`, `head.ref`, `head.repo.clone_url`,
14//! `head.repo.owner.login`, and `base.repo.clone_url`.
15//!
16//! Diffstat: `review_from_pull` also fetches (paginated) `GET
17//! /repos/{owner}/{repo}/pulls/{number}/files` for the per-file `filename`/`additions`/
18//! `deletions` breakdown - one extra request per review, paid during `rq sync` rather than by
19//! `rq show`'s TUI. If that call fails (rate limit, permissions), it falls back to the
20//! aggregate-only `additions`/`deletions`/`changed_files` already on the pull object fetched
21//! above (present only on the single-PR fetch, not the search results) rather than showing no
22//! diffstat at all.
23//!
24//! Checkout is `Checkout::Ref { refspec: "refs/pull/{n}/head", commit: head.sha, fork }`.
25//! `refspec` is fetched from the base repo (GitHub exposes `refs/pull/N/head` there for any PR),
26//! so the git backend never needs a remote for the fork. `fork` is `Some(ForkRef { remote_name:
27//! head.repo.owner.login, remote_url: head.repo.clone_url, branch: head.ref })`, used only by
28//! the jj backend - `remote_name` becomes `"origin"` instead when the fork URL normalizes to the
29//! canonical repo's own origin (a same-repo branch PR). `fork` is `None` when `head.repo` is
30//! null (source fork deleted). `checkout_spec`/`fetch_status` independently re-fetch the pull
31//! from its id rather than caching data from `fetch_queue`, so they stay correct even if called
32//! much later against stale state.
33//!
34//! `fetch_status`: `GET /repos/{owner}/{repo}/pulls/{n}` for each tracked PR. `state == "closed"`
35//! (merged or not) counts as `Lifecycle::Resolved`.
36
37use 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;
51/// GitHub caps a PR's file list at 3000 entries across pages; 100/page keeps this well under
52/// the pagination loop's own 30-page ceiling even at that cap.
53const FILES_PER_PAGE: u32 = 100;
54
55/// This source's hardcoded id-namespace prefix - see [`crate::state::ReviewKey`].
56pub 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            // Fall back to the aggregate-only counts already on `pr` (present only on the
155            // single-PR fetch, not the search results) rather than showing no diffstat at all.
156            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    /// Per-file `filename`/`additions`/`deletions`, paginating `GET
176    /// /repos/{owner}/{repo}/pulls/{number}/files`.
177    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                // Deleted, transferred, or no longer accessible - don't track it forever.
290                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/// A 404 from the GitHub API, kept distinguishable from other request failures.
300#[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
311/// `token`, then `token_cmd`, then `$GITHUB_TOKEN`, then `gh auth token`.
312async 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
353/// `{owner}/{repo}/{number}` - unambiguous since owner/repo names can't contain `/`.
354fn 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
370/// `https://github.com/{owner}/{repo}/pull/{number}` -> `(owner, repo, number)`.
371fn 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    /// Only present on the single-PR fetch (`GET .../pulls/{number}`), not the search results -
413    /// see `review_from_pull`.
414    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        // `/files` isn't mocked in this test, so this exercises the fallback path - it happens to
575        // match `pull_json`'s aggregate fields exactly, which is what
576        // `diff_stat_falls_back_to_the_aggregate_when_the_files_endpoint_fails` asserts on
577        // directly; `diff_stat_prefers_the_full_per_file_breakdown` covers the primary path.
578        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        // Differs from `pull_json`'s aggregate fields (2 files/3+/1-) - proves this came from
609        // `/files`, not the fallback.
610        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        // per_page=1 so each mocked page has exactly one item, forcing the loop to page 3.
687        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        // fork_owner == base owner: a branch-on-the-same-repo PR, not an actual fork.
746        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}