feat: add schema rs

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-02-03 23:32:51 +01:00
parent 41d7b76685
commit 091c09450e
9 changed files with 378 additions and 21 deletions

2
.env
View File

@@ -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

64
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 |

View File

@@ -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"

View File

@@ -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(())
}
}
}

View File

@@ -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<ForgeRegex>,
#[serde(default = "Vec::new")]
pub deny: Vec<ForgeRegex>,
#[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<ForgeRegex>,
/// # Deny
/// Denied repositories, uses regex patterns
///
/// ```toml
/// deny = [".*", "^something-.*$"]
/// ````
#[serde(default = "deny_default")]
pub deny: Vec<ForgeRegex>,
}
impl Default for Filter {
fn default() -> Self {
Self {
allow: allow_all(),
deny: deny_default(),
}
}
}
fn deny_default() -> Vec<ForgeRegex> {
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<ForgeRegex> {
}]
}
#[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<ForgeRegex> 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),
}

View File

@@ -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<Vec<ForgeService>> {
pub async fn load(path: &Path, _state: &State) -> anyhow::Result<Vec<ForgeService>> {
if !path.exists() {
anyhow::bail!("config path does not exist: {}", path.display());
}

View File

@@ -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

165
templates/fe/schema.json Normal file
View File

@@ -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"
}
}
}