From 2e47eeb144399e6496e37c6a4dbbd85fafad57a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20Casta=C3=B1o=20Arteaga?= <tegioz@icloud.com>
Date: Mon, 18 Mar 2024 12:35:10 +0100
Subject: [PATCH] Add experimental `generate` subcommand to CLI (#175)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #123

Signed-off-by: Sergio CastaƱo Arteaga <tegioz@icloud.com>
---
 Cargo.lock                                |   2 +
 clowarden-cli/Cargo.toml                  |   2 +
 clowarden-cli/src/main.rs                 | 110 +++++++++++++++-------
 clowarden-core/src/directory/legacy.rs    |  21 ++++-
 clowarden-core/src/directory/mod.rs       |  25 ++---
 clowarden-core/src/services/github/mod.rs |   2 +-
 6 files changed, 114 insertions(+), 48 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index f500a31..102c387 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -448,6 +448,8 @@ dependencies = [
  "clap",
  "clowarden-core",
  "config",
+ "serde",
+ "serde_yaml",
  "tokio",
  "tracing",
  "tracing-subscriber",
diff --git a/clowarden-cli/Cargo.toml b/clowarden-cli/Cargo.toml
index 493a969..090c358 100644
--- a/clowarden-cli/Cargo.toml
+++ b/clowarden-cli/Cargo.toml
@@ -11,6 +11,8 @@ anyhow = { workspace = true }
 clap = { workspace = true }
 clowarden-core = { path = "../clowarden-core" }
 config = { workspace = true }
+serde = { workspace = true }
+serde_yaml = { workspace = true }
 tokio = { workspace = true }
 tracing = { workspace = true }
 tracing-subscriber = { workspace = true }
diff --git a/clowarden-cli/src/main.rs b/clowarden-cli/src/main.rs
index 0b04563..c9a1069 100644
--- a/clowarden-cli/src/main.rs
+++ b/clowarden-cli/src/main.rs
@@ -5,6 +5,7 @@ use anyhow::{format_err, Result};
 use clap::{Args, Parser, Subcommand};
 use clowarden_core::{
     cfg::Legacy,
+    directory,
     github::{GHApi, Source},
     multierror,
     services::{
@@ -17,7 +18,7 @@ use clowarden_core::{
         Change,
     },
 };
-use std::{env, sync::Arc};
+use std::{env, fs::File, path::PathBuf, sync::Arc};
 
 #[derive(Parser)]
 #[command(
@@ -25,7 +26,7 @@ use std::{env, sync::Arc};
     about = "CLOWarden CLI tool
 
 This tool uses the Github API, which requires authentication. Please make sure
-you provide a Github token (with repo and read:org scopes) by setting the
+you provide a GitHub token (with repo and read:org scopes) by setting the
 GITHUB_TOKEN environment variable."
 )]
 struct Cli {
@@ -35,12 +36,15 @@ struct Cli {
 
 #[derive(Subcommand)]
 enum Command {
-    /// Validate the configuration in the repository provided.
-    Validate(BaseArgs),
-
     /// Display changes between the actual state (as defined in the services)
     /// and the desired state (as defined in the configuration).
     Diff(BaseArgs),
+
+    /// Generate configuration file from the actual state (experimental).
+    Generate(GenerateArgs),
+
+    /// Validate the configuration in the repository provided.
+    Validate(BaseArgs),
 }
 
 #[derive(Args)]
@@ -66,6 +70,17 @@ struct BaseArgs {
     people_file: Option<String>,
 }
 
+#[derive(Args)]
+struct GenerateArgs {
+    /// GitHub organization.
+    #[arg(long)]
+    org: String,
+
+    /// Output file.
+    #[arg(long)]
+    output_file: PathBuf,
+}
+
 /// Environment variable containing Github token.
 const GITHUB_TOKEN: &str = "GITHUB_TOKEN";
 
@@ -87,31 +102,9 @@ async fn main() -> Result<()> {
 
     // Run command
     match cli.command {
-        Command::Validate(args) => validate(args, github_token).await?,
         Command::Diff(args) => diff(args, github_token).await?,
-    }
-
-    Ok(())
-}
-
-/// Validate configuration.
-async fn validate(args: BaseArgs, github_token: String) -> Result<()> {
-    // GitHub
-
-    // Setup services
-    let (gh, svc) = setup_services(github_token);
-    let legacy = setup_legacy(&args);
-    let ctx = setup_context(&args);
-    let src = setup_source(&args);
-
-    // Validate configuration and display results
-    println!("Validating configuration...");
-    match github::State::new_from_config(gh, svc, &legacy, &ctx, &src).await {
-        Ok(_) => println!("Configuration is valid!"),
-        Err(err) => {
-            println!("{}\n", multierror::format_error(&err)?);
-            return Err(format_err!("Invalid configuration"));
-        }
+        Command::Validate(args) => validate(args, github_token).await?,
+        Command::Generate(args) => generate(args, github_token).await?,
     }
 
     Ok(())
@@ -124,7 +117,7 @@ async fn diff(args: BaseArgs, github_token: String) -> Result<()> {
     // Setup services
     let (gh, svc) = setup_services(github_token);
     let legacy = setup_legacy(&args);
-    let ctx = setup_context(&args);
+    let ctx = setup_context(&args.org);
     let src = setup_source(&args);
 
     // Get changes from the actual state to the desired state
@@ -148,6 +141,57 @@ async fn diff(args: BaseArgs, github_token: String) -> Result<()> {
     Ok(())
 }
 
+/// Generate a configuration file from the actual state of the services.
+///
+/// NOTE: at the moment the configuration generated uses the legacy format for
+/// backwards compatibility reasons.
+async fn generate(args: GenerateArgs, github_token: String) -> Result<()> {
+    #[derive(serde::Serialize)]
+    struct LegacyCfg {
+        teams: Vec<directory::legacy::sheriff::Team>,
+        repositories: Vec<github::state::Repository>,
+    }
+
+    println!("Getting actual state from GitHub...");
+    let (_, svc) = setup_services(github_token);
+    let ctx = setup_context(&args.org);
+    let actual_state = github::State::new_from_service(svc.clone(), &ctx).await?;
+
+    println!("Generating configuration file and writing it to the output file provided...");
+    let cfg = LegacyCfg {
+        teams: actual_state.directory.teams.into_iter().map(Into::into).collect(),
+        repositories: actual_state.repositories,
+    };
+    let file = File::create(&args.output_file)?;
+    serde_yaml::to_writer(file, &cfg)?;
+
+    println!("done!");
+    Ok(())
+}
+
+/// Validate configuration.
+async fn validate(args: BaseArgs, github_token: String) -> Result<()> {
+    // GitHub
+
+    // Setup services
+    let (gh, svc) = setup_services(github_token);
+    let legacy = setup_legacy(&args);
+    let ctx = setup_context(&args.org);
+    let src = setup_source(&args);
+
+    // Validate configuration and display results
+    println!("Validating configuration...");
+    match github::State::new_from_config(gh, svc, &legacy, &ctx, &src).await {
+        Ok(_) => println!("Configuration is valid!"),
+        Err(err) => {
+            println!("{}\n", multierror::format_error(&err)?);
+            return Err(format_err!("Invalid configuration"));
+        }
+    }
+
+    Ok(())
+}
+
 /// Helper function to setup some services from the arguments provided.
 fn setup_services(github_token: String) -> (Arc<GHApi>, Arc<SvcApi>) {
     let gh = GHApi::new_with_token(github_token.clone());
@@ -165,11 +209,11 @@ fn setup_legacy(args: &BaseArgs) -> Legacy {
     }
 }
 
-/// Helper function to create a context instance from the arguments.
-fn setup_context(args: &BaseArgs) -> Ctx {
+/// Helper function to create a context instance for the organization provided.
+fn setup_context(org: &str) -> Ctx {
     Ctx {
         inst_id: None,
-        org: args.org.clone(),
+        org: org.to_string(),
     }
 }
 
diff --git a/clowarden-core/src/directory/legacy.rs b/clowarden-core/src/directory/legacy.rs
index 660cc5a..45c666c 100644
--- a/clowarden-core/src/directory/legacy.rs
+++ b/clowarden-core/src/directory/legacy.rs
@@ -50,7 +50,7 @@ impl Cfg {
     }
 }
 
-pub(crate) mod sheriff {
+pub mod sheriff {
     use crate::{
         directory::{TeamName, UserName},
         github::{DynGH, Source},
@@ -185,12 +185,29 @@ pub(crate) mod sheriff {
 
     /// Team configuration.
     #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
-    pub(crate) struct Team {
+    pub struct Team {
         pub name: String,
+
+        #[serde(skip_serializing_if = "Option::is_none")]
         pub maintainers: Option<Vec<UserName>>,
+
+        #[serde(skip_serializing_if = "Option::is_none")]
         pub members: Option<Vec<UserName>>,
+
+        #[serde(skip_serializing_if = "Option::is_none")]
         pub formation: Option<Vec<TeamName>>,
     }
+
+    impl From<crate::directory::Team> for Team {
+        fn from(team: crate::directory::Team) -> Self {
+            Team {
+                name: team.name,
+                maintainers: Some(team.maintainers),
+                members: Some(team.members),
+                ..Default::default()
+            }
+        }
+    }
 }
 
 pub(crate) mod cncf {
diff --git a/clowarden-core/src/directory/mod.rs b/clowarden-core/src/directory/mod.rs
index 5000fc1..43a247b 100644
--- a/clowarden-core/src/directory/mod.rs
+++ b/clowarden-core/src/directory/mod.rs
@@ -16,7 +16,7 @@ use std::{
     fmt::Write,
 };
 
-mod legacy;
+pub mod legacy;
 
 lazy_static! {
     static ref GITHUB_URL: Regex =
@@ -192,17 +192,7 @@ impl From<legacy::Cfg> for Directory {
     /// Create a new directory instance from the legacy configuration.
     fn from(cfg: legacy::Cfg) -> Self {
         // Teams
-        let teams = cfg
-            .sheriff
-            .teams
-            .into_iter()
-            .map(|t| Team {
-                name: t.name,
-                maintainers: t.maintainers.unwrap_or_default(),
-                members: t.members.unwrap_or_default(),
-                ..Default::default()
-            })
-            .collect();
+        let teams = cfg.sheriff.teams.into_iter().map(Into::into).collect();
 
         // Users
         let users = if let Some(cncf) = cfg.cncf {
@@ -266,6 +256,17 @@ pub struct Team {
     pub annotations: HashMap<String, String>,
 }
 
+impl From<legacy::sheriff::Team> for Team {
+    fn from(team: legacy::sheriff::Team) -> Self {
+        Team {
+            name: team.name.clone(),
+            maintainers: team.maintainers.clone().unwrap_or_default(),
+            members: team.members.clone().unwrap_or_default(),
+            ..Default::default()
+        }
+    }
+}
+
 /// User profile.
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
 pub struct User {
diff --git a/clowarden-core/src/services/github/mod.rs b/clowarden-core/src/services/github/mod.rs
index cb41f73..d11d328 100644
--- a/clowarden-core/src/services/github/mod.rs
+++ b/clowarden-core/src/services/github/mod.rs
@@ -18,7 +18,7 @@ use tracing::debug;
 
 mod legacy;
 pub mod service;
-mod state;
+pub mod state;
 pub use state::State;
 
 /// GitHub's service name.