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}