git-next/src/server/config.rs
Paul Campbell 24cb485410
All checks were successful
ci/woodpecker/push/tag-created Pipeline was successful
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
feat(repo/webhook): Handle messages received via webhook for ForgeJo
Closes kemitix/git-next#43
2024-04-14 19:14:38 +01:00

484 lines
14 KiB
Rust

use std::{
collections::HashMap,
fmt::{Display, Formatter},
ops::Deref,
};
use secrecy::ExposeSecret;
use serde::Deserialize;
use terrors::OneOf;
use crate::filesystem::FileSystem;
/// Mapped from the `git-next-server.toml` file
#[derive(Debug, PartialEq, Eq, Deserialize)]
pub struct ServerConfig {
webhook: Webhook,
forge: HashMap<String, Forge>,
}
impl ServerConfig {
pub(crate) fn load(fs: &FileSystem) -> Result<Self, OneOf<(std::io::Error, toml::de::Error)>> {
let str = fs.read_file("git-next-server.toml").map_err(OneOf::new)?;
toml::from_str(&str).map_err(OneOf::new)
}
pub(crate) fn forges(&self) -> impl Iterator<Item = (ForgeName, &Forge)> {
self.forge
.iter()
.map(|(name, forge)| (ForgeName(name.clone()), forge))
}
pub const fn webhook(&self) -> &Webhook {
&self.webhook
}
}
/// Defines the Webhook Forges should send updates to
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct Webhook {
url: String,
}
impl Webhook {
pub fn url(&self) -> WebhookUrl {
WebhookUrl(self.url.clone())
}
}
/// The URL for the webhook where forges should send their updates
pub struct WebhookUrl(String);
impl AsRef<str> for WebhookUrl {
fn as_ref(&self) -> &str {
&self.0
}
}
/// Mapped from `.git-next.toml` file in target repo
/// Is also derived from the optional parameters in `git-next-server.toml` at
/// `forge.{forge}.repos.{repo}.(main|next|dev)`
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct RepoConfig {
branches: RepoBranches,
}
impl RepoConfig {
#[allow(dead_code)]
pub(crate) fn load(toml: &str) -> Result<Self, OneOf<(toml::de::Error,)>> {
toml::from_str(toml).map_err(OneOf::new)
}
pub const fn branches(&self) -> &RepoBranches {
&self.branches
}
}
impl Display for RepoConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.branches)
}
}
/// Mapped from `.git-next.toml` file at `branches`
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct RepoBranches {
main: String,
next: String,
dev: String,
}
impl RepoBranches {
pub fn main(&self) -> BranchName {
BranchName(self.main.clone())
}
pub fn next(&self) -> BranchName {
BranchName(self.next.clone())
}
pub fn dev(&self) -> BranchName {
BranchName(self.dev.clone())
}
}
impl Display for RepoBranches {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/// Defines a Forge to connect to
/// Maps from `git-next-server.toml` at `forge.{forge}`
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct Forge {
forge_type: ForgeType,
hostname: String,
user: String,
token: String,
// API Token
// Private SSH Key Path
repos: HashMap<String, Repo>,
}
impl Forge {
#[allow(dead_code)]
pub const fn forge_type(&self) -> &ForgeType {
&self.forge_type
}
pub fn hostname(&self) -> Hostname {
Hostname(self.hostname.clone())
}
pub fn user(&self) -> User {
User(self.user.clone())
}
pub fn token(&self) -> ApiToken {
ApiToken(self.token.clone().into())
}
pub fn repos(&self) -> impl Iterator<Item = (RepoAlias, &Repo)> {
self.repos
.iter()
.map(|(name, repo)| (RepoAlias(name.clone()), repo))
}
}
impl Display for Forge {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} - {}@{}", self.forge_type, self.user, self.hostname)
}
}
/// Defines a Repo within a Forge to be monitored by the server
/// Maps from `git-next-server.toml` at `forge.{forge}.repos.{name}`
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct Repo {
repo: String,
branch: String,
main: Option<String>,
next: Option<String>,
dev: Option<String>,
}
impl Repo {
#[allow(dead_code)]
pub fn repo(&self) -> RepoPath {
RepoPath(self.repo.clone())
}
#[allow(dead_code)]
pub fn branch(&self) -> BranchName {
BranchName(self.branch.clone())
}
/// Returns a RepoConfig from the server configuration if ALL THREE branches were provided
pub fn repo_config(&self) -> Option<RepoConfig> {
match (&self.main, &self.next, &self.dev) {
(Some(main), Some(next), Some(dev)) => Some(RepoConfig {
branches: RepoBranches {
main: main.to_string(),
next: next.to_string(),
dev: dev.to_string(),
},
}),
_ => None,
}
}
}
#[cfg(test)]
impl AsRef<Self> for Repo {
fn as_ref(&self) -> &Self {
self
}
}
impl Display for Repo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} - {}", self.repo, self.branch)
}
}
/// The name of a Forge to connect to
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ForgeName(pub String);
impl Display for ForgeName {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The hostname of a forge
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hostname(pub String);
impl Display for Hostname {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The user within the forge to connect as
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct User(pub String);
impl Display for User {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The API Token for the [user]
/// ForgeJo: https://{hostname}/user/settings/applications
/// Github: https://github.com/settings/tokens
#[derive(Clone, Debug)]
pub struct ApiToken(pub secrecy::Secret<String>);
impl From<String> for ApiToken {
fn from(value: String) -> Self {
Self(value.into())
}
}
/// The API Token is in effect a password, so it must be explicitly exposed to access its value
impl ExposeSecret<String> for ApiToken {
fn expose_secret(&self) -> &String {
self.0.expose_secret()
}
}
/// The derived information about a Forge, used to create interactions with it
#[derive(Clone, Debug)]
pub struct ForgeDetails {
pub name: ForgeName,
pub forge_type: ForgeType,
pub hostname: Hostname,
pub user: User,
pub token: ApiToken,
// API Token
// Private SSH Key Path
}
impl From<(&ForgeName, &Forge)> for ForgeDetails {
fn from(forge: (&ForgeName, &Forge)) -> Self {
Self {
name: forge.0.clone(),
forge_type: forge.1.forge_type.clone(),
hostname: forge.1.hostname(),
user: forge.1.user(),
token: forge.1.token(),
}
}
}
/// The alias of a repo
/// This is the alias for the repo within `git-next-server.toml`
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RepoAlias(pub String);
impl Display for RepoAlias {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The path for the repo within the forge.
/// Typically this is composed of the user or organisation and the name of the repo
/// e.g. `{user}/{repo}`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoPath(pub String);
impl Display for RepoPath {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The name of a Branch
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct BranchName(pub String);
impl Display for BranchName {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Deref for BranchName {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// The derived information about a repo, used to interact with it
#[derive(Clone, Debug)]
pub struct RepoDetails {
pub name: RepoAlias,
pub repo: RepoPath,
pub branch: BranchName,
pub forge: ForgeDetails,
pub config: Option<RepoConfig>,
}
impl RepoDetails {
pub fn new(name: &RepoAlias, repo: &Repo, forge_name: &ForgeName, forge: &Forge) -> Self {
Self {
name: name.clone(),
repo: RepoPath(repo.repo.clone()),
config: repo.repo_config(),
branch: BranchName(repo.branch.clone()),
forge: ForgeDetails {
name: forge_name.clone(),
forge_type: forge.forge_type.clone(),
hostname: forge.hostname(),
user: forge.user(),
token: forge.token(),
},
}
}
}
impl Display for RepoDetails {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}/{} ({}): {}:{}/{} @ {}",
self.forge.name,
self.name,
self.forge.forge_type,
self.forge.hostname,
self.forge.user,
self.repo,
self.branch,
)
}
}
/// Identifier for the type of Forge
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub enum ForgeType {
#[cfg(feature = "forgejo")]
ForgeJo,
// Gitea,
// GitHub,
// GitLab,
// BitBucket,
#[cfg(test)]
MockForge,
}
impl Display for ForgeType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
#[cfg(feature = "forgejo")]
#[cfg(test)]
mod tests {
use crate::filesystem::FileSystem;
use super::*;
#[test]
#[cfg(feature = "forgejo")]
fn test_server_config_load() -> Result<(), OneOf<(std::io::Error, toml::de::Error)>> {
let fs = FileSystem::new_temp().map_err(OneOf::new)?;
fs.write_file(
"git-next-server.toml",
r#"
[webhook]
url = "http://localhost:9909/webhook"
[forge.default]
forge_type = "ForgeJo"
hostname = "git.example.net"
user = "Bob"
token = "API-Token"
[forge.default.repos]
hello = { repo = "user/hello", branch = "main" }
world = { repo = "user/world", branch = "master", main = "main", next = "next", dev = "dev" }
[forge.default.repos.sam]
repo = "user/sam"
branch = "main"
main = "master"
next = "upcoming"
dev = "sam-dev"
"#,
)
.map_err(OneOf::new)?;
let config = ServerConfig::load(&fs)?;
let expected = ServerConfig {
webhook: Webhook {
url: "http://localhost:9909/webhook".to_string(),
},
forge: HashMap::from([(
"default".to_string(),
Forge {
forge_type: ForgeType::ForgeJo,
hostname: "git.example.net".to_string(),
user: "Bob".to_string(),
token: "API-Token".to_string(),
repos: HashMap::from([
(
"hello".to_string(),
Repo {
repo: "user/hello".to_string(),
branch: "main".to_string(),
main: None,
next: None,
dev: None,
},
),
(
"world".to_string(),
Repo {
repo: "user/world".to_string(),
branch: "master".to_string(),
main: Some("main".to_string()),
next: Some("next".to_string()),
dev: Some("dev".to_string()),
},
),
(
"sam".to_string(),
Repo {
repo: "user/sam".to_string(),
branch: "main".to_string(),
main: Some("master".to_string()),
next: Some("upcoming".to_string()),
dev: Some("sam-dev".to_string()),
},
),
]),
},
)]),
};
assert_eq!(config, expected, "ServerConfig");
if let Some(forge) = config.forge.get("world") {
if let Some(repo) = forge.repos.get("sam") {
let repo_config = repo.repo_config();
let expected = Some(RepoConfig {
branches: RepoBranches {
main: "master".to_string(),
next: "upcoming".to_string(),
dev: "sam-dev".to_string(),
},
});
assert_eq!(repo_config, expected, "RepoConfig");
}
}
Ok(())
}
#[test]
fn test_repo_config_load() -> Result<(), OneOf<(toml::de::Error,)>> {
let toml = r#"
[branches]
main = "main"
next = "next"
dev = "dev"
[options]
"#;
let config = RepoConfig::load(toml)?;
assert_eq!(
config,
RepoConfig {
branches: RepoBranches {
main: "main".to_string(),
next: "next".to_string(),
dev: "dev".to_string(),
},
}
);
Ok(())
}
}