gitforge/
hub.rs

1// Copyright 2021 Citrix
2// SPDX-License-Identifier: MIT OR Apache-2.0
3// There is NO WARRANTY.
4
5//! GitHub client, talking to one GitHub instance
6//!
7//! Based on the `octocrab` library, which we reexport in case you
8//! need it.
9//!
10//! # Tokens
11//!
12//! The token is a **Personal access token**, as can be created via
13//! the web UI:
14//!  * Top right user icon menu: *Settings*
15//!  * Left sidebar: *Developer settings*
16//!  * From menu on the left: *Personal access tokens*
17//!
18//! # Limitations
19//!
20//! It is not possible to use the API to create a merge request where
21//! the source and target repository leafnames (the `PROJECT` part of
22//! `USER/PROJECT`) are not the same.  This is a GitHub API limitation.
23//! It appears to be possible to create such MRs via the GitHub web UI,
24//! so programs must be prepared to deal with them.
25//!
26//! # Terminology and model - supplementary
27//!
28//! * **Repository**: a GitHub **project**.  Identified by the
29//!   string `NAMESPACE/PROJECT` eg `USER/PROJECT`.
30//!
31//! * **Merge Request**. a GitHub **pull request**.  GitHub has
32//!   confusing terminology: the `gitforge` **source** is the GitHub
33//!   **head** and the `gitforge` *target* is the GitHub **base**.
34//!
35//! * **User**: a GitHub **user** or **organisation.  Identified by the
36//!   user or organisation slug.
37
38/// Re-export of the Octocrab GitHub-specific client library
39pub use octocrab;
40
41use crate::prelude::*;
42use octocrab::Octocrab;
43
44/// GitHub client, as `Forge`.  Use `Box<dyn Forge>` instead.
45pub struct Hub {
46  host: String,
47  oc: Octocrab,
48  tok: tokio::runtime::Runtime,
49}
50impl Debug for Hub {
51  #[throws(fmt::Error)]
52  fn fmt(&self, f: &mut fmt::Formatter) {
53    f.debug_struct("Hub")
54      .field("oc", &self.oc)
55      .field("tok", &format_args!("{{..tokio..}}"))
56      .finish()?
57  }
58}
59
60impl TryFrom<Octocrab> for Hub {
61  type Error = FE;
62  #[throws(FE)]
63  fn try_from(oc: Octocrab) -> Self {
64    let host = (||{
65      let url = oc.absolute_url("/").context("get base url")?;
66      let domain = url.domain()
67        .ok_or_else(|| anyhow!("no domain (IP address?"))
68        .with_context(|| format!("{:?}", &url))?;
69      let host = domain.strip_prefix("api.").unwrap_or(domain);
70      Ok::<_,AE>(host.to_owned())
71    })()
72      .context("determine canonical hostname")
73      .map_err(FE::ClientCreationFailed)?;
74
75    let tok = tokio::runtime::Runtime::new()
76      .context("create").map_err(FE::Async)?;
77    Hub { host, oc, tok }
78  }
79}
80
81impl Hub {
82  /// Create a new GitHub client.  Prefer `forge::Config::new()`
83  ///
84  /// This call is primarily provided for internal use.
85  /// Call it yourself only if you want to hardcode use of GitHub.
86  #[throws(FE)]
87  pub fn new(config: &forge::Config) -> Box<dyn Forge> {
88    let mut oc = Octocrab::builder();
89    if let Some(Token(token)) = config.get_token()? {
90      oc = oc.personal_token(token.into());
91    }
92    if config.host != "github.com" {
93      oc = oc.base_url(format!("https://{}", config.host))
94        .context("set url")
95        .map_err(FE::ClientCreationFailed)?;
96    }
97    let oc = oc.build()
98      .context("build Octocrab client")
99      .map_err(FE::ClientCreationFailed)?;
100
101    let oc: Hub = oc.try_into()?;
102    Box::new(oc) as _
103  }
104}
105
106#[throws(FE)]
107fn repo_parse(frepo: &str) -> (&str /*gh owner*/,  &str /*gh owner */) {
108  frepo.split_once('/')
109    .ok_or_else(|| FE::InvalidObjectSyntax(
110      RemoteObjectKind::Repo,
111      "github repo must be <owner>/<repo>".into(),
112      frepo.into()
113    ))?
114}
115
116impl ForgeMethods for Hub {
117  fn clear_id_caches(&mut self) { }
118
119  fn host(&self) -> &str { &self.host }
120  fn kind(&self) -> Kind { Kind::GitHub }
121
122  #[throws(FE)]
123  fn request(&mut self, req: &Req) -> Resp {
124    macro_rules! make_request {
125      ($future:expr) => {
126        self.tok.block_on($future)
127          .with_context(|| format!("{:?}", req))
128          .map_err(FE::UncleassifiedOperationError)
129      }
130    }
131
132    match req {
133
134      Req::MergeRequests(q) => {
135        let (t_owner, t_repo) = repo_parse(&q.target_repo)?;
136
137        debug!("MergeRequests t_owner={:?} t_repo={:?} query {:?}",
138               &t_owner, &t_repo, &q);
139
140        let d = self.oc.pulls(t_owner, t_repo);
141
142        let d = if let Some(number) = &q.number {
143
144          let number = number.parse().map_err(
145            |e: ParseIntError| FE::InvalidIdSyntax(
146              RemoteObjectKind::MergeReq,
147              e.to_string(),
148              number.clone(),
149            ))?;
150
151          let d = make_request!( d.get(number) )?;
152
153          debug!("MergeRequests reply {:?}", &d);
154
155          vec![d]
156
157        } else {
158
159          let mut d = d.list();
160
161          d = d.state({
162            use IssueMrStatus as F;
163            use octocrab::params::State as G;
164            let some_want = |m: &dyn Fn(_) -> bool| match &q.statuses {
165              None => true,
166              Some(states) => states.iter().cloned().any(m),
167            };
168            let want_open = some_want(
169              &|st| matches!(st, F::Open)
170            );
171            let want_closed = some_want(
172              &|st| matches!(st, F::Closed | F::Merged)
173            );
174            match (want_open, want_closed) {
175              (false,false) => return Resp::MergeRequests { mrs: vec![] },
176              (false,true ) => G::Closed,
177              (true, false) => G::Open,
178              (true, true ) => G::All,
179            }
180          });
181
182          if let (Some(source_repo), Some(source_branch)) =
183                  (&q.source_repo,   &q.source_branch)
184          {
185            d = d.head(format!("{}:{}",
186                               repo_parse(source_repo)?.0,
187                               source_branch));
188          }
189
190          if let Some(target_branch) = &q.target_branch {
191            d = d.base(target_branch);
192          }
193
194          let d = make_request!( d.send() )?;
195
196          debug!("MergeRequests reply {:?}", &d);
197
198          if d.incomplete_results == Some(true) ||
199            d.next.is_some() { throw!(FE::TooManyResults); }
200
201          d.items
202
203        };
204
205        let mrs = d.into_iter().map(|g| {
206          let number = &g.number;
207
208          macro_rules! some_repo { { $what_repo:ident = $field:ident } => {
209            let $what_repo = g.$field.repo.ok_or_else(
210              || anyhow!("missing {} for #{}", stringify!($field), number)
211            )?.full_name;
212          } }
213          some_repo!{ target_repo = base }
214          some_repo!{ source_repo = head }
215
216          Ok::<_,AE>(Resp_MergeRequest {
217            number: number.to_string(),
218            author: g.user.login,
219            state: IssueMrState {
220              locked: if g.locked { IssueMrLocked::Locked }
221                             else { IssueMrLocked::Unlocked },
222              status: {
223                use IssueMrStatus as F;
224                use octocrab::models::IssueState as G;
225
226                match (g.state, &g.merged_at) {
227                  (G::Closed, None,   ) => F::Closed,
228                  (G::Closed, Some(_),) => F::Merged,
229                  (G::Open  , None,   ) => F::Open,
230                  (G::Open  , Some(_),) => {
231                    info!("mapping MR {:?} {:?} \
232                           open + merged to Unrepresentable",
233                          &target_repo, &g.number);
234                    F::Unrepresentable
235                  },
236                  (gstate, _) => {
237                    info!("mapping MR {:?} {:?} \
238                           unknown state={:?} Unrepresentable",
239                          &target_repo, &g.number, gstate);
240                    F::Unrepresentable
241                  },
242                }
243              },
244            },
245            target: RepoBranch {
246              repo: target_repo,
247              branch: g.base.ref_field,
248            },
249            source: RepoBranch {
250              repo: source_repo,
251              branch: g.head.ref_field,
252            },
253          })
254        })
255          .filter_ok(filter_mergerequests(&q))
256          .collect::<Result<Vec<_>,_>>()
257          .map_err(FE::ResultsProcessingFailed)?;
258          
259        Resp::MergeRequests { mrs }
260      },
261
262      Req::CreateMergeRequest(Req_CreateMergeRequest {
263        title, description,
264        target: RepoBranch { repo: target_repo, branch: target_branch },
265        source: RepoBranch { repo: source_repo, branch: source_branch },
266        _non_exhaustive, // we want to use *every* field
267      }) => {
268        let (t_owner, t_repo) = repo_parse(target_repo)?;
269        let (s_owner, s_repo) = repo_parse(source_repo)?;
270
271        debug!("MergeRequests query {:?} t_owner={:?} t_repo={:?} \
272                                         s_owner={:?} s_repo={:?}",
273               &t_owner, &t_repo,
274               &s_owner, &s_repo, &req);
275
276        // This does seem to be true.  I tried the syntax
277        // <s_owner>/<s_repo>:<s_branch> for "head" and got
278        // "Validation failed"
279        if &s_repo != &t_repo { throw!(FE::UnsupportedOperation(anyhow!(
280          "github octocrab cannot make an MR when \
281           source and target repos have different names :-( \
282           source={:?} != target={:?}",
283          &s_repo, &t_repo
284        ))) }
285
286        let d = self.oc.pulls(t_owner, t_repo);
287        let d = d.create(
288          title,
289          format!("{}:{}", &s_owner, source_branch),
290          target_branch,
291        );
292        let d = d.body(description);
293
294        let d = make_request!( d.send() )?;
295
296        Resp::CreateMergeRequest {
297          number: d.number.to_string(),
298        }
299      },
300
301      q@ Req::_NonExhaustive() => panic!("bad request {:?}", q),
302    }
303  }
304}