about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorAlyssa Ross <hi@alyssa.is>2021-02-14 11:57:45 +0000
committerAlyssa Ross <hi@alyssa.is>2021-02-17 15:16:29 +0000
commit6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4 (patch)
tree5955257a31295586dd2203137736693ae01068d9 /src
downloadpr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.gz
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.bz2
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.lz
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.xz
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.zst
pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.zip
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/branches.rs62
-rw-r--r--src/github.rs139
-rw-r--r--src/main.rs211
-rw-r--r--src/merge_commit.graphql15
-rw-r--r--src/nixpkgs.rs127
-rw-r--r--src/systemd.rs49
-rw-r--r--src/tree.rs91
7 files changed, 694 insertions, 0 deletions
diff --git a/src/branches.rs b/src/branches.rs
new file mode 100644
index 0000000..0e1e04f
--- /dev/null
+++ b/src/branches.rs
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+use std::borrow::Cow;
+use std::collections::BTreeMap;
+
+use once_cell::sync::Lazy;
+use regex::{Regex, RegexSet};
+
+const NEXT_BRANCH_TABLE: [(&str, &str); 8] = [
+    (r"\Astaging(-[\d.]+)?\z", "staging-next$1"),
+    (r"\Astaging-next\z", "master"),
+    (r"\Amaster\z", "nixpkgs-unstable"),
+    (r"\Amaster\z", "nixos-unstable-small"),
+    (r"\Anixos-(.*)-small\z", "nixos-$1"),
+    (r"\Arelease-([\d.]+)\z", "nixpkgs-$1-darwin"),
+    (r"\Arelease-([\d.]+)\z", "nixos-$1-small"),
+    (r"\Astaging-next-([\d.]*)\z", "release-$1"),
+];
+
+static BRANCH_NEXTS: Lazy<BTreeMap<&str, Vec<&str>>> = Lazy::new(|| {
+    NEXT_BRANCH_TABLE
+        .iter()
+        .fold(BTreeMap::new(), |mut map, (pattern, next)| {
+            map.entry(pattern).or_insert_with(Vec::new).push(next);
+            map
+        })
+});
+
+static BRANCH_NEXTS_BY_INDEX: Lazy<Vec<&Vec<&str>>> = Lazy::new(|| BRANCH_NEXTS.values().collect());
+
+static BRANCH_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
+    BRANCH_NEXTS
+        .keys()
+        .copied()
+        .map(Regex::new)
+        .map(Result::unwrap)
+        .collect()
+});
+
+static BRANCH_REGEXES: Lazy<RegexSet> = Lazy::new(|| RegexSet::new(BRANCH_NEXTS.keys()).unwrap());
+
+pub fn next_branches(branch: &str) -> Vec<Cow<str>> {
+    BRANCH_REGEXES
+        .matches(branch)
+        .iter()
+        .flat_map(|index| {
+            let regex = BRANCH_PATTERNS.get(index).unwrap();
+            BRANCH_NEXTS_BY_INDEX
+                .get(index)
+                .unwrap()
+                .iter()
+                .map(move |next| regex.replace(branch, *next))
+        })
+        .collect()
+}
+
+#[test]
+fn test_next_branches() {
+    let res = next_branches("release-20.09");
+    assert_eq!(res, vec!["nixpkgs-20.09-darwin", "nixos-20.09-small"])
+}
diff --git a/src/github.rs b/src/github.rs
new file mode 100644
index 0000000..dc7c8e5
--- /dev/null
+++ b/src/github.rs
@@ -0,0 +1,139 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+use std::ffi::OsStr;
+use std::fmt::{self, Display, Formatter};
+use std::os::unix::ffi::OsStrExt;
+
+use graphql_client::GraphQLQuery;
+use serde::Deserialize;
+use surf::http::headers::HeaderValue;
+use surf::StatusCode;
+
+type GitObjectID = String;
+
+#[derive(Debug)]
+pub enum Error {
+    NotFound,
+    Serialization(serde_json::Error),
+    Request(surf::Error),
+    Response(StatusCode),
+    Deserialization(http_types::Error),
+}
+
+impl Display for Error {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        use Error::*;
+        match self {
+            NotFound => write!(f, "Not found"),
+            Serialization(e) => write!(f, "Serialization error: {}", e),
+            Request(e) => write!(f, "Request error: {}", e),
+            Response(s) => write!(f, "Unexpected response status: {}", s),
+            Deserialization(e) => write!(f, "Deserialization error: {}", e),
+        }
+    }
+}
+
+impl std::error::Error for Error {}
+
+#[derive(GraphQLQuery)]
+#[graphql(
+    schema_path = "vendor/github_schema.graphql",
+    query_path = "src/merge_commit.graphql",
+    response_derives = "Debug"
+)]
+struct MergeCommitQuery;
+
+#[derive(Debug, Deserialize)]
+struct GitHubGraphQLResponse<D> {
+    data: D,
+}
+
+#[derive(Debug)]
+pub enum PullRequestStatus {
+    Open,
+    Closed,
+    Merged {
+        /// This field is optional because GitHub doesn't provide us with this information
+        /// for PRs merged before around March 2016.
+        merge_commit_oid: Option<String>,
+    },
+}
+
+#[derive(Debug)]
+pub struct MergeInfo {
+    pub branch: String,
+    pub status: PullRequestStatus,
+}
+
+pub struct GitHub<'a> {
+    token: &'a OsStr,
+    user_agent: &'a OsStr,
+}
+
+impl<'a> GitHub<'a> {
+    pub fn new(token: &'a OsStr, user_agent: &'a OsStr) -> Self {
+        Self { token, user_agent }
+    }
+
+    fn authorization_header(&self) -> Result<HeaderValue, surf::Error> {
+        let mut value = b"bearer ".to_vec();
+        value.extend_from_slice(self.token.as_bytes());
+        Ok(HeaderValue::from_bytes(value)?)
+    }
+
+    pub async fn merge_info_for_nixpkgs_pr(&self, pr: i64) -> Result<MergeInfo, Error> {
+        let query = MergeCommitQuery::build_query(merge_commit_query::Variables {
+            owner: "NixOS".to_string(),
+            repo: "nixpkgs".to_string(),
+            number: pr,
+        });
+
+        let response = surf::post("https://api.github.com/graphql")
+            .header("Accept", "application/vnd.github.merge-info-preview+json")
+            .header(
+                "User-Agent",
+                HeaderValue::from_bytes(self.user_agent.as_bytes().to_vec())
+                    .map_err(Error::Request)?,
+            )
+            .header(
+                "Authorization",
+                self.authorization_header().map_err(Error::Request)?,
+            )
+            .body(serde_json::to_vec(&query).map_err(Error::Serialization)?)
+            .send()
+            .await
+            .map_err(Error::Request)?;
+
+        let status = response.status();
+        if status == StatusCode::NotFound || status == StatusCode::Gone {
+            return Err(Error::NotFound);
+        } else if !status.is_success() {
+            return Err(Error::Response(status));
+        }
+
+        let data: GitHubGraphQLResponse<merge_commit_query::ResponseData> = dbg!(response)
+            .body_json()
+            .await
+            .map_err(Error::Deserialization)?;
+
+        let pr = data
+            .data
+            .repository
+            .and_then(|repo| repo.pull_request)
+            .ok_or(Error::NotFound)?;
+
+        Ok(MergeInfo {
+            branch: pr.base_ref_name,
+            status: if pr.merged {
+                PullRequestStatus::Merged {
+                    merge_commit_oid: pr.merge_commit.map(|commit| commit.oid),
+                }
+            } else if pr.closed {
+                PullRequestStatus::Closed
+            } else {
+                PullRequestStatus::Open
+            },
+        })
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..22023a8
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,211 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+mod branches;
+mod github;
+mod nixpkgs;
+mod systemd;
+mod tree;
+
+use std::ffi::OsString;
+use std::path::PathBuf;
+
+use askama::Template;
+use async_std::io;
+use async_std::net::TcpListener;
+use async_std::os::unix::io::FromRawFd;
+use async_std::os::unix::net::UnixListener;
+use async_std::pin::Pin;
+use async_std::prelude::*;
+use async_std::process::exit;
+use futures_util::future::join_all;
+use http_types::mime;
+use once_cell::sync::Lazy;
+use serde::Deserialize;
+use structopt::StructOpt;
+use tide::{Request, Response};
+
+use github::{GitHub, PullRequestStatus};
+use nixpkgs::Nixpkgs;
+use systemd::{is_socket_inet, is_socket_unix, listen_fds};
+use tree::Tree;
+
+#[derive(StructOpt, Debug)]
+struct Config {
+    #[structopt(long, parse(from_os_str))]
+    path: PathBuf,
+
+    #[structopt(long, parse(from_os_str))]
+    remote: PathBuf,
+
+    #[structopt(long, parse(from_os_str))]
+    user_agent: OsString,
+
+    #[structopt(long)]
+    source_url: String,
+
+    #[structopt(long, default_value = "/")]
+    mount: String,
+}
+
+static CONFIG: Lazy<Config> = Lazy::new(Config::from_args);
+
+static GITHUB_TOKEN: Lazy<OsString> = Lazy::new(|| {
+    use std::io::{stdin, BufRead, BufReader};
+    use std::os::unix::prelude::*;
+
+    let mut bytes = Vec::with_capacity(41);
+    if let Err(e) = BufReader::new(stdin()).read_until(b'\n', &mut bytes) {
+        eprintln!("pr-tracker: read: {}", e);
+        exit(74)
+    }
+    if bytes.last() == Some(&b'\n') {
+        bytes.pop();
+    }
+    OsString::from_vec(bytes)
+});
+
+#[derive(Debug, Default, Template)]
+#[template(path = "page.html")]
+struct PageTemplate {
+    error: Option<String>,
+    pr_number: Option<String>,
+    closed: bool,
+    tree: Option<Tree>,
+    source_url: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct Query {
+    pr: Option<String>,
+}
+
+async fn track_pr(pr_number: Option<String>, status: &mut u16, page: &mut PageTemplate) {
+    let pr_number = match pr_number {
+        Some(pr_number) => pr_number,
+        None => return,
+    };
+
+    let pr_number_i64 = match pr_number.parse() {
+        Ok(n) => n,
+        Err(_) => {
+            *status = 400;
+            page.error = Some(format!("Invalid PR number: {}", pr_number));
+            return;
+        }
+    };
+
+    let github = GitHub::new(&GITHUB_TOKEN, &CONFIG.user_agent);
+
+    let merge_info = match github.merge_info_for_nixpkgs_pr(pr_number_i64).await {
+        Err(github::Error::NotFound) => {
+            *status = 404;
+            page.error = Some(format!("No such nixpkgs PR #{}.", pr_number_i64));
+            return;
+        }
+
+        Err(e) => {
+            *status = 500;
+            page.error = Some(e.to_string());
+            return;
+        }
+
+        Ok(info) => info,
+    };
+
+    page.pr_number = Some(pr_number);
+
+    if matches!(merge_info.status, PullRequestStatus::Closed) {
+        page.closed = true;
+        return;
+    }
+
+    let nixpkgs = Nixpkgs::new(&CONFIG.path, &CONFIG.remote);
+    let tree = Tree::make(merge_info.branch.to_string(), &merge_info.status, &nixpkgs).await;
+
+    if let github::PullRequestStatus::Merged {
+        merge_commit_oid, ..
+    } = merge_info.status
+    {
+        if merge_commit_oid.is_none() {
+            page.error = Some("For older PRs, GitHub doesn't tell us the merge commit, so we're unable to track this PR past being merged.".to_string());
+        }
+    }
+
+    page.tree = Some(tree);
+}
+
+async fn handle_request<S>(request: Request<S>) -> http_types::Result<Response> {
+    let mut status = 200;
+    let mut page = PageTemplate {
+        source_url: CONFIG.source_url.clone(),
+        ..Default::default()
+    };
+
+    let pr_number = request.query::<Query>()?.pr;
+
+    track_pr(pr_number, &mut status, &mut page).await;
+
+    Ok(Response::builder(status)
+        .content_type(mime::HTML)
+        .body(page.render()?)
+        .build())
+}
+
+#[async_std::main]
+async fn main() {
+    fn handle_error<T, E>(result: Result<T, E>, code: i32, message: impl AsRef<str>) -> T
+    where
+        E: std::error::Error,
+    {
+        match result {
+            Ok(v) => return v,
+            Err(e) => {
+                eprintln!("pr-tracker: {}: {}", message.as_ref(), e);
+                exit(code);
+            }
+        }
+    }
+
+    // Make sure arguments are parsed before starting server.
+    let _ = *CONFIG;
+    let _ = *GITHUB_TOKEN;
+
+    let mut server = tide::new();
+    let mut root = server.at(&CONFIG.mount);
+
+    root.at("/").get(handle_request);
+
+    let fd_count = handle_error(listen_fds(true), 71, "sd_listen_fds");
+
+    if fd_count == 0 {
+        eprintln!("pr-tracker: No listen file descriptors given");
+        exit(64);
+    }
+
+    let mut listeners: Vec<Pin<Box<dyn Future<Output = _>>>> = Vec::new();
+
+    for fd in (3..).into_iter().take(fd_count as usize) {
+        let s = server.clone();
+        if handle_error(is_socket_inet(fd), 74, "sd_is_socket_inet") {
+            listeners.push(Box::pin(s.listen(unsafe { TcpListener::from_raw_fd(fd) })));
+        } else if handle_error(is_socket_unix(fd), 74, "sd_is_socket_unix") {
+            listeners.push(Box::pin(s.listen(unsafe { UnixListener::from_raw_fd(fd) })));
+        } else {
+            eprintln!("pr-tracker: file descriptor {} is not a socket", fd);
+            exit(64);
+        }
+    }
+
+    let errors: Vec<_> = join_all(listeners)
+        .await
+        .into_iter()
+        .filter_map(io::Result::err)
+        .collect();
+    for error in errors.iter() {
+        eprintln!("pr-tracker: listen: {}", error);
+    }
+    if !errors.is_empty() {
+        exit(74);
+    }
+}
diff --git a/src/merge_commit.graphql b/src/merge_commit.graphql
new file mode 100644
index 0000000..5df583a
--- /dev/null
+++ b/src/merge_commit.graphql
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+# SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+query MergeCommitQuery($owner: String!, $repo: String!, $number: Int!) {
+  repository(owner: $owner, name: $repo) {
+    pullRequest(number: $number) {
+      baseRefName
+      mergeCommit {
+        oid
+      }
+      merged
+      closed
+    }
+  }
+}
diff --git a/src/nixpkgs.rs b/src/nixpkgs.rs
new file mode 100644
index 0000000..7e622c2
--- /dev/null
+++ b/src/nixpkgs.rs
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+use std::collections::BTreeSet;
+use std::ffi::OsStr;
+use std::ffi::OsString;
+use std::fmt::{self, Display, Formatter};
+use std::os::unix::prelude::*;
+use std::path::{Path, PathBuf};
+use std::process::ExitStatus;
+
+use async_std::io;
+use async_std::process::{Command, Stdio};
+
+#[derive(Debug)]
+pub enum Error {
+    Io(io::Error),
+    ExitFailure(ExitStatus),
+}
+
+impl Display for Error {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        use Error::*;
+        match self {
+            Io(e) => write!(f, "git: {}", e),
+            ExitFailure(e) => match e.code() {
+                Some(code) => write!(f, "git exited {}", code),
+                None => write!(f, "git killed by signal {}", e.signal().unwrap()),
+            },
+        }
+    }
+}
+
+impl std::error::Error for Error {}
+
+type Result<T, E = Error> = std::result::Result<T, E>;
+
+fn check_status(status: ExitStatus) -> Result<()> {
+    if status.success() {
+        Ok(())
+    } else {
+        Err(Error::ExitFailure(status))
+    }
+}
+
+pub struct Nixpkgs<'a> {
+    path: &'a Path,
+    remote_name: &'a Path,
+}
+
+impl<'a> Nixpkgs<'a> {
+    pub fn new(path: &'a Path, remote_name: &'a Path) -> Self {
+        Self { path, remote_name }
+    }
+
+    fn git_command(&self, subcommand: impl AsRef<OsStr>) -> Command {
+        let mut command = Command::new("git");
+        command.arg("-C");
+        command.arg(&self.path);
+        command.arg(subcommand);
+        command
+    }
+
+    async fn git_branch_contains(&self, commit: &str) -> Result<Vec<u8>> {
+        let output = self
+            .git_command("branch")
+            .args(&["-r", "--format=%(refname)", "--contains"])
+            .arg(commit)
+            .stderr(Stdio::inherit())
+            .output()
+            .await
+            .map_err(Error::Io)?;
+
+        check_status(output.status)?;
+
+        Ok(output.stdout)
+    }
+
+    async fn git_fetch_nixpkgs(&self) -> Result<()> {
+        // TODO: add refspecs
+        self.git_command("fetch")
+            .arg(&self.remote_name)
+            .status()
+            .await
+            .map_err(Error::Io)
+            .and_then(check_status)
+    }
+
+    pub async fn branches_containing_commit(
+        &self,
+        commit: &str,
+        out: &mut BTreeSet<OsString>,
+    ) -> Result<()> {
+        let output = match self.git_branch_contains(commit).await {
+            Err(Error::ExitFailure(status)) if status.code().is_some() => {
+                eprintln!("pr-tracker: git branch --contains failed; updating branches");
+
+                if let Err(e) = self.git_fetch_nixpkgs().await {
+                    eprintln!("pr-tracker: fetching nixpkgs: {}", e);
+                    // Carry on, because it might have fetched what we
+                    // need before dying.
+                }
+
+                self.git_branch_contains(commit).await?
+            }
+
+            Ok(output) => output,
+            Err(e) => return Err(e),
+        };
+
+        let mut prefix = PathBuf::from("refs/remotes/");
+        prefix.push(&self.remote_name);
+
+        for branch_name in output
+            .split(|byte| *byte == b'\n')
+            .filter(|b| !b.is_empty())
+            .map(OsStr::from_bytes)
+            .map(Path::new)
+            .filter_map(|r| r.strip_prefix(&prefix).ok())
+            .map(Into::into)
+        {
+            out.insert(branch_name);
+        }
+
+        Ok(())
+    }
+}
diff --git a/src/systemd.rs b/src/systemd.rs
new file mode 100644
index 0000000..7bd2142
--- /dev/null
+++ b/src/systemd.rs
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+use std::io;
+use std::os::raw::{c_char, c_int, c_uint};
+use std::os::unix::prelude::*;
+use std::ptr::null;
+
+extern "C" {
+    fn sd_listen_fds(unset_environment: c_int) -> c_int;
+    fn sd_is_socket_inet(
+        fd: c_int,
+        family: c_int,
+        type_: c_int,
+        listening: c_int,
+        port: u16,
+    ) -> c_int;
+    fn sd_is_socket_unix(
+        fd: c_int,
+        type_: c_int,
+        listening: c_int,
+        path: *const c_char,
+        length: usize,
+    ) -> c_int;
+}
+
+pub fn listen_fds(unset_environment: bool) -> io::Result<c_uint> {
+    let r = unsafe { sd_listen_fds(if unset_environment { 1 } else { 0 }) };
+    if r < 0 {
+        return Err(io::Error::from_raw_os_error(-r));
+    }
+    Ok(r as c_uint)
+}
+
+pub fn is_socket_inet(fd: RawFd) -> io::Result<bool> {
+    let r = unsafe { sd_is_socket_inet(fd, 0, 0, -1, 0) };
+    if r < 0 {
+        return Err(io::Error::from_raw_os_error(-r));
+    }
+    Ok(r != 0)
+}
+
+pub fn is_socket_unix(fd: RawFd) -> io::Result<bool> {
+    let r = unsafe { sd_is_socket_unix(fd, 0, -1, null(), 0) };
+    if r < 0 {
+        return Err(io::Error::from_raw_os_error(-r));
+    }
+    Ok(r != 0)
+}
diff --git a/src/tree.rs b/src/tree.rs
new file mode 100644
index 0000000..f569dba
--- /dev/null
+++ b/src/tree.rs
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
+// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+use std::collections::BTreeSet;
+use std::ffi::{OsStr, OsString};
+
+use askama::Template;
+
+use crate::branches::next_branches;
+use crate::github;
+use crate::nixpkgs::Nixpkgs;
+
+#[derive(Debug, Template)]
+#[template(path = "tree.html")]
+pub struct Tree {
+    branch_name: String,
+    accepted: Option<bool>,
+    children: Vec<Tree>,
+}
+
+impl Tree {
+    fn generate(branch: String, found_branches: &mut BTreeSet<OsString>) -> Tree {
+        found_branches.insert((&branch).into());
+
+        let nexts = next_branches(&branch)
+            .into_iter()
+            .map(|b| Self::generate(b.to_string(), found_branches))
+            .collect();
+
+        Tree {
+            accepted: None,
+            branch_name: branch,
+            children: nexts,
+        }
+    }
+
+    fn fill_accepted(&mut self, branches: &BTreeSet<OsString>, missing_means_absent: bool) {
+        self.accepted = match branches.contains(OsStr::new(&self.branch_name)) {
+            true => Some(true),
+            false if missing_means_absent => Some(false),
+            false => None,
+        };
+
+        for child in self.children.iter_mut() {
+            child.fill_accepted(branches, missing_means_absent);
+        }
+    }
+
+    pub async fn make(base_branch: String, merge_status: &github::PullRequestStatus, nixpkgs: &Nixpkgs<'_>) -> Tree {
+        let mut missing_means_absent = true;
+        let mut branches = BTreeSet::new();
+
+        let mut tree = Self::generate(base_branch.clone(), &mut branches);
+
+        if let github::PullRequestStatus::Merged {
+            merge_commit_oid, ..
+        } = merge_status
+        {
+            if let Some(merge_commit) = merge_commit_oid {
+                let mut containing_commits = BTreeSet::new();
+
+                if let Err(e) =
+                    nixpkgs.branches_containing_commit(&merge_commit, &mut containing_commits)
+                        .await
+                {
+                    eprintln!("pr-tracker: branches_containing_commit: {}", e);
+                    missing_means_absent = false;
+                }
+
+                branches = branches
+                    .intersection(&containing_commits)
+                    .cloned()
+                    .collect();
+            } else {
+                branches.clear();
+                missing_means_absent = false;
+            }
+
+            // Even if something goes wrong with our local Git repo,
+            // or GitHub didn't tell us the merge commit, we know that
+            // the base branch of the PR must contain the commit,
+            // because GitHub told us it was merged into it.
+            branches.insert(base_branch.into());
+        } else {
+            branches.clear();
+        }
+
+        tree.fill_accepted(&branches, missing_means_absent);
+        tree
+    }
+}