gitforge 0.2.0

uniform access to git forges (gitlab and github)
Documentation
// Copyright 2021 Citrix
// SPDX-License-Identifier: MIT OR Apache-2.0
// There is NO WARRANTY.

//! GitHub client, talking to one GitHub instance
//!
//! Based on the `octocrab` library, which we reexport in case you
//! need it.
//!
//! # Tokens
//!
//! The token is a **Personal access token**, as can be created via
//! the web UI:
//!  * Top right user icon menu: *Settings*
//!  * Left sidebar: *Developer settings*
//!  * From menu on the left: *Personal access tokens*
//!
//! # Limitations
//!
//! It is not possible to use the API to create a merge request where
//! the source and target repository leafnames (the `PROJECT` part of
//! `USER/PROJECT`) are not the same.  This is a GitHub API limitation.
//! It appears to be possible to create such MRs via the GitHub web UI,
//! so programs must be prepared to deal with them.
//!
//! # Terminology and model - supplementary
//!
//! * **Repository**: a GitHub **project**.  Identified by the
//!   string `NAMESPACE/PROJECT` eg `USER/PROJECT`.
//!
//! * **Merge Request**. a GitHub **pull request**.  GitHub has
//!   confusing terminology: the `gitforge` **source** is the GitHub
//!   **head** and the `gitforge` *target* is the GitHub **base**.
//!
//! * **User**: a GitHub **user** or **organisation.  Identified by the
//!   user or organisation slug.

/// Re-export of the Octocrab GitHub-specific client library
pub use octocrab;

use crate::prelude::*;
use octocrab::Octocrab;

/// GitHub client, as `Forge`.  Use `Box<dyn Forge>` instead.
pub struct Hub {
  host: String,
  oc: Octocrab,
  tok: tokio::runtime::Runtime,
}
impl Debug for Hub {
  #[throws(fmt::Error)]
  fn fmt(&self, f: &mut fmt::Formatter) {
    f.debug_struct("Hub")
      .field("oc", &self.oc)
      .field("tok", &format_args!("{{..tokio..}}"))
      .finish()?
  }
}

impl TryFrom<Octocrab> for Hub {
  type Error = FE;
  #[throws(FE)]
  fn try_from(oc: Octocrab) -> Self {
    let host = (||{
      let url = oc.absolute_url("/").context("get base url")?;
      let domain = url.domain()
        .ok_or_else(|| anyhow!("no domain (IP address?"))
        .with_context(|| format!("{:?}", &url))?;
      let host = domain.strip_prefix("api.").unwrap_or(domain);
      Ok::<_,AE>(host.to_owned())
    })()
      .context("determine canonical hostname")
      .map_err(FE::ClientCreationFailed)?;

    let tok = tokio::runtime::Runtime::new()
      .context("create").map_err(FE::Async)?;
    Hub { host, oc, tok }
  }
}

impl Hub {
  /// Create a new GitHub client.  Prefer `forge::Config::new()`
  ///
  /// This call is primarily provided for internal use.
  /// Call it yourself only if you want to hardcode use of GitHub.
  #[throws(FE)]
  pub fn new(config: &forge::Config) -> Box<dyn Forge> {
    let mut oc = Octocrab::builder();
    if let Some(Token(token)) = config.get_token()? {
      oc = oc.personal_token(token.into());
    }
    if config.host != "github.com" {
      oc = oc.base_url(format!("https://{}", config.host))
        .context("set url")
        .map_err(FE::ClientCreationFailed)?;
    }
    let oc = oc.build()
      .context("build Octocrab client")
      .map_err(FE::ClientCreationFailed)?;

    let oc: Hub = oc.try_into()?;
    Box::new(oc) as _
  }
}

#[throws(FE)]
fn repo_parse(frepo: &str) -> (&str /*gh owner*/,  &str /*gh owner */) {
  frepo.split_once('/')
    .ok_or_else(|| FE::InvalidObjectSyntax(
      RemoteObjectKind::Repo,
      "github repo must be <owner>/<repo>".into(),
      frepo.into()
    ))?
}

impl ForgeMethods for Hub {
  fn clear_id_caches(&mut self) { }

  fn host(&self) -> &str { &self.host }
  fn kind(&self) -> Kind { Kind::GitHub }

  #[throws(FE)]
  fn request(&mut self, req: &Req) -> Resp {
    macro_rules! make_request {
      ($future:expr) => {
        self.tok.block_on($future)
          .with_context(|| format!("{:?}", req))
          .map_err(FE::UncleassifiedOperationError)
      }
    }

    match req {

      Req::MergeRequests(q) => {
        let (t_owner, t_repo) = repo_parse(&q.target_repo)?;

        debug!("MergeRequests t_owner={:?} t_repo={:?} query {:?}",
               &t_owner, &t_repo, &q);

        let d = self.oc.pulls(t_owner, t_repo);

        let d = if let Some(number) = &q.number {

          let number = number.parse().map_err(
            |e: ParseIntError| FE::InvalidIdSyntax(
              RemoteObjectKind::MergeReq,
              e.to_string(),
              number.clone(),
            ))?;

          let d = make_request!( d.get(number) )?;

          debug!("MergeRequests reply {:?}", &d);

          vec![d]

        } else {

          let mut d = d.list();

          d = d.state({
            use IssueMrStatus as F;
            use octocrab::params::State as G;
            let some_want = |m: &dyn Fn(_) -> bool| match &q.statuses {
              None => true,
              Some(states) => states.iter().cloned().any(m),
            };
            let want_open = some_want(
              &|st| matches!(st, F::Open)
            );
            let want_closed = some_want(
              &|st| matches!(st, F::Closed | F::Merged)
            );
            match (want_open, want_closed) {
              (false,false) => return Resp::MergeRequests { mrs: vec![] },
              (false,true ) => G::Closed,
              (true, false) => G::Open,
              (true, true ) => G::All,
            }
          });

          if let (Some(source_repo), Some(source_branch)) =
                  (&q.source_repo,   &q.source_branch)
          {
            d = d.head(format!("{}:{}",
                               repo_parse(source_repo)?.0,
                               source_branch));
          }

          if let Some(target_branch) = &q.target_branch {
            d = d.base(target_branch);
          }

          let d = make_request!( d.send() )?;

          debug!("MergeRequests reply {:?}", &d);

          if d.incomplete_results == Some(true) ||
            d.next.is_some() { throw!(FE::TooManyResults); }

          d.items

        };

        let mrs = d.into_iter().map(|g| {
          let number = &g.number;

          macro_rules! some_repo { { $what_repo:ident = $field:ident } => {
            let $what_repo = g.$field.repo.ok_or_else(
              || anyhow!("missing {} for #{}", stringify!($field), number)
            )?.full_name;
          } }
          some_repo!{ target_repo = base }
          some_repo!{ source_repo = head }

          Ok::<_,AE>(Resp_MergeRequest {
            number: number.to_string(),
            author: g.user.login,
            state: IssueMrState {
              locked: if g.locked { IssueMrLocked::Locked }
                             else { IssueMrLocked::Unlocked },
              status: {
                use IssueMrStatus as F;
                use octocrab::models::IssueState as G;

                match (g.state, &g.merged_at) {
                  (G::Closed, None,   ) => F::Closed,
                  (G::Closed, Some(_),) => F::Merged,
                  (G::Open  , None,   ) => F::Open,
                  (G::Open  , Some(_),) => {
                    info!("mapping MR {:?} {:?} \
                           open + merged to Unrepresentable",
                          &target_repo, &g.number);
                    F::Unrepresentable
                  },
                  (gstate, _) => {
                    info!("mapping MR {:?} {:?} \
                           unknown state={:?} Unrepresentable",
                          &target_repo, &g.number, gstate);
                    F::Unrepresentable
                  },
                }
              },
            },
            target: RepoBranch {
              repo: target_repo,
              branch: g.base.ref_field,
            },
            source: RepoBranch {
              repo: source_repo,
              branch: g.head.ref_field,
            },
          })
        })
          .filter_ok(filter_mergerequests(&q))
          .collect::<Result<Vec<_>,_>>()
          .map_err(FE::ResultsProcessingFailed)?;
          
        Resp::MergeRequests { mrs }
      },

      Req::CreateMergeRequest(Req_CreateMergeRequest {
        title, description,
        target: RepoBranch { repo: target_repo, branch: target_branch },
        source: RepoBranch { repo: source_repo, branch: source_branch },
        _non_exhaustive, // we want to use *every* field
      }) => {
        let (t_owner, t_repo) = repo_parse(target_repo)?;
        let (s_owner, s_repo) = repo_parse(source_repo)?;

        debug!("MergeRequests query {:?} t_owner={:?} t_repo={:?} \
                                         s_owner={:?} s_repo={:?}",
               &t_owner, &t_repo,
               &s_owner, &s_repo, &req);

        // This does seem to be true.  I tried the syntax
        // <s_owner>/<s_repo>:<s_branch> for "head" and got
        // "Validation failed"
        if &s_repo != &t_repo { throw!(FE::UnsupportedOperation(anyhow!(
          "github octocrab cannot make an MR when \
           source and target repos have different names :-( \
           source={:?} != target={:?}",
          &s_repo, &t_repo
        ))) }

        let d = self.oc.pulls(t_owner, t_repo);
        let d = d.create(
          title,
          format!("{}:{}", &s_owner, source_branch),
          target_branch,
        );
        let d = d.body(description);

        let d = make_request!( d.send() )?;

        Resp::CreateMergeRequest {
          number: d.number.to_string(),
        }
      },

      q@ Req::_NonExhaustive() => panic!("bad request {:?}", q),
    }
  }
}