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.

//! GitLab client, talking to one GitLab instance
//!
//! # Limitations
//!
//! Anonymous access is typically not supported.  You will need
//! to use a token.
//!
//! # Tokens
//!
//! The token is a **Personal access token**, as can be created via
//! the web UI:
//!  * Top right user icon menu: *Preferences*
//!  * Left sidebar: *Access tokens* (an oval icon with dots in)
//!
//! # Terminology and model - supplementary
//!
//! * **Repository**: a GitLab **project**.  Identified by the
//!   string `USER/PROJECT` or `GROUP/PROJECT`.
//!
//! * **User**: a GitLab **user** or **organisation** or perhaps
//!   **group**.   Identified by the user or organisation slug.
//!
//! # Example
//!
//! ```
//! use gitforge::forge;
//!
//! let mut f = match (forge::Config {
//!   kind: "gitlab".parse().ok(),
//!   host: "salsa.debian.org".into(),
//!   ..Default::default()
//! }
//!   .load_default_token().unwrap()
//!   .forge()
//! ){
//!   Err(forge::Error::TokenAlwaysRequired(_)) => {
//!     eprintln!("token not supplied, not running gitlab client test");
//!     return;
//!   },
//!   other => other.unwrap(),
//! };
//!
//! let req = forge::Req::MergeRequests(forge::Req_MergeRequests{
//!   target_repo: "dgit-team/dgit-test-dummy".into(),
//!   statuses: Some([forge::IssueMrStatus::Open].iter().cloned().collect()),
//!   ..Default::default()
//! });
//!
//! match f.request(&req).unwrap() {
//!   forge::Resp::MergeRequests { mrs,.. } => {
//!     for mr in mrs {
//!       println!("{:?}", &mr);
//!     }
//!   },
//!   x => panic!("unexpected response {:?}", &x),
//! };
//! ```

use crate::prelude::*;
use crate::forge::RepoBranch; // gitlab has a RepoBranch too

use gitlab_crate as gitlab;

use gitlab::*;
use gitlab::api::Query as _;

/// GitLab client, as `Forge`.  Use `Box<dyn Forge>` instead.
#[derive(Debug)]
pub struct Lab {
  host: String,
  gl: gitlab::Gitlab,
  cache_user: IdCacheData<Lab, UserId>,
  cache_proj: IdCacheData<Lab, ProjectId>,
}

impl From<(Gitlab, String)> for Lab {
  fn from((gl, host): (Gitlab, String)) -> Self { Lab {
    gl, host,
    cache_user: default(),
    cache_proj: default(),
  } }
}

impl Lab {
  /// Create a new GitLab client.  Prefer `forge::Config::new()`
  ///
  /// This call is primarily provided for internal use.
  /// Call it yourself only if you want to hardcode use of GitLab.
  #[throws(FE)]
  pub fn new(config: &forge::Config) -> Box<dyn Forge> {
    let gl = gitlab::Gitlab::new(
      config.host.clone(),
      config.get_token()?.ok_or_else(
        || FE::TokenAlwaysRequired(Kind::GitLab)
      )?.0,
    ).map_err(|e| FE::ClientCreationFailed(e.into()))?;
    Box::new(Lab::from((gl, config.host.clone()))) as _
  }
}

impl TryFrom<IssueMrStatus> for
  gitlab::api::projects::merge_requests::MergeRequestState
{
  type Error = FE;
  #[throws(FE)]
  fn try_from(st: IssueMrStatus) -> Self {
    use IssueMrStatus as F;
    use gitlab::api::projects::merge_requests::MergeRequestState as G;
    match st {
      F::Open   => G::Opened,
      F::Closed => G::Closed,
      F::Merged => G::Merged,
      F::Unrepresentable => throw!(FE::UnsupportedState(
        RemoteObjectKind::MergeReq,
        format!("{:?}", st),
      )),
    }
  }
}

impl ForgeMethods for Lab {
  fn clear_id_caches(&mut self) {
    self.cache_user.clear();
    self.cache_proj.clear();
  }

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

  #[throws(FE)]
  fn request(&mut self, req: &Req) -> Resp {
    let req_dbg = || format!("{:?}", req);
    let e_build = |e:String|
      FE::OperationBuildFailed(anyhow!(e).context(req_dbg()));
    let e_query = |e|
      FE::UncleassifiedOperationError(AE::new(e).context(req_dbg()));
    let e_process = |e|
      FE::ResultsProcessingFailed(AE::new(e).context(req_dbg()));

    match req {

      Req::MergeRequests(q) => {
        let target_project: ProjectId =
          self.name2id_required(&q.target_repo)?;
        let target_project = target_project.value();

        let d = if let Some(number) = &q.number {
          let number: u64 = number.parse().map_err(
            |e: ParseIntError| FE::InvalidIdSyntax(
              RemoteObjectKind::MergeReq, number.into(), e.to_string()
            ))?;

          let mut d = api::projects::merge_requests::MergeRequest::builder();
          d.project(target_project);
          d.merge_request(number);

          let d = d.build().map_err(e_build)?;

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

          let d: Option<gitlab::types::MergeRequest> =
            d.query(&mut self.gl).map_err(e_query)?;

          d.into_iter().collect_vec()

        } else {

          let mut d = api::projects::merge_requests::MergeRequests::builder();

          d.project(target_project);

          if let Some(author) = &q.author {
            let user: UserId = self.name2id_required(author)?;
            d.author(user.value());
          }

          if let Some(statuses) = &q.statuses {
            match statuses.iter().take(2).collect_vec().as_slice() {
              [] => return Resp::MergeRequests { mrs: vec![] },
              &[&state] => { d.state(state.try_into()?); },
              [_,_,..] => { },
            }
          }

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

          d.with_merge_status_recheck(true);

          let d = d.build().map_err(e_build)?;
          debug!("MergeRequests query {:?}", &d);

          let d = api::paged(d, api::Pagination::All);
          let d: Vec<gitlab::types::MergeRequest> =
            d.query(&mut self.gl).map_err(e_query)?;

          d
        };

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

        let mrs = d.into_iter().map(|g| Ok::<_,FE>(
          Resp_MergeRequest {
            number: g.iid.value().to_string(),
            author: g.author.username,
            state: IssueMrState {
              locked: match g.discussion_locked {
                None | Some(false) => IssueMrLocked::Unlocked,
                Some(true)         => IssueMrLocked::Locked,
              },
              status: {
                use IssueMrStatus as F;
                if      let Some(_) = g.merged_at { F::Merged }
                else if let Some(_) = g.closed_at { F::Merged }
                else                              { F::Open   }
              },
            },
            source: RepoBranch {
              repo: self.id2name_required(g.source_project_id)?.to_string(),
              branch: g.source_branch,
            },
            target: RepoBranch {
              repo: self.id2name_required(g.target_project_id)?.to_string(),
              branch: g.target_branch,
            },
          }
        ))
          .filter_ok(filter_mergerequests(&q))
          .collect::<Result<Vec<_>,_>>()
          .map_err(e_process)?;

        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 mut d = api::projects::merge_requests::CreateMergeRequest
          ::builder();

        let target_proj: ProjectId = self.name2id_required(&target_repo)?;
        d.target_project_id(target_proj.value());
        d.target_branch(target_branch);

        let source_proj: ProjectId = self.name2id_required(&source_repo)?;
        d.project(source_proj.value());
        d.source_branch(source_branch);

        d.title(title);
        d.description(description);

        let d = d.build().map_err(e_build)?;
        debug!("CreateMergeRequest query {:?}", &d);

        let d: gitlab::types::MergeRequest =
          d.query(&mut self.gl).map_err(e_query)?;

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

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

impl Lab {
  #[throws(FE)]
  fn idcache_some_lookup<BQ,Q,RR,PR,EC,O>(
    &mut self,
    query_what: &str,
    error_context: EC,
    build_query: BQ,
    process_results: PR,
  ) -> O
  where
    BQ: FnOnce() -> Result<Q, String>,
    Q: gitlab::api::Endpoint,
    PR: FnOnce(RR) -> Result<O, anyhow::Error>,
    EC: FnOnce() -> String,
    RR: DeserializeOwned,
  {
    (||{
      let raw_results: RR =
        build_query()
        .map_err(|s| anyhow!("build {} query: {}", query_what, s))?
        .query(&mut self.gl)
        .with_context(|| format!("perform {} query", query_what))?;

      let output = process_results(raw_results)?;
      Ok::<_,AE>(output)
    })()
      .with_context(error_context)
      .map_err(FE::AncillaryOperationFailed)?
  }
}

impl IdCache<UserId> for Lab {
  const UNKNOWN: RemoteObjectKind = RemoteObjectKind::User;

  #[throws(FE)]
  fn name2id_lookup(&mut self, username: &str) -> Option<UserId> {
    self.idcache_some_lookup(
      "User",
      || format!("username {:?} lookup failed", username),
      ||{
        gitlab::api::users::Users::builder()
          .username(username)
          .build()
      },
      |user: Vec<UserBasic>| Ok::<_,AE>({
        match user.as_slice() {
          [user] => Some(user.id),
          [] => None,
          _ => throw!(anyhow!("multiple users found!")),
        }
      }),
    )?
  }
  
  #[throws(FE)]
  fn id2name_lookup(&mut self, user: UserId) -> Option<String> {
    self.idcache_some_lookup(
      "User",
      || format!("userid {:?} lookup failed", user),
      || Ok(
        gitlab::api::users::User::builder()
          .user(user.value())
          .build()?
      ),
      |ub: Option<UserBasic>| Ok(
        ub.map(|ub| ub.name)
      ),
    )?
  }

  fn id_cache(&mut self) -> &mut IdCacheData<Self, UserId> {
    &mut self.cache_user
  }
}
 
impl IdCache<ProjectId> for Lab {
  const UNKNOWN: RemoteObjectKind = RemoteObjectKind::Repo;

  #[throws(FE)]
  fn name2id_lookup(&mut self, repo: &str) -> Option<ProjectId> {
    self.idcache_some_lookup(
      "Project",
      || format!("repo (project) {:?} lookup failed", repo),
      || Ok(
        gitlab::api::projects::Project::builder()
          .project(repo)
          .build()?
      ),
      |proj: Option<Project>| Ok(
        proj.map(|proj| proj.id)
      ),
    )?
  }

  #[throws(FE)]
  fn id2name_lookup(&mut self, id: ProjectId) -> Option<String> {
    self.idcache_some_lookup(
      "Project",
      || format!("repo (project) id {:?} lookup failed", id),
      || Ok(
        gitlab::api::projects::Project::builder()
          .project(id.value())
          .build()?
      ),
      |proj: Option<Project>| Ok(
        proj.map(|proj| proj.path_with_namespace)
      ),
    )?
  }

  fn id_cache(&mut self) -> &mut IdCacheData<Self, ProjectId> {
    &mut self.cache_proj
  }
}