1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
// Copyright 2021 Citrix
// SPDX-License-Identifier: MIT OR Apache-2.0
// There is NO WARRANTY.

//! Uniform access to github and gitlab
//!
//! Currently, listing and creating merge requests is suppored.
//!
//! Start by creating a `Forge`, probably by initialising a
//! `Config` with `default`-based a literal and calling `Setup::forge`.
//!
//! # Terminology and model
//!
//! * **Repository**.  A single git tree that can contain code, and
//!   has other things associated with it such as issues and merge
//!   requests.  (GitHub, GitLab: **project**.)
//!
//!   Identified in the `gitforge` API by a string, which is typically
//!   the path suffix.
//!
//! * **Merge Request**.  A request to incorporate changes from
//!   a **source** repository and **branch** into a **target**
//!   repository and branch.  Forges typically associate merge
//!   requsts primarily with the *target* repository.  A merge
//!   request typically has an associated discussion.
//!
//!   (GitHub: a **pull request**.)
//!
//! * **Issue**.  A feature request or bug report, associated with
//!   a *repository*, and an associated discussion.  *Not currently
//!   supported by this library.
//!
//! * *Merge request* or *issue* **Number**.  A (usually short)
//!   string which uniquely identifies a merge request or issue
//!   within a *repository*.
//!
//! * **User**.  An individual user, or possibly, organisation.
//!   Represented as a string, being the username (as used, eg,
//!   as a slug in the forge's URLs).
//!
//! * **Branch**.  A git *branch* (on the server), ie a remote
//!   ref named `refs/heads/BRANCH`.  Identified by the string
//!   `BRANCH`.
//!
//! See the individual forge module docs for the mapping.
//!
//! # Non-exhaustive structs
//!
//! Some structs in this api have fields named `_non_exhaustive`.
//! This indicates that the struct may be extended with additional
//! fields in future, and that this won't be considered a semver
//! breaking change.
//!
//! Do not explicitly initialise such a field.  Instead, use
//! `..` syntax to initialise all the fields you are not naming.
//! Typically, you would write `..Default::default()`.
//!
//! This rule is in addition to some (other) structs being marked
//! `#[non_exhaustive]`.
//!
//! # Non-exhaustive enums
//!
//! Some enums have variants named `_NonExhaustive`.  This indicates
//! that the enum may be extended with additional variants in future,
//! and that this won't be considered a semver breaking change.
//!
//! Do not construct such an enum variant.  The library will *panic*
//! if it finds one.
//!
//! This rule is in addition to some (other) enums being marked
//! `#[non_exhaustive]`.

use crate::prelude::*;

/// Errors accessing forges
#[derive(Error,Debug)]
#[non_exhaustive]
pub enum Error {
  /// Forge operation error (unclassified)
  ///
  /// Something went wrong accessing the forge.  The error has not
  /// been more completely classified.
  #[error("error conducting forge operation (unclassified): {0}")]
  UncleassifiedOperationError(anyhow::Error),

  /// Forge operation build failed
  ///
  /// The forge-specific client library rejected the attempted request
  /// or otherwise failed to construct an appropriate query to the
  /// forge.
  ///
  /// This error occured before the actual forge was contacted.
  #[error("forge operation build failed: {0}")]
  OperationBuildFailed(anyhow::Error),

  /// Forge operation result processing failed
  ///
  /// The principal forge operation completed successfully (at least
  /// as far as the forge-specific client library was concerned) but
  /// error occurred processing the results.
  #[error("forge results processing failed: {0}")]
  ResultsProcessingFailed(anyhow::Error),

  /// Forge ancillary operation failed
  ///
  /// An error occurred while conducting operations ancillary to the
  /// principally-requested operation.
  #[error("forge ancillary operation failed: {0}")]
  AncillaryOperationFailed(anyhow::Error),

  /// Forge client creation error
  ///
  /// The operation to construct a forge client instance failed.
  /// Perhaps the forge-kind-specific library did not like the
  /// `Config`.
  #[error("forge client creation failed (bad config?): {0}")]
  ClientCreationFailed(anyhow::Error),

  /// Forge kind must be specified
  ///
  /// `Config` contains `Option<Kind>` so that it `impl Default` and
  /// for future compatibility.  But the kind must, currently, always
  /// be specified.
  #[error("forge kind must be specified")]
  KindMustBeSpecified,

  /// Forge kind disabled in this build
  ///
  /// This build of the `gitforge` library has been compiled without
  /// support for the specified forge type.
  #[error("forge kind {0:?} disnabled (cargo feature)")]
  KindDisabled(Kind),

  /// Async runtime failed
  ///
  /// The asynchronous runtimee failed
  #[error("async runtime error: {0}")] Async(anyhow::Error),

  /// Token always required for this forge kind
  ///
  /// Some forges (eg gitlab) always need a token and do not support
  /// anonymous API access.
  #[error("token always required for {0}")]
  TokenAlwaysRequired(Kind),

  /// Search query had too many results.
  ///
  /// See the discussion of *Searching and limits* by
  /// `ForgeMethods::request`.  Narrow your search by providing more
  /// parametsrs.
  #[error("request returned too many results (API limit)")]
  TooManyResults,

  /// Unsupported operation
  ///
  /// The operation is not supported on this kind of forge.
  #[error("unsupporrted operation: {0}")]
  UnsupportedOperation(anyhow::Error),

  /// Unsupported state in this context
  ///
  /// A state or status field value in a request was not supported.
  /// Whether a particular state or status is supported might depend on
  /// the request.
  #[error("{0}: state not supported (in this context): {1:?}")]
  UnsupportedState(RemoteObjectKind, String),

  /// Name refers to nonexistent remote object
  #[error("{0}: name not found: {1:?}")]
  NameNotFound(RemoteObjectKind,String),

  /// Id or number refers to nonexistent remote object
  #[error("{0}: id not found: {1}")]
  IdNotFound(RemoteObjectKind,String),

  /// Invalid object syntax
  #[error("invalid {0} syntax: {2:?}: {1}")]
  InvalidObjectSyntax(RemoteObjectKind,String,String),

  /// Invalid id or number syntax
  #[error("{0}: invalid id syntax: {2:?}: {1}")]
  InvalidIdSyntax(RemoteObjectKind,String,String),

  /// Unsupported remote URL format
  #[error("unsupported remote URL format: {1:?}: {0}")]
  UnsupportedRemoteUrlSyntax(String,String),

  /// Remote URL does not match the forge hsotname
  #[error("remote URL host {url:?} does not match forge host {forge:?}")]
  UrlHostMismatch { url: String, forge: String },
}

/// Kind of a thing on a forge.  Used mostly in errors.
#[derive(Debug,Error,Clone,Copy,Eq,PartialEq,Hash)]
pub enum RemoteObjectKind {
  #[error("user")]      User,
  #[error("repo")]      Repo,
  #[error("merge req")] MergeReq,
  #[error("issue")]     Issue,
}

#[derive(Debug,Error,Clone,Copy,Eq,PartialEq,Hash)]
#[derive(Serialize,Deserialize)]
#[serde(rename_all="snake_case")]
#[derive(Display,EnumString)]
#[strum(serialize_all = "lowercase")]
/// What protocol to use to access a forge.  Don't hardcode.
///
/// Please do not hardcode this.  Instead, read it, along with your
/// forge hostname, from a config file, or your command line.
///
/// You can parse this from a string.  The `FromStr` implementation
/// expects lowercase strings: **`gitlab`** or **`github`**.
pub enum Kind {
  GitLab,
  GitHub,
}

/// Secret access token.
#[derive(Clone,From,FromStr,Serialize,Deserialize)]
#[serde(transparent)]
pub struct Token(pub String);

impl Debug for Token {
  #[throws(fmt::Error)]
  fn fmt(&self, f: &mut fmt::Formatter) { write!(f,"forge::Token(..)")? }
}

pub(crate) type Constructor = fn(&Config) -> Result<
    Box<dyn Forge + 'static>,
    FE,
  >;

pub(crate) type ForgeEntry = (Kind, Constructor);
pub(crate) const FORGES: &[ForgeEntry] = &[
  #[cfg(feature="gitlab")] (Kind::GitLab, crate::lab::Lab::new),
  #[cfg(feature="github")] (Kind::GitHub, crate::hub::Hub::new),
];

/// Instructions for how to connect to a forge
#[derive(Default,Clone,Debug,Serialize,Deserialize)]
pub struct Config {
  /// Access token (secret).
  ///
  /// If left as `None`, `Config::forge()` will do the work
  /// of `load_default_token`.
  #[serde(default)]
  pub token: Option<TokenConfig>,

  /// The kind of forge (ie, the protocol to speak).
  ///
  /// Currently, `None` is not supported.  In the future omitting
  /// `kind` might result in auto-guessing from `host`.
  #[serde(default)]
  pub kind: Option<Kind>,

  /// Hostname.
  ///
  /// Eg, `gitlab.com`, `github.com`, `salsa.debian.org`.
  #[serde(default)]
  pub host: String,

  /// Do not specify this field.  Instead, say `..Default::default()`.
  /// New fields may be added and this will not be considered a semver break.
  #[serde(skip)]
  pub _non_exhaustive: (),
}

/// Instructions for how to obtain an access token
#[derive(Clone,Debug,Serialize,Deserialize)]
pub enum TokenConfig {
  /** Use anonymous access, do not authenticate. */ Anonymous,
  /** Read the token from this file.             */ Path(PathBuf),
  /** Use this token.                            */ Value(Token),
}

impl Config {
  /// Main constructor for a `Forge`
  #[throws(FE)]
  pub fn forge(&self) -> Box<dyn Forge + 'static> {
    let kind = self.kind.ok_or_else(|| FE::KindMustBeSpecified)?;

    let entry = FORGES.iter().find(|c| c.0 == kind)
      .ok_or_else(|| FE::KindDisabled(kind))?;
    entry.1(&self)?
  }
}

impl TokenConfig {
  #[throws(AE)]
  fn get_token(&self) -> Option<Token> {
    match self {
      TokenConfig::Anonymous => None,
      TokenConfig::Value(v) => Some(v).cloned(),
      TokenConfig::Path(path) => {
        let token = Token::load(path)?
          .ok_or_else(|| anyhow!("specified token file does not exist"))
          .with_context(|| format!("{:?}", &path))?;
        Some(token)
      }
    }
  }
}

impl Token {
  /// Load a secret token from a specified file.
  ///
  /// If the file does not exist (as opposed to other errors trying to
  /// read it), returns None.
  ///
  /// On Unix, will return an error if the file is world-readable.
  #[throws(anyhow::Error)]
  pub fn load(path: &Path) -> Option<Token> { (|| {
    let mut f = match File::open(&path) {
      Err(e) if e.kind() == ErrorKind::NotFound => {
        info!("forge token file {:?} does not exist", path);
        return Ok(None);
      },
      Err(e) => throw!(anyhow::Error::from(e).context("open")),
      Ok(f) => f,
    };

    #[cfg(unix)] {
      use std::os::unix::fs::MetadataExt;
      let m = f.metadata().context("stat")?;
      if m.mode() & 0o004 != 0 {
        throw!(anyhow!("token file is world-readable! refusing to use"))
      }
    }

    let mut buf = String::new();
    f.read_to_string(&mut buf).context("read")?;
    let token = Token(buf.trim().into());

    info!("forge token file {:?}", path);

    Ok::<_,anyhow::Error>(Some(token))
  })()
    .with_context(|| format!("{:?}", path))
    .context("git forge auth token file")?
  }
}

impl Config {
  /// Calculate the default path for finding a secret token.
  ///
  /// On Unix this is `~/.config/gitforge/FORGE_EXAMPLE_ORG.KIND-token`
  /// where `FORGE_EXAMPLE_ORG` is `host` with dots replaced with
  /// underscores.
  #[throws(anyhow::Error)]
  pub fn default_token_path(&self) -> PathBuf {
    let chk = |s: &str, what| if {
      s.chars().all(|c| c=='-' || c=='.' || c.is_ascii_alphanumeric()) &&
      s.chars().next().map(|c| c.is_ascii_alphanumeric()) == Some(true)
    } { Ok(()) } else { Err(anyhow!(
      "{} contains, or starts with, bad character(s)", what
    )) };

    let kind = self.kind.ok_or_else(|| FE::KindMustBeSpecified)?;

    let host: &str = &self.host;
    chk(host, "hostname")?;

    let mut path =
      directories::ProjectDirs::from("","","GitForge")
      .ok_or_else(|| anyhow!("could not find home directory"))?
      .config_dir().to_owned();

    path.push(format!(
      "{}.{}-token",
      host.replace('.',"_"),
      kind,
    ));

    path
  }

  #[throws(AE)]
  pub(crate) fn get_token_or_default(&self) -> Option<Token> {
    match &self.token {
      Some(c) => {
        c.get_token()?
      },
      None => {
        let path = self.default_token_path()?;
        let token = Token::load(&path)?;
        token
      },
    }

  }

  /// Load the default token, updating this Config.
  ///
  /// It is not normally necessary to call this, because `Config::new()`
  /// will automatically laod and use the appropriate token anyway,
  /// according to the same algorithm.
  ///
  /// Arranges that `self.token` is either `Anonymous` or `Value`,
  /// by establishing a suitable default, and loading the token from
  /// a file, as necessary.
  #[throws(anyhow::Error)]
  pub fn load_default_token(&mut self) -> &mut Self {
    (||{
      self.token = Some(match self.get_token_or_default()? {
        None => TokenConfig::Anonymous,
        Some(v) => TokenConfig::Value(v),
      });
      Ok::<_,AE>(())
    })().context("load default token")?;
    self
  }

  #[throws(FE)]
  pub(crate) fn get_token(&self) -> Option<Token> {
    self.get_token_or_default()
      .map_err(FE::ClientCreationFailed)?
  }
}

/// Main entrypoints once a `Forge` has been constructed.
///
/// All `Forge` opbjects implement this trait.
pub trait ForgeMethods {
  /// Make a request.  This could be a read or write request.
  ///
  /// # General rules
  ///
  /// ## Searching and `Option`
  ///
  /// Searching and listing is done with requests which specify
  /// the kind of thing to search for, and some fields for filtering
  /// the returned results.  Where a field is not `Option`, it is
  /// mandatory (and there is no way to specify a wildcard).  Where
  /// a field is an `Option(VALUE)`, it must match exactly; `None`
  /// is a wildcard.
  ///
  /// ## Searching and limits
  ///
  /// Search operations (ie, requests which return a list of objects
  /// matching various criteria) need careful use.
  ///
  /// Most forges apply a limit to the number of results from any
  /// search operation.  Therefore, we have to make searches producing
  /// (at the forge API level) no more results than fit into the
  /// result limit.
  ///
  /// It is therefore necessary to make sure that the search operation
  /// parameters are sufficient to limit the results.  Unfortunately
  /// the search criteria actually implemented by each forge's public
  /// API are different.  We resolve this as follows:
  ///
  /// *Each search operation in this library gives minimum guarantees
  /// for server-side result filtering*.  When making a search
  /// operation, you should try to ensure that you can make the
  /// server-side-filtered result set sufficiently small; typically,
  /// less than 100 results.
  ///
  /// If the search succeds, the `gitforge` library will then do
  /// client-side filtering to make sure to only return results
  /// matching your actual query.
  ///
  /// (The `gitforge` library does not make multiple requests for
  /// successive "pages" of results.  This is because forges do not
  /// provide a way to maintain a snapshot of results which can be
  /// retreived over multiple requets.  Consequently pages results are
  /// impossible to use reliably For example, if a the set of matching
  /// items changes, an already-existing item might be imagined by the
  /// forge to be on page 2 for our request for page 1, but be
  /// promoted to page 1 for our request for page 2 - so we would miss
  /// it entirely.0
  fn request(&mut self, req: &Req) -> Result<Resp, forge::Error>;

  /// Clear any cached id lookups.
  ///
  /// Some forges need to cache various id lookups (for example,
  /// project and user names).  When these mappings may have changed,
  /// call this methods.
  ///
  /// This is necessary to pick up changes to:
  ///   * users (and organisations)
  ///   * repositories (or projects)
  ///
  /// It is not necessary for routine objects such as merge requests,
  /// issues, discussion commonets, etc.
  fn clear_id_caches(&mut self);

  /// Get the repo name string from a URL
  ///
  /// For example, `https://salsa.debian.org/iwj/otter` => `iwj/otter`.
  ///
  /// Correct results are not guaranteed if the url is not a valid url
  /// at this forge.  The host might or might not be checked against
  /// the configured/ host.
  ///
  /// Not all git URL formats are supported, but the usual ones are.
  #[throws(FE)]
  fn remote_url_to_repo(&self, url: &str) -> String {
    parse_git_remote_url(url, Some(self.host()))?.path.to_owned()
  }

  /// Inspect forge's hostname
  fn host(&self) -> &str;

  /// Inspect forge's hostname
  fn kind(&self) -> Kind;
}

/// Main trait; forge objects are `Box<dyn Forge>`
///
/// This is just an alias for `ForgeMethods` with various
/// conventional supertraits.
///
/// Look at `ForgeMethods`.
pub trait Forge: ForgeMethods + Debug + Send + Sync + 'static { }
impl<T> Forge for T where T: ForgeMethods + Debug + Send + Sync + 'static { }

/// Search for merge requests.
///
/// Server-side filtering guaranteed on:
///  * `target_repo`
///  * `author`
///  * `statuses` but only if it contains exactly one entry
///  * `source_branch` but only if source_repo provided
#[derive(Debug,Default,Clone,Eq,PartialEq,Serialize,Deserialize)]
#[allow(non_camel_case_types)]
pub struct Req_MergeRequests {
  pub target_repo:   String,
  pub number:        Option<String>,
  pub author:        Option<String>,
  pub source_repo:   Option<String>,
  pub target_branch: Option<String>,
  pub source_branch: Option<String>,
  pub statuses:      Option<HashSet<IssueMrStatus>>,
  #[serde(skip)] pub _non_exhaustive: (),
}

/// Create a new merge request.
#[derive(Debug,Default,Clone,Eq,PartialEq,Serialize,Deserialize)]
#[allow(non_camel_case_types)]
pub struct Req_CreateMergeRequest {
  pub target:        RepoBranch,
  pub source:        RepoBranch,
  pub title:         String,
  /// markdown, in forge's own format
  pub description:   String,
  #[serde(skip)] pub _non_exhaustive: (),
}

/// Issue or Merge Request status
///
/// The `Ord` implementation orders, roughly, "open-ness"
///
/// `Unrepresentable` covers states which can be present on the forge
/// but which cannot be represented in the `gitforge` API.
#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
#[derive(Eq,PartialEq,Ord,PartialOrd)]
pub enum IssueMrStatus {
  Closed, Merged, Open, Unrepresentable,
}

/// Whether an Issue or Merge Request is locked
///
/// **Locked** means that contributions to the discussion, and/or
/// state changes, are restricted.  Typically locking is used as a
/// moderation tool for controversial discussions, and prevents
/// strangers from commenting.
#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
#[derive(Eq,PartialEq,Ord,PartialOrd)] // ordered by "open-ness"
pub enum IssueMrLocked {
  Unlocked, Locked,
}

/// Overall state of an Issue or Merge Request
#[derive(Debug,Clone,Copy,Hash,Serialize,Deserialize)]
#[derive(Eq,PartialEq,Ord,PartialOrd)] // ordered by "open-ness"
#[non_exhaustive]
pub struct IssueMrState {
  pub status: IssueMrStatus,
  pub locked: IssueMrLocked,
}

/// Request (command) to a forge
#[derive(Debug,Clone,Eq,PartialEq,Serialize,Deserialize)]
pub enum Req {
  /** Merge Requests: search */ MergeRequests(Req_MergeRequests),
  /** Merge Request: create */ CreateMergeRequest(Req_CreateMergeRequest),
  #[allow(non_camel_case_types)] _NonExhaustive(),
}

/// Response from a forge
#[derive(Debug,Clone,Hash,Eq,PartialEq,Serialize,Deserialize)]
pub enum Resp {
  #[non_exhaustive] MergeRequests      { mrs: Vec<Resp_MergeRequest>, },
  #[non_exhaustive] CreateMergeRequest { number: String,              },
  #[allow(non_camel_case_types)] _NonExhaustive(),
}

/// Repository and branch
///
/// Convenience structure for cases where these come together
#[derive(Debug,Clone,Default,Hash,Eq,PartialEq,Serialize,Deserialize)]
pub struct RepoBranch {
  pub repo: String,
  pub branch: String,
}

/// Merge request, in a response
#[derive(Debug,Clone,Hash,Eq,PartialEq,Serialize,Deserialize)]
#[allow(non_camel_case_types)]
#[non_exhaustive]
pub struct Resp_MergeRequest {
  pub number: String,
  pub author: String,
  pub state:  IssueMrState,
  pub source: RepoBranch,
  pub target: RepoBranch,
}