From 091c09450edf4b64d45f6e64a90c2cd1eaf63e35 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Tue, 3 Feb 2026 23:32:51 +0100 Subject: [PATCH] feat: add schema rs Signed-off-by: kjuulh --- .env | 2 + Cargo.lock | 64 ++++++++ README.md | 46 ++++++ crates/forge-enforce/Cargo.toml | 2 + crates/forge-enforce/src/cli.rs | 24 ++- crates/forge-enforce/src/forge_config.rs | 75 ++++++++-- crates/forge-enforce/src/forge_services.rs | 6 +- templates/fe/configs/github.com.toml | 15 +- templates/fe/schema.json | 165 +++++++++++++++++++++ 9 files changed, 378 insertions(+), 21 deletions(-) create mode 100644 templates/fe/schema.json diff --git a/.env b/.env index 6cb9a93..5bd873e 100644 --- a/.env +++ b/.env @@ -1,2 +1,4 @@ RUST_LOG=notmad=debug,nodrift=debug,forge=trace,info + FE_CONFIG_DIR=./templates/fe/configs/ +FE_SCHEMA_FILE=./templates/fe/schema.json diff --git a/Cargo.lock b/Cargo.lock index 8856cc4..c9eceac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,6 +450,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -572,7 +578,9 @@ dependencies = [ "notmad", "octocrab", "regex", + "schemars", "serde", + "serde_json", "tokio", "tokio-util", "toml", @@ -1473,6 +1481,26 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -1637,6 +1665,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1729,6 +1782,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/README.md b/README.md index 512343c..25d8906 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ # forge-enforce + +Enforce repository policies across forge providers (GitHub, etc.). Runs on a schedule, discovers repositories matching allow/deny filters, and applies configured policies. + +## Usage + +```bash +forge-enforce serve --host 127.0.0.1:3000 +``` + +Requires `FE_CONFIG_DIR` (or `--config-dir`) pointing to a directory of TOML config files, one per forge. + +## Configuration + +Each file in the config directory defines a forge connection. Example (`github.com.toml`): + +```toml +# Schedule: pick one +# schedule.cron = "0 * * * * *" +# schedule.interval = "60" # minutes (default) +schedule.once = true + +# Repository filters (regex patterns) +allow = ["^canopy-.*$"] +deny = ["^infrastructure-.*$", "^canopy-data-gateway$"] + +# Forge connection +[github] +credentials.token_env = "GITHUB_ACCESS_TOKEN" +organisation = "understory-io" + +# Policies to enforce +[policies] +squash_merge_only.enabled = true +``` + +### Fields + +| Field | Description | +|---|---| +| `schedule` | `cron`, `interval` (minutes), or `once` | +| `allow` | Regex list of repository names to include (default: `.*`) | +| `deny` | Regex list of repository names to exclude (default: none) | +| `[github]` | GitHub forge config: `credentials` and `organisation` | +| `credentials` | `token = "..."` or `token_env = "ENV_VAR"` | +| `[policies]` | Policy rules to enforce on matched repositories | +| `squash_merge_only.enabled` | Require squash merges only | diff --git a/crates/forge-enforce/Cargo.toml b/crates/forge-enforce/Cargo.toml index d596375..8ccf552 100644 --- a/crates/forge-enforce/Cargo.toml +++ b/crates/forge-enforce/Cargo.toml @@ -21,3 +21,5 @@ regex = "1.12.3" toml = "0.9.11" nodrift = "0.3.5" octocrab = "0.49.5" +schemars = "1.2.1" +serde_json = "1.0.149" diff --git a/crates/forge-enforce/src/cli.rs b/crates/forge-enforce/src/cli.rs index 4fcbbb4..e417300 100644 --- a/crates/forge-enforce/src/cli.rs +++ b/crates/forge-enforce/src/cli.rs @@ -1,6 +1,10 @@ -use clap::{Parser, Subcommand}; +use std::path::PathBuf; -use crate::{Config, State, cli::serve::ServeCommand}; +use clap::{Parser, Subcommand}; +use schemars::schema_for; +use tokio::io::AsyncWriteExt; + +use crate::{Config, State, cli::serve::ServeCommand, forge_config::ForgeConfig}; mod serve; @@ -17,6 +21,10 @@ struct Command { #[derive(Subcommand)] enum Commands { Serve(ServeCommand), + Schema { + #[arg(long, env = "FE_SCHEMA_FILE")] + schema_file: PathBuf, + }, } pub async fn execute() -> anyhow::Result<()> { @@ -26,5 +34,17 @@ pub async fn execute() -> anyhow::Result<()> { match cli.command.expect("a subcommand") { Commands::Serve(cmd) => cmd.execute(&state).await, + Commands::Schema { schema_file } => { + let schema = schema_for!(ForgeConfig); + + let output = serde_json::to_string_pretty(&schema)?; + + let mut file = tokio::fs::File::create(&schema_file).await?; + + file.write_all(output.as_bytes()).await?; + file.flush().await?; + + Ok(()) + } } } diff --git a/crates/forge-enforce/src/forge_config.rs b/crates/forge-enforce/src/forge_config.rs index b9e1e31..59067db 100644 --- a/crates/forge-enforce/src/forge_config.rs +++ b/crates/forge-enforce/src/forge_config.rs @@ -1,32 +1,67 @@ use regex::Regex; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ForgeConfig { #[serde(flatten)] pub forge_type: ForgeConfigType, + #[serde(default = "Filter::default")] + pub filter: Filter, + #[serde(default = "ForgeSchedule::default")] pub schedule: ForgeSchedule, - #[serde(default = "allow_all")] - pub allow: Vec, - #[serde(default = "Vec::new")] - pub deny: Vec, - #[serde(default)] pub policies: Policies, } -#[derive(Clone, Debug, Default, Deserialize)] +/// # Filter +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Filter { + /// # Allow + /// Allowed repositories, uses regex patterns + /// + /// ```toml + /// allow = [".*", "^something-.*$"] + /// ```` + #[serde(default = "allow_all")] + pub allow: Vec, + + /// # Deny + /// Denied repositories, uses regex patterns + /// + /// ```toml + /// deny = [".*", "^something-.*$"] + /// ```` + #[serde(default = "deny_default")] + pub deny: Vec, +} + +impl Default for Filter { + fn default() -> Self { + Self { + allow: allow_all(), + deny: deny_default(), + } + } +} + +fn deny_default() -> Vec { + vec![] +} + +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Policies { #[serde(default)] pub squash_merge_only: PolicyOption, } -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct PolicyOption { #[serde(default)] @@ -39,7 +74,7 @@ fn allow_all() -> Vec { }] } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema)] pub enum ForgeSchedule { #[serde(rename = "cron")] Cron(String), @@ -60,6 +95,16 @@ pub struct ForgeRegex { regex: Regex, } +impl JsonSchema for ForgeRegex { + fn schema_name() -> std::borrow::Cow<'static, str> { + "regex".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::schema_for!(String) + } +} + impl From for Regex { fn from(value: ForgeRegex) -> Self { value.regex @@ -99,20 +144,28 @@ impl<'de> Deserialize<'de> for ForgeRegex { } } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] // #[serde(tag = "type")] pub enum ForgeConfigType { + /// GitHub forge #[serde(rename = "github")] GitHub { + /// Credentials credentials: GitHubCredentials, + + /// Organisation organisation: String, }, } -#[derive(Clone, Debug, Serialize, Deserialize)] +/// GitHubCredentials +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub enum GitHubCredentials { + /// Token #[serde(rename = "token")] Token(String), + + /// Get token from env key #[serde(rename = "token_env")] TokenEnv(String), } diff --git a/crates/forge-enforce/src/forge_services.rs b/crates/forge-enforce/src/forge_services.rs index 9535c5b..93b757b 100644 --- a/crates/forge-enforce/src/forge_services.rs +++ b/crates/forge-enforce/src/forge_services.rs @@ -81,8 +81,8 @@ impl nodrift::Drifter for ForgeService { client .get_repositories( organisation, - self.config.allow.iter().map(|a| a.into()).collect(), - self.config.deny.iter().map(|a| a.into()).collect(), + self.config.filter.allow.iter().map(|a| a.into()).collect(), + self.config.filter.deny.iter().map(|a| a.into()).collect(), ) .await?; @@ -95,7 +95,7 @@ impl nodrift::Drifter for ForgeService { } } -pub async fn load(path: &Path, state: &State) -> anyhow::Result> { +pub async fn load(path: &Path, _state: &State) -> anyhow::Result> { if !path.exists() { anyhow::bail!("config path does not exist: {}", path.display()); } diff --git a/templates/fe/configs/github.com.toml b/templates/fe/configs/github.com.toml index 55cb177..53ee43f 100644 --- a/templates/fe/configs/github.com.toml +++ b/templates/fe/configs/github.com.toml @@ -1,12 +1,17 @@ -# schedule.cron = "0 * * * * *" -schedule.once = true +#:schema ../schema.json +[github] +organisation = "understory-io" +credentials.token_env = "GITHUB_ACCESS_TOKEN" + + +[filter] allow = ["^canopy-.*$"] deny = ["^infrastructure-.*$", "^canopy-data-gateway$"] -[github] -credentials.token_env = "GITHUB_ACCESS_TOKEN" -organisation = "understory-io" +[schedule] +once = true + [policies] squash_merge_only.enabled = true diff --git a/templates/fe/schema.json b/templates/fe/schema.json new file mode 100644 index 0000000..9c10b4e --- /dev/null +++ b/templates/fe/schema.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ForgeConfig", + "type": "object", + "properties": { + "filter": { + "$ref": "#/$defs/Filter" + }, + "policies": { + "$ref": "#/$defs/Policies" + }, + "schedule": { + "$ref": "#/$defs/ForgeSchedule" + } + }, + "oneOf": [ + { + "description": "GitHub forge", + "type": "object", + "properties": { + "github": { + "type": "object", + "properties": { + "credentials": { + "description": "Credentials", + "$ref": "#/$defs/GitHubCredentials" + }, + "organisation": { + "description": "Organisation", + "type": "string" + } + }, + "required": [ + "credentials", + "organisation" + ] + } + }, + "required": [ + "github" + ] + } + ], + "unevaluatedProperties": false, + "$defs": { + "Filter": { + "title": "Filter", + "type": "object", + "properties": { + "allow": { + "title": "Allow", + "description": "Allowed repositories, uses regex patterns\n\n```toml\nallow = [\".*\", \"^something-.*$\"]\n````", + "type": "array", + "items": { + "$ref": "#/$defs/regex" + } + }, + "deny": { + "title": "Deny", + "description": "Denied repositories, uses regex patterns\n\n```toml\ndeny = [\".*\", \"^something-.*$\"]\n````", + "type": "array", + "items": { + "$ref": "#/$defs/regex" + } + } + }, + "additionalProperties": false + }, + "ForgeSchedule": { + "oneOf": [ + { + "type": "object", + "properties": { + "cron": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "cron" + ] + }, + { + "type": "object", + "properties": { + "interval": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "interval" + ] + }, + { + "type": "object", + "properties": { + "once": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "once" + ] + } + ] + }, + "GitHubCredentials": { + "description": "GitHubCredentials", + "oneOf": [ + { + "description": "Token", + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "token" + ] + }, + { + "description": "Get token from env key", + "type": "object", + "properties": { + "token_env": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "token_env" + ] + } + ] + }, + "Policies": { + "type": "object", + "properties": { + "squash_merge_only": { + "$ref": "#/$defs/PolicyOption" + } + }, + "additionalProperties": false + }, + "PolicyOption": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, + "regex": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "string", + "type": "string" + } + } +} \ No newline at end of file