gitforge/
lab.rs

1// Copyright 2021 Citrix
2// SPDX-License-Identifier: MIT OR Apache-2.0
3// There is NO WARRANTY.
4
5//! GitLab client, talking to one GitLab instance
6//!
7//! # Limitations
8//!
9//! Anonymous access is typically not supported.  You will need
10//! to use a token.
11//!
12//! # Tokens
13//!
14//! The token is a **Personal access token**, as can be created via
15//! the web UI:
16//!  * Top right user icon menu: *Preferences*
17//!  * Left sidebar: *Access tokens* (an oval icon with dots in)
18//!
19//! # Terminology and model - supplementary
20//!
21//! * **Repository**: a GitLab **project**.  Identified by the
22//!   string `USER/PROJECT` or `GROUP/PROJECT`.
23//!
24//! * **User**: a GitLab **user** or **organisation** or perhaps
25//!   **group**.   Identified by the user or organisation slug.
26//!
27//! # Example
28//!
29//! ```
30//! use gitforge::forge;
31//!
32//! let mut f = match (forge::Config {
33//!   kind: "gitlab".parse().ok(),
34//!   host: "salsa.debian.org".into(),
35//!   ..Default::default()
36//! }
37//!   .load_default_token().unwrap()
38//!   .forge()
39//! ){
40//!   Err(forge::Error::TokenAlwaysRequired(_)) => {
41//!     eprintln!("token not supplied, not running gitlab client test");
42//!     return;
43//!   },
44//!   other => other.unwrap(),
45//! };
46//!
47//! let req = forge::Req::MergeRequests(forge::Req_MergeRequests{
48//!   target_repo: "dgit-team/dgit-test-dummy".into(),
49//!   statuses: Some([forge::IssueMrStatus::Open].iter().cloned().collect()),
50//!   ..Default::default()
51//! });
52//!
53//! match f.request(&req).unwrap() {
54//!   forge::Resp::MergeRequests { mrs,.. } => {
55//!     for mr in mrs {
56//!       println!("{:?}", &mr);
57//!     }
58//!   },
59//!   x => panic!("unexpected response {:?}", &x),
60//! };
61//! ```
62
63use crate::prelude::*;
64use crate::forge::RepoBranch; // gitlab has a RepoBranch too
65
66use gitlab_crate as gitlab;
67
68use gitlab::*;
69use gitlab::api::Query as _;
70
71/// GitLab client, as `Forge`.  Use `Box<dyn Forge>` instead.
72#[derive(Debug)]
73pub struct Lab {
74  host: String,
75  gl: gitlab::Gitlab,
76  cache_user: IdCacheData<Lab, UserId>,
77  cache_proj: IdCacheData<Lab, ProjectId>,
78}
79
80impl From<(Gitlab, String)> for Lab {
81  fn from((gl, host): (Gitlab, String)) -> Self { Lab {
82    gl, host,
83    cache_user: default(),
84    cache_proj: default(),
85  } }
86}
87
88impl Lab {
89  /// Create a new GitLab client.  Prefer `forge::Config::new()`
90  ///
91  /// This call is primarily provided for internal use.
92  /// Call it yourself only if you want to hardcode use of GitLab.
93  #[throws(FE)]
94  pub fn new(config: &forge::Config) -> Box<dyn Forge> {
95    let gl = gitlab::Gitlab::new(
96      config.host.clone(),
97      config.get_token()?.ok_or_else(
98        || FE::TokenAlwaysRequired(Kind::GitLab)
99      )?.0,
100    ).map_err(|e| FE::ClientCreationFailed(e.into()))?;
101    Box::new(Lab::from((gl, config.host.clone()))) as _
102  }
103}
104
105impl TryFrom<IssueMrStatus> for
106  gitlab::api::projects::merge_requests::MergeRequestState
107{
108  type Error = FE;
109  #[throws(FE)]
110  fn try_from(st: IssueMrStatus) -> Self {
111    use IssueMrStatus as F;
112    use gitlab::api::projects::merge_requests::MergeRequestState as G;
113    match st {
114      F::Open   => G::Opened,
115      F::Closed => G::Closed,
116      F::Merged => G::Merged,
117      F::Unrepresentable => throw!(FE::UnsupportedState(
118        RemoteObjectKind::MergeReq,
119        format!("{:?}", st),
120      )),
121    }
122  }
123}
124
125impl ForgeMethods for Lab {
126  fn clear_id_caches(&mut self) {
127    self.cache_user.clear();
128    self.cache_proj.clear();
129  }
130
131  fn host(&self) -> &str { &self.host }
132  fn kind(&self) -> Kind { Kind::GitLab }
133
134  #[throws(FE)]
135  fn request(&mut self, req: &Req) -> Resp {
136    let req_dbg = || format!("{:?}", req);
137    let e_build = |e:String|
138      FE::OperationBuildFailed(anyhow!(e).context(req_dbg()));
139    let e_query = |e|
140      FE::UncleassifiedOperationError(AE::new(e).context(req_dbg()));
141    let e_process = |e|
142      FE::ResultsProcessingFailed(AE::new(e).context(req_dbg()));
143
144    match req {
145
146      Req::MergeRequests(q) => {
147        let target_project: ProjectId =
148          self.name2id_required(&q.target_repo)?;
149        let target_project = target_project.value();
150
151        let d = if let Some(number) = &q.number {
152          let number: u64 = number.parse().map_err(
153            |e: ParseIntError| FE::InvalidIdSyntax(
154              RemoteObjectKind::MergeReq, number.into(), e.to_string()
155            ))?;
156
157          let mut d = api::projects::merge_requests::MergeRequest::builder();
158          d.project(target_project);
159          d.merge_request(number);
160
161          let d = d.build().map_err(e_build)?;
162
163          debug!("MergeRequests query {:?}", &d);
164
165          let d: Option<gitlab::types::MergeRequest> =
166            d.query(&mut self.gl).map_err(e_query)?;
167
168          d.into_iter().collect_vec()
169
170        } else {
171
172          let mut d = api::projects::merge_requests::MergeRequests::builder();
173
174          d.project(target_project);
175
176          if let Some(author) = &q.author {
177            let user: UserId = self.name2id_required(author)?;
178            d.author(user.value());
179          }
180
181          if let Some(statuses) = &q.statuses {
182            match statuses.iter().take(2).collect_vec().as_slice() {
183              [] => return Resp::MergeRequests { mrs: vec![] },
184              &[&state] => { d.state(state.try_into()?); },
185              [_,_,..] => { },
186            }
187          }
188
189          if let Some(source_branch) = &q.source_branch {
190            d.source_branch(source_branch);
191          }
192
193          d.with_merge_status_recheck(true);
194
195          let d = d.build().map_err(e_build)?;
196          debug!("MergeRequests query {:?}", &d);
197
198          let d = api::paged(d, api::Pagination::All);
199          let d: Vec<gitlab::types::MergeRequest> =
200            d.query(&mut self.gl).map_err(e_query)?;
201
202          d
203        };
204
205        debug!("MergeRequests reply {:?}", &d);
206
207        let mrs = d.into_iter().map(|g| Ok::<_,FE>(
208          Resp_MergeRequest {
209            number: g.iid.value().to_string(),
210            author: g.author.username,
211            state: IssueMrState {
212              locked: match g.discussion_locked {
213                None | Some(false) => IssueMrLocked::Unlocked,
214                Some(true)         => IssueMrLocked::Locked,
215              },
216              status: {
217                use IssueMrStatus as F;
218                if      let Some(_) = g.merged_at { F::Merged }
219                else if let Some(_) = g.closed_at { F::Merged }
220                else                              { F::Open   }
221              },
222            },
223            source: RepoBranch {
224              repo: self.id2name_required(g.source_project_id)?.to_string(),
225              branch: g.source_branch,
226            },
227            target: RepoBranch {
228              repo: self.id2name_required(g.target_project_id)?.to_string(),
229              branch: g.target_branch,
230            },
231          }
232        ))
233          .filter_ok(filter_mergerequests(&q))
234          .collect::<Result<Vec<_>,_>>()
235          .map_err(e_process)?;
236
237        Resp::MergeRequests { mrs }
238      }
239
240      Req::CreateMergeRequest(Req_CreateMergeRequest {
241        title, description,
242        target: RepoBranch { repo: target_repo, branch: target_branch },
243        source: RepoBranch { repo: source_repo, branch: source_branch },
244        _non_exhaustive, // we want to use *every* field
245      }) => {
246        let mut d = api::projects::merge_requests::CreateMergeRequest
247          ::builder();
248
249        let target_proj: ProjectId = self.name2id_required(&target_repo)?;
250        d.target_project_id(target_proj.value());
251        d.target_branch(target_branch);
252
253        let source_proj: ProjectId = self.name2id_required(&source_repo)?;
254        d.project(source_proj.value());
255        d.source_branch(source_branch);
256
257        d.title(title);
258        d.description(description);
259
260        let d = d.build().map_err(e_build)?;
261        debug!("CreateMergeRequest query {:?}", &d);
262
263        let d: gitlab::types::MergeRequest =
264          d.query(&mut self.gl).map_err(e_query)?;
265
266        Resp::CreateMergeRequest {
267          number: d.iid.value().to_string(),
268        }
269      }
270
271      q@ Req::_NonExhaustive() => panic!("bad request {:?}", q),
272    }
273  }
274}
275
276impl Lab {
277  #[throws(FE)]
278  fn idcache_some_lookup<BQ,Q,RR,PR,EC,O>(
279    &mut self,
280    query_what: &str,
281    error_context: EC,
282    build_query: BQ,
283    process_results: PR,
284  ) -> O
285  where
286    BQ: FnOnce() -> Result<Q, String>,
287    Q: gitlab::api::Endpoint,
288    PR: FnOnce(RR) -> Result<O, anyhow::Error>,
289    EC: FnOnce() -> String,
290    RR: DeserializeOwned,
291  {
292    (||{
293      let raw_results: RR =
294        build_query()
295        .map_err(|s| anyhow!("build {} query: {}", query_what, s))?
296        .query(&mut self.gl)
297        .with_context(|| format!("perform {} query", query_what))?;
298
299      let output = process_results(raw_results)?;
300      Ok::<_,AE>(output)
301    })()
302      .with_context(error_context)
303      .map_err(FE::AncillaryOperationFailed)?
304  }
305}
306
307impl IdCache<UserId> for Lab {
308  const UNKNOWN: RemoteObjectKind = RemoteObjectKind::User;
309
310  #[throws(FE)]
311  fn name2id_lookup(&mut self, username: &str) -> Option<UserId> {
312    self.idcache_some_lookup(
313      "User",
314      || format!("username {:?} lookup failed", username),
315      ||{
316        gitlab::api::users::Users::builder()
317          .username(username)
318          .build()
319      },
320      |user: Vec<UserBasic>| Ok::<_,AE>({
321        match user.as_slice() {
322          [user] => Some(user.id),
323          [] => None,
324          _ => throw!(anyhow!("multiple users found!")),
325        }
326      }),
327    )?
328  }
329  
330  #[throws(FE)]
331  fn id2name_lookup(&mut self, user: UserId) -> Option<String> {
332    self.idcache_some_lookup(
333      "User",
334      || format!("userid {:?} lookup failed", user),
335      || Ok(
336        gitlab::api::users::User::builder()
337          .user(user.value())
338          .build()?
339      ),
340      |ub: Option<UserBasic>| Ok(
341        ub.map(|ub| ub.name)
342      ),
343    )?
344  }
345
346  fn id_cache(&mut self) -> &mut IdCacheData<Self, UserId> {
347    &mut self.cache_user
348  }
349}
350 
351impl IdCache<ProjectId> for Lab {
352  const UNKNOWN: RemoteObjectKind = RemoteObjectKind::Repo;
353
354  #[throws(FE)]
355  fn name2id_lookup(&mut self, repo: &str) -> Option<ProjectId> {
356    self.idcache_some_lookup(
357      "Project",
358      || format!("repo (project) {:?} lookup failed", repo),
359      || Ok(
360        gitlab::api::projects::Project::builder()
361          .project(repo)
362          .build()?
363      ),
364      |proj: Option<Project>| Ok(
365        proj.map(|proj| proj.id)
366      ),
367    )?
368  }
369
370  #[throws(FE)]
371  fn id2name_lookup(&mut self, id: ProjectId) -> Option<String> {
372    self.idcache_some_lookup(
373      "Project",
374      || format!("repo (project) id {:?} lookup failed", id),
375      || Ok(
376        gitlab::api::projects::Project::builder()
377          .project(id.value())
378          .build()?
379      ),
380      |proj: Option<Project>| Ok(
381        proj.map(|proj| proj.path_with_namespace)
382      ),
383    )?
384  }
385
386  fn id_cache(&mut self) -> &mut IdCacheData<Self, ProjectId> {
387    &mut self.cache_proj
388  }
389}