gitforge/
forge.rs

1// Copyright 2021 Citrix
2// SPDX-License-Identifier: MIT OR Apache-2.0
3// There is NO WARRANTY.
4
5//! Uniform access to github and gitlab
6//!
7//! Currently, listing and creating merge requests is suppored.
8//!
9//! Start by creating a `Forge`, probably by initialising a
10//! `Config` with `default`-based a literal and calling `Setup::forge`.
11//!
12//! # Terminology and model
13//!
14//! * **Repository**.  A single git tree that can contain code, and
15//!   has other things associated with it such as issues and merge
16//!   requests.  (GitHub, GitLab: **project**.)
17//!
18//!   Identified in the `gitforge` API by a string, which is typically
19//!   the path suffix.
20//!
21//! * **Merge Request**.  A request to incorporate changes from
22//!   a **source** repository and **branch** into a **target**
23//!   repository and branch.  Forges typically associate merge
24//!   requsts primarily with the *target* repository.  A merge
25//!   request typically has an associated discussion.
26//!
27//!   (GitHub: a **pull request**.)
28//!
29//! * **Issue**.  A feature request or bug report, associated with
30//!   a *repository*, and an associated discussion.  *Not currently
31//!   supported by this library.
32//!
33//! * *Merge request* or *issue* **Number**.  A (usually short)
34//!   string which uniquely identifies a merge request or issue
35//!   within a *repository*.
36//!
37//! * **User**.  An individual user, or possibly, organisation.
38//!   Represented as a string, being the username (as used, eg,
39//!   as a slug in the forge's URLs).
40//!
41//! * **Branch**.  A git *branch* (on the server), ie a remote
42//!   ref named `refs/heads/BRANCH`.  Identified by the string
43//!   `BRANCH`.
44//!
45//! See the individual forge module docs for the mapping.
46//!
47//! # Non-exhaustive structs
48//!
49//! Some structs in this api have fields named `_non_exhaustive`.
50//! This indicates that the struct may be extended with additional
51//! fields in future, and that this won't be considered a semver
52//! breaking change.
53//!
54//! Do not explicitly initialise such a field.  Instead, use
55//! `..` syntax to initialise all the fields you are not naming.
56//! Typically, you would write `..Default::default()`.
57//!
58//! This rule is in addition to some (other) structs being marked
59//! `#[non_exhaustive]`.
60//!
61//! # Non-exhaustive enums
62//!
63//! Some enums have variants named `_NonExhaustive`.  This indicates
64//! that the enum may be extended with additional variants in future,
65//! and that this won't be considered a semver breaking change.
66//!
67//! Do not construct such an enum variant.  The library will *panic*
68//! if it finds one.
69//!
70//! This rule is in addition to some (other) enums being marked
71//! `#[non_exhaustive]`.
72
73use crate::prelude::*;
74
75/// Errors accessing forges
76#[derive(Error,Debug)]
77#[non_exhaustive]
78pub enum Error {
79  /// Forge operation error (unclassified)
80  ///
81  /// Something went wrong accessing the forge.  The error has not
82  /// been more completely classified.
83  #[error("error conducting forge operation (unclassified): {0}")]
84  UncleassifiedOperationError(anyhow::Error),
85
86  /// Forge operation build failed
87  ///
88  /// The forge-specific client library rejected the attempted request
89  /// or otherwise failed to construct an appropriate query to the
90  /// forge.
91  ///
92  /// This error occured before the actual forge was contacted.
93  #[error("forge operation build failed: {0}")]
94  OperationBuildFailed(anyhow::Error),
95
96  /// Forge operation result processing failed
97  ///
98  /// The principal forge operation completed successfully (at least
99  /// as far as the forge-specific client library was concerned) but
100  /// error occurred processing the results.
101  #[error("forge results processing failed: {0}")]
102  ResultsProcessingFailed(anyhow::Error),
103
104  /// Forge ancillary operation failed
105  ///
106  /// An error occurred while conducting operations ancillary to the
107  /// principally-requested operation.
108  #[error("forge ancillary operation failed: {0}")]
109  AncillaryOperationFailed(anyhow::Error),
110
111  /// Forge client creation error
112  ///
113  /// The operation to construct a forge client instance failed.
114  /// Perhaps the forge-kind-specific library did not like the
115  /// `Config`.
116  #[error("forge client creation failed (bad config?): {0}")]
117  ClientCreationFailed(anyhow::Error),
118
119  /// Forge kind must be specified
120  ///
121  /// `Config` contains `Option<Kind>` so that it `impl Default` and
122  /// for future compatibility.  But the kind must, currently, always
123  /// be specified.
124  #[error("forge kind must be specified")]
125  KindMustBeSpecified,
126
127  /// Forge kind disabled in this build
128  ///
129  /// This build of the `gitforge` library has been compiled without
130  /// support for the specified forge type.
131  #[error("forge kind {0:?} disnabled (cargo feature)")]
132  KindDisabled(Kind),
133
134  /// Async runtime failed
135  ///
136  /// The asynchronous runtimee failed
137  #[error("async runtime error: {0}")] Async(anyhow::Error),
138
139  /// Token always required for this forge kind
140  ///
141  /// Some forges (eg gitlab) always need a token and do not support
142  /// anonymous API access.
143  #[error("token always required for {0}")]
144  TokenAlwaysRequired(Kind),
145
146  /// Search query had too many results.
147  ///
148  /// See the discussion of *Searching and limits* by
149  /// `ForgeMethods::request`.  Narrow your search by providing more
150  /// parametsrs.
151  #[error("request returned too many results (API limit)")]
152  TooManyResults,
153
154  /// Unsupported operation
155  ///
156  /// The operation is not supported on this kind of forge.
157  #[error("unsupporrted operation: {0}")]
158  UnsupportedOperation(anyhow::Error),
159
160  /// Unsupported state in this context
161  ///
162  /// A state or status field value in a request was not supported.
163  /// Whether a particular state or status is supported might depend on
164  /// the request.
165  #[error("{0}: state not supported (in this context): {1:?}")]
166  UnsupportedState(RemoteObjectKind, String),
167
168  /// Name refers to nonexistent remote object
169  #[error("{0}: name not found: {1:?}")]
170  NameNotFound(RemoteObjectKind,String),
171
172  /// Id or number refers to nonexistent remote object
173  #[error("{0}: id not found: {1}")]
174  IdNotFound(RemoteObjectKind,String),
175
176  /// Invalid object syntax
177  #[error("invalid {0} syntax: {2:?}: {1}")]
178  InvalidObjectSyntax(RemoteObjectKind,String,String),
179
180  /// Invalid id or number syntax
181  #[error("{0}: invalid id syntax: {2:?}: {1}")]
182  InvalidIdSyntax(RemoteObjectKind,String,String),
183
184  /// Unsupported remote URL format
185  #[error("unsupported remote URL format: {1:?}: {0}")]
186  UnsupportedRemoteUrlSyntax(String,String),
187
188  /// Remote URL does not match the forge hsotname
189  #[error("remote URL host {url:?} does not match forge host {forge:?}")]
190  UrlHostMismatch { url: String, forge: String },
191}
192
193/// Kind of a thing on a forge.  Used mostly in errors.
194#[derive(Debug,Error,Clone,Copy,Eq,PartialEq,Hash)]
195pub enum RemoteObjectKind {
196  #[error("user")]      User,
197  #[error("repo")]      Repo,
198  #[error("merge req")] MergeReq,
199  #[error("issue")]     Issue,
200}
201
202#[derive(Debug,Error,Clone,Copy,Eq,PartialEq,Hash)]
203#[derive(Serialize,Deserialize)]
204#[serde(rename_all="snake_case")]
205#[derive(Display,EnumString)]
206#[strum(serialize_all = "lowercase")]
207/// What protocol to use to access a forge.  Don't hardcode.
208///
209/// Please do not hardcode this.  Instead, read it, along with your
210/// forge hostname, from a config file, or your command line.
211///
212/// You can parse this from a string.  The `FromStr` implementation
213/// expects lowercase strings: **`gitlab`** or **`github`**.
214pub enum Kind {
215  GitLab,
216  GitHub,
217}
218
219/// Secret access token.
220#[derive(Clone,From,FromStr,Serialize,Deserialize)]
221#[serde(transparent)]
222pub struct Token(pub String);
223
224impl Debug for Token {
225  #[throws(fmt::Error)]
226  fn fmt(&self, f: &mut fmt::Formatter) { write!(f,"forge::Token(..)")? }
227}
228
229pub(crate) type Constructor = fn(&Config) -> Result<
230    Box<dyn Forge + 'static>,
231    FE,
232  >;
233
234pub(crate) type ForgeEntry = (Kind, Constructor);
235pub(crate) const FORGES: &[ForgeEntry] = &[
236  #[cfg(feature="gitlab")] (Kind::GitLab, crate::lab::Lab::new),
237  #[cfg(feature="github")] (Kind::GitHub, crate::hub::Hub::new),
238];
239
240/// Instructions for how to connect to a forge
241#[derive(Default,Clone,Debug,Serialize,Deserialize)]
242pub struct Config {
243  /// Access token (secret).
244  ///
245  /// If left as `None`, `Config::forge()` will do the work
246  /// of `load_default_token`.
247  #[serde(default)]
248  pub token: Option<TokenConfig>,
249
250  /// The kind of forge (ie, the protocol to speak).
251  ///
252  /// Currently, `None` is not supported.  In the future omitting
253  /// `kind` might result in auto-guessing from `host`.
254  #[serde(default)]
255  pub kind: Option<Kind>,
256
257  /// Hostname.
258  ///
259  /// Eg, `gitlab.com`, `github.com`, `salsa.debian.org`.
260  #[serde(default)]
261  pub host: String,
262
263  /// Do not specify this field.  Instead, say `..Default::default()`.
264  /// New fields may be added and this will not be considered a semver break.
265  #[serde(skip)]
266  pub _non_exhaustive: (),
267}
268
269/// Instructions for how to obtain an access token
270#[derive(Clone,Debug,Serialize,Deserialize)]
271pub enum TokenConfig {
272  /** Use anonymous access, do not authenticate. */ Anonymous,
273  /** Read the token from this file.             */ Path(PathBuf),
274  /** Use this token.                            */ Value(Token),
275}
276
277impl Config {
278  /// Main constructor for a `Forge`
279  #[throws(FE)]
280  pub fn forge(&self) -> Box<dyn Forge + 'static> {
281    let kind = self.kind.ok_or_else(|| FE::KindMustBeSpecified)?;
282
283    let entry = FORGES.iter().find(|c| c.0 == kind)
284      .ok_or_else(|| FE::KindDisabled(kind))?;
285    entry.1(&self)?
286  }
287}
288
289impl TokenConfig {
290  #[throws(AE)]
291  fn get_token(&self) -> Option<Token> {
292    match self {
293      TokenConfig::Anonymous => None,
294      TokenConfig::Value(v) => Some(v).cloned(),
295      TokenConfig::Path(path) => {
296        let token = Token::load(path)?
297          .ok_or_else(|| anyhow!("specified token file does not exist"))
298          .with_context(|| format!("{:?}", &path))?;
299        Some(token)
300      }
301    }
302  }
303}
304
305impl Token {
306  /// Load a secret token from a specified file.
307  ///
308  /// If the file does not exist (as opposed to other errors trying to
309  /// read it), returns None.
310  ///
311  /// On Unix, will return an error if the file is world-readable.
312  #[throws(anyhow::Error)]
313  pub fn load(path: &Path) -> Option<Token> { (|| {
314    let mut f = match File::open(&path) {
315      Err(e) if e.kind() == ErrorKind::NotFound => {
316        info!("forge token file {:?} does not exist", path);
317        return Ok(None);
318      },
319      Err(e) => throw!(anyhow::Error::from(e).context("open")),
320      Ok(f) => f,
321    };
322
323    #[cfg(unix)] {
324      use std::os::unix::fs::MetadataExt;
325      let m = f.metadata().context("stat")?;
326      if m.mode() & 0o004 != 0 {
327        throw!(anyhow!("token file is world-readable! refusing to use"))
328      }
329    }
330
331    let mut buf = String::new();
332    f.read_to_string(&mut buf).context("read")?;
333    let token = Token(buf.trim().into());
334
335    info!("forge token file {:?}", path);
336
337    Ok::<_,anyhow::Error>(Some(token))
338  })()
339    .with_context(|| format!("{:?}", path))
340    .context("git forge auth token file")?
341  }
342}
343
344impl Config {
345  /// Calculate the default path for finding a secret token.
346  ///
347  /// On Unix this is `~/.config/gitforge/FORGE_EXAMPLE_ORG.KIND-token`
348  /// where `FORGE_EXAMPLE_ORG` is `host` with dots replaced with
349  /// underscores.
350  #[throws(anyhow::Error)]
351  pub fn default_token_path(&self) -> PathBuf {
352    let chk = |s: &str, what| if {
353      s.chars().all(|c| c=='-' || c=='.' || c.is_ascii_alphanumeric()) &&
354      s.chars().next().map(|c| c.is_ascii_alphanumeric()) == Some(true)
355    } { Ok(()) } else { Err(anyhow!(
356      "{} contains, or starts with, bad character(s)", what
357    )) };
358
359    let kind = self.kind.ok_or_else(|| FE::KindMustBeSpecified)?;
360
361    let host: &str = &self.host;
362    chk(host, "hostname")?;
363
364    let mut path =
365      directories::ProjectDirs::from("","","GitForge")
366      .ok_or_else(|| anyhow!("could not find home directory"))?
367      .config_dir().to_owned();
368
369    path.push(format!(
370      "{}.{}-token",
371      host.replace('.',"_"),
372      kind,
373    ));
374
375    path
376  }
377
378  #[throws(AE)]
379  pub(crate) fn get_token_or_default(&self) -> Option<Token> {
380    match &self.token {
381      Some(c) => {
382        c.get_token()?
383      },
384      None => {
385        let path = self.default_token_path()?;
386        let token = Token::load(&path)?;
387        token
388      },
389    }
390
391  }
392
393  /// Load the default token, updating this Config.
394  ///
395  /// It is not normally necessary to call this, because `Config::new()`
396  /// will automatically laod and use the appropriate token anyway,
397  /// according to the same algorithm.
398  ///
399  /// Arranges that `self.token` is either `Anonymous` or `Value`,
400  /// by establishing a suitable default, and loading the token from
401  /// a file, as necessary.
402  #[throws(anyhow::Error)]
403  pub fn load_default_token(&mut self) -> &mut Self {
404    (||{
405      self.token = Some(match self.get_token_or_default()? {
406        None => TokenConfig::Anonymous,
407        Some(v) => TokenConfig::Value(v),
408      });
409      Ok::<_,AE>(())
410    })().context("load default token")?;
411    self
412  }
413
414  #[throws(FE)]
415  pub(crate) fn get_token(&self) -> Option<Token> {
416    self.get_token_or_default()
417      .map_err(FE::ClientCreationFailed)?
418  }
419}
420
421/// Main entrypoints once a `Forge` has been constructed.
422///
423/// All `Forge` opbjects implement this trait.
424pub trait ForgeMethods {
425  /// Make a request.  This could be a read or write request.
426  ///
427  /// # General rules
428  ///
429  /// ## Searching and `Option`
430  ///
431  /// Searching and listing is done with requests which specify
432  /// the kind of thing to search for, and some fields for filtering
433  /// the returned results.  Where a field is not `Option`, it is
434  /// mandatory (and there is no way to specify a wildcard).  Where
435  /// a field is an `Option(VALUE)`, it must match exactly; `None`
436  /// is a wildcard.
437  ///
438  /// ## Searching and limits
439  ///
440  /// Search operations (ie, requests which return a list of objects
441  /// matching various criteria) need careful use.
442  ///
443  /// Most forges apply a limit to the number of results from any
444  /// search operation.  Therefore, we have to make searches producing
445  /// (at the forge API level) no more results than fit into the
446  /// result limit.
447  ///
448  /// It is therefore necessary to make sure that the search operation
449  /// parameters are sufficient to limit the results.  Unfortunately
450  /// the search criteria actually implemented by each forge's public
451  /// API are different.  We resolve this as follows:
452  ///
453  /// *Each search operation in this library gives minimum guarantees
454  /// for server-side result filtering*.  When making a search
455  /// operation, you should try to ensure that you can make the
456  /// server-side-filtered result set sufficiently small; typically,
457  /// less than 100 results.
458  ///
459  /// If the search succeds, the `gitforge` library will then do
460  /// client-side filtering to make sure to only return results
461  /// matching your actual query.
462  ///
463  /// (The `gitforge` library does not make multiple requests for
464  /// successive "pages" of results.  This is because forges do not
465  /// provide a way to maintain a snapshot of results which can be
466  /// retreived over multiple requets.  Consequently pages results are
467  /// impossible to use reliably For example, if a the set of matching
468  /// items changes, an already-existing item might be imagined by the
469  /// forge to be on page 2 for our request for page 1, but be
470  /// promoted to page 1 for our request for page 2 - so we would miss
471  /// it entirely.0
472  fn request(&mut self, req: &Req) -> Result<Resp, forge::Error>;
473
474  /// Clear any cached id lookups.
475  ///
476  /// Some forges need to cache various id lookups (for example,
477  /// project and user names).  When these mappings may have changed,
478  /// call this methods.
479  ///
480  /// This is necessary to pick up changes to:
481  ///   * users (and organisations)
482  ///   * repositories (or projects)
483  ///
484  /// It is not necessary for routine objects such as merge requests,
485  /// issues, discussion commonets, etc.
486  fn clear_id_caches(&mut self);
487
488  /// Get the repo name string from a URL
489  ///
490  /// For example, `https://salsa.debian.org/iwj/otter` => `iwj/otter`.
491  ///
492  /// Correct results are not guaranteed if the url is not a valid url
493  /// at this forge.  The host might or might not be checked against
494  /// the configured/ host.
495  ///
496  /// Not all git URL formats are supported, but the usual ones are.
497  #[throws(FE)]
498  fn remote_url_to_repo(&self, url: &str) -> String {
499    parse_git_remote_url(url, Some(self.host()))?.path.to_owned()
500  }
501
502  /// Inspect forge's hostname
503  fn host(&self) -> &str;
504
505  /// Inspect forge's hostname
506  fn kind(&self) -> Kind;
507}
508
509/// Main trait; forge objects are `Box<dyn Forge>`
510///
511/// This is just an alias for `ForgeMethods` with various
512/// conventional supertraits.
513///
514/// Look at `ForgeMethods`.
515pub trait Forge: ForgeMethods + Debug + Send + Sync + 'static { }
516impl<T> Forge for T where T: ForgeMethods + Debug + Send + Sync + 'static { }
517
518/// Search for merge requests.
519///
520/// Server-side filtering guaranteed on:
521///  * `target_repo`
522///  * `author`
523///  * `statuses` but only if it contains exactly one entry
524///  * `source_branch` but only if source_repo provided
525#[derive(Debug,Default,Clone,Eq,PartialEq,Serialize,Deserialize)]
526#[allow(non_camel_case_types)]
527pub struct Req_MergeRequests {
528  pub target_repo:   String,
529  pub number:        Option<String>,
530  pub author:        Option<String>,
531  pub source_repo:   Option<String>,
532  pub target_branch: Option<String>,
533  pub source_branch: Option<String>,
534  pub statuses:      Option<HashSet<IssueMrStatus>>,
535  #[serde(skip)] pub _non_exhaustive: (),
536}
537
538/// Create a new merge request.
539#[derive(Debug,Default,Clone,Eq,PartialEq,Serialize,Deserialize)]
540#[allow(non_camel_case_types)]
541pub struct Req_CreateMergeRequest {
542  pub target:        RepoBranch,
543  pub source:        RepoBranch,
544  pub title:         String,
545  /// markdown, in forge's own format
546  pub description:   String,
547  #[serde(skip)] pub _non_exhaustive: (),
548}
549
550/// Issue or Merge Request status
551///
552/// The `Ord` implementation orders, roughly, "open-ness"
553///
554/// `Unrepresentable` covers states which can be present on the forge
555/// but which cannot be represented in the `gitforge` API.
556#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
557#[derive(Eq,PartialEq,Ord,PartialOrd)]
558pub enum IssueMrStatus {
559  Closed, Merged, Open, Unrepresentable,
560}
561
562/// Whether an Issue or Merge Request is locked
563///
564/// **Locked** means that contributions to the discussion, and/or
565/// state changes, are restricted.  Typically locking is used as a
566/// moderation tool for controversial discussions, and prevents
567/// strangers from commenting.
568#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
569#[derive(Eq,PartialEq,Ord,PartialOrd)] // ordered by "open-ness"
570pub enum IssueMrLocked {
571  Unlocked, Locked,
572}
573
574/// Overall state of an Issue or Merge Request
575#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
576#[derive(Eq,PartialEq,Ord,PartialOrd)] // ordered by "open-ness"
577#[non_exhaustive]
578pub struct IssueMrState {
579  pub status: IssueMrStatus,
580  pub locked: IssueMrLocked,
581}
582
583/// Request (command) to a forge
584#[derive(Debug,Clone,Eq,PartialEq,Serialize,Deserialize)]
585pub enum Req {
586  /** Merge Requests: search */ MergeRequests(Req_MergeRequests),
587  /** Merge Request: create */ CreateMergeRequest(Req_CreateMergeRequest),
588  #[allow(non_camel_case_types)] _NonExhaustive(),
589}
590
591/// Response from a forge
592#[derive(Debug,Clone,Hash,Eq,PartialEq,Serialize,Deserialize)]
593pub enum Resp {
594  #[non_exhaustive] MergeRequests      { mrs: Vec<Resp_MergeRequest>, },
595  #[non_exhaustive] CreateMergeRequest { number: String,              },
596  #[allow(non_camel_case_types)] _NonExhaustive(),
597}
598
599/// Repository and branch
600///
601/// Convenience structure for cases where these come together
602#[derive(Debug,Clone,Default,Hash,Eq,PartialEq,Serialize,Deserialize)]
603pub struct RepoBranch {
604  pub repo: String,
605  pub branch: String,
606}
607
608/// Merge request, in a response
609#[derive(Debug,Clone,Hash,Eq,PartialEq,Serialize,Deserialize)]
610#[allow(non_camel_case_types)]
611#[non_exhaustive]
612pub struct Resp_MergeRequest {
613  pub number: String,
614  pub author: String,
615  pub state:  IssueMrState,
616  pub source: RepoBranch,
617  pub target: RepoBranch,
618}