diff --git a/Cargo.lock b/Cargo.lock index c3c8634..78f83ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,7 @@ dependencies = [ "axum", "clap", "dotenv", + "http", "nodrift", "notmad", "octocrab", diff --git a/crates/forge-enforce/Cargo.toml b/crates/forge-enforce/Cargo.toml index 8ccf552..cdd44d3 100644 --- a/crates/forge-enforce/Cargo.toml +++ b/crates/forge-enforce/Cargo.toml @@ -23,3 +23,4 @@ nodrift = "0.3.5" octocrab = "0.49.5" schemars = "1.2.1" serde_json = "1.0.149" +http = "1" diff --git a/crates/forge-enforce/src/forge_config.rs b/crates/forge-enforce/src/forge_config.rs index 59067db..06e3841 100644 --- a/crates/forge-enforce/src/forge_config.rs +++ b/crates/forge-enforce/src/forge_config.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use regex::Regex; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -15,7 +17,19 @@ pub struct ForgeConfig { pub schedule: ForgeSchedule, #[serde(default)] - pub policies: Policies, + pub policies: BTreeMap, + + #[serde(default)] + pub mode: Mode, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Default)] +pub enum Mode { + #[serde(rename = "warn")] + Warn, + #[serde(rename = "update")] + #[default] + Update, } /// # Filter @@ -54,13 +68,6 @@ 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, JsonSchema)] #[serde(deny_unknown_fields)] pub struct PolicyOption { diff --git a/crates/forge-enforce/src/forge_services.rs b/crates/forge-enforce/src/forge_services.rs index 93b757b..5214ebe 100644 --- a/crates/forge-enforce/src/forge_services.rs +++ b/crates/forge-enforce/src/forge_services.rs @@ -1,4 +1,4 @@ -use std::{path::Path, time::Duration}; +use std::{collections::BTreeMap, path::Path, time::Duration}; use anyhow::Context; use async_trait::async_trait; @@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken; use crate::{ State, - forge_config::{ForgeConfig, ForgeConfigType, ForgeSchedule, GitHubCredentials}, + forge_config::{ForgeConfig, ForgeConfigType, ForgeSchedule, GitHubCredentials, Mode}, forges, }; @@ -18,6 +18,105 @@ pub struct ForgeService { config: ForgeConfig, } +impl ForgeService { + async fn sync(&self) -> anyhow::Result<()> { + match &self.config.forge_type { + ForgeConfigType::GitHub { + credentials, + organisation, + } => { + let creds = match credentials.clone() { + GitHubCredentials::Token(token) => { + forges::github::GitHubCredentials::Token(token) + } + GitHubCredentials::TokenEnv(key) => { + let token = + std::env::var(key).context("failed to lookup github token env")?; + + forges::github::GitHubCredentials::Token(token) + } + }; + + let client = forges::github::GitHub::new(creds); + let policies = forges::github::policies::Policies::new(); + + // 1. get repositories matching allow / deny list + + let repos = client + .get_repositories( + organisation, + self.config.filter.allow.iter().map(|a| a.into()).collect(), + self.config.filter.deny.iter().map(|a| a.into()).collect(), + ) + .await?; + + for repo in repos { + let repo = client.get_repository(&repo).await?; + + let repo_display = serde_json::to_string(&repo)?; + tracing::trace!(repo = repo_display, "checking for updates"); + tracing::debug!(repo = ?repo.full_name, "checking policies"); + + let mut all_updates = BTreeMap::new(); + let mut all_warnings = Vec::new(); + + // 2. Evaluate each enabled policy + for policy_name in self + .config + .policies + .iter() + .filter(|(_, option)| option.enabled) + .map(|(p, _)| p) + { + let policy = policies + .get_policy(policy_name) + .context(anyhow::anyhow!("failed to find policy: {policy_name}"))?; + + let result = policy.evaluate(&client, &repo).await?; + + all_updates.extend(result.updates); + all_warnings.extend(result.warnings); + } + + // Always log warnings regardless of mode + for warning in &all_warnings { + tracing::warn!(repo = ?repo.full_name, "{warning}"); + } + + if all_updates.is_empty() { + if all_warnings.is_empty() { + tracing::debug!(repo = ?repo.full_name, "no policy required updating, skipping"); + } + continue; + } + + tracing::info!(repo = ?repo.full_name, "updating repository"); + + let updates_display = serde_json::to_string(&all_updates)?; + + match self.config.mode { + Mode::Warn => { + tracing::error!(update = updates_display, "repository requires update"); + } + Mode::Update => { + tracing::info!(update = updates_display, "repository updating"); + + // 3. Update repositories + client.update_repository(&repo, all_updates).await.context( + anyhow::anyhow!( + "update repository: {}", + repo.full_name.unwrap_or_default() + ), + )?; + } + } + } + } + } + Ok(()) + } +} + #[async_trait] impl Component for ForgeService { fn name(&self) -> Option { @@ -57,39 +156,9 @@ impl nodrift::Drifter for ForgeService { async fn execute(&self, _token: CancellationToken) -> anyhow::Result<()> { tracing::info!("running schedule"); - match &self.config.forge_type { - ForgeConfigType::GitHub { - credentials, - organisation, - } => { - let creds = match credentials.clone() { - GitHubCredentials::Token(token) => { - forges::github::GitHubCredentials::Token(token) - } - GitHubCredentials::TokenEnv(key) => { - let token = - std::env::var(key).context("failed to lookup github token env")?; - - forges::github::GitHubCredentials::Token(token) - } - }; - - let client = forges::github::GitHub::new(creds); - - // 1. get repositories matching allow / deny list - - client - .get_repositories( - organisation, - self.config.filter.allow.iter().map(|a| a.into()).collect(), - self.config.filter.deny.iter().map(|a| a.into()).collect(), - ) - .await?; - - // 2. Get updates for each - // 3. Update repositories - } - } + self.sync() + .await + .inspect_err(|e| tracing::warn!("failed to update repository: {e:#}"))?; Ok(()) } diff --git a/crates/forge-enforce/src/forges/github.rs b/crates/forge-enforce/src/forges/github.rs index 0c9c0cf..f2bde34 100644 --- a/crates/forge-enforce/src/forges/github.rs +++ b/crates/forge-enforce/src/forges/github.rs @@ -1,7 +1,11 @@ +use std::collections::BTreeMap; + use anyhow::Context; use octocrab::{Octocrab, models::Repository}; use regex::Regex; +pub mod policies; + pub struct GitHub { credentials: GitHubCredentials, client: Octocrab, @@ -29,6 +33,14 @@ impl GitHub { } } + pub async fn get_repository(&self, repo: &Repository) -> anyhow::Result { + self.client + .repos_by_id(repo.id) + .get() + .await + .context("repo by id") + } + pub async fn get_repositories( &self, organisation: &str, @@ -97,4 +109,47 @@ impl GitHub { Ok(allowed_repos) } + + pub async fn file_exists(&self, repo: &Repository, path: &str) -> anyhow::Result { + let full_name = repo.full_name.as_ref().context("full name is required")?; + let parts: Vec<&str> = full_name.splitn(2, '/').collect(); + anyhow::ensure!(parts.len() == 2, "invalid full_name format: {full_name}"); + let (owner, name) = (parts[0], parts[1]); + + match self + .client + .repos(owner, name) + .get_content() + .path(path) + .send() + .await + { + Ok(_) => Ok(true), + Err(octocrab::Error::GitHub { source, .. }) + if source.status_code == http::StatusCode::NOT_FOUND => + { + Ok(false) + } + Err(e) => Err(e.into()), + } + } + + pub async fn update_repository( + &self, + repo: &Repository, + options: BTreeMap, + ) -> anyhow::Result<()> { + let _res: serde_json::Value = self + .client + .patch( + format!( + "/repos/{}", + repo.full_name.as_ref().context("full name is required")? + ), + Some(&options), + ) + .await?; + + Ok(()) + } } diff --git a/crates/forge-enforce/src/forges/github/policies.rs b/crates/forge-enforce/src/forges/github/policies.rs new file mode 100644 index 0000000..68a7a87 --- /dev/null +++ b/crates/forge-enforce/src/forges/github/policies.rs @@ -0,0 +1,69 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use octocrab::models::Repository; + +use crate::forges::github::GitHub; + +pub mod auto_merge; +pub mod has_codeowners; +pub mod squash_merge; + +pub struct PolicyResult { + pub updates: BTreeMap, + pub warnings: Vec, +} + +impl PolicyResult { + pub fn ok() -> Self { + Self { + updates: BTreeMap::new(), + warnings: Vec::new(), + } + } + + pub fn warn(message: impl Into) -> Self { + Self { + updates: BTreeMap::new(), + warnings: vec![message.into()], + } + } + + pub fn is_empty(&self) -> bool { + self.updates.is_empty() && self.warnings.is_empty() + } +} + +#[async_trait] +pub trait Policy: Send + Sync { + async fn evaluate(&self, client: &GitHub, repo: &Repository) -> anyhow::Result; +} + +#[derive(Clone)] +pub struct Policies { + policies: Arc>>, +} + +impl Policies { + pub fn new() -> Self { + let mut policies: BTreeMap> = BTreeMap::new(); + + policies.insert( + "squash_merge_only".into(), + Arc::new(squash_merge::SquashMergeOnly), + ); + policies.insert("allow_auto_merge".into(), Arc::new(auto_merge::AutoMerge)); + policies.insert( + "has_codeowners".into(), + Arc::new(has_codeowners::HasCodeowners), + ); + + Self { + policies: Arc::new(policies), + } + } + + pub fn get_policy(&self, policy_name: &str) -> Option> { + self.policies.get(policy_name).cloned() + } +} diff --git a/crates/forge-enforce/src/forges/github/policies/auto_merge.rs b/crates/forge-enforce/src/forges/github/policies/auto_merge.rs new file mode 100644 index 0000000..5a70d72 --- /dev/null +++ b/crates/forge-enforce/src/forges/github/policies/auto_merge.rs @@ -0,0 +1,27 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; +use octocrab::models::Repository; + +use crate::forges::github::GitHub; +use crate::forges::github::policies::{Policy, PolicyResult}; + +pub struct AutoMerge; + +#[async_trait] +impl Policy for AutoMerge { + async fn evaluate(&self, _client: &GitHub, repo: &Repository) -> anyhow::Result { + if repo.allow_auto_merge.unwrap_or_default() { + tracing::trace!("already has allow auto merge: {:?}", repo.allow_auto_merge); + return Ok(PolicyResult::ok()); + } + + Ok(PolicyResult { + updates: BTreeMap::from([( + "allow_auto_merge".to_string(), + serde_json::Value::Bool(true), + )]), + warnings: Vec::new(), + }) + } +} diff --git a/crates/forge-enforce/src/forges/github/policies/has_codeowners.rs b/crates/forge-enforce/src/forges/github/policies/has_codeowners.rs new file mode 100644 index 0000000..e87052f --- /dev/null +++ b/crates/forge-enforce/src/forges/github/policies/has_codeowners.rs @@ -0,0 +1,31 @@ +use async_trait::async_trait; +use octocrab::models::Repository; + +use crate::forges::github::GitHub; +use crate::forges::github::policies::{Policy, PolicyResult}; + +const CODEOWNERS_PATHS: &[&str] = &["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]; + +pub struct HasCodeowners; + +#[async_trait] +impl Policy for HasCodeowners { + async fn evaluate(&self, client: &GitHub, repo: &Repository) -> anyhow::Result { + for path in CODEOWNERS_PATHS { + if client.file_exists(repo, path).await? { + tracing::trace!( + repo = ?repo.full_name, + path, + "CODEOWNERS file found" + ); + return Ok(PolicyResult::ok()); + } + } + + let repo_name = repo.full_name.as_deref().unwrap_or(&repo.name); + Ok(PolicyResult::warn(format!( + "{repo_name}: no CODEOWNERS file found (checked: {})", + CODEOWNERS_PATHS.join(", ") + ))) + } +} diff --git a/crates/forge-enforce/src/forges/github/policies/squash_merge.rs b/crates/forge-enforce/src/forges/github/policies/squash_merge.rs new file mode 100644 index 0000000..e7d1368 --- /dev/null +++ b/crates/forge-enforce/src/forges/github/policies/squash_merge.rs @@ -0,0 +1,48 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; +use octocrab::models::Repository; +use serde_json::Value; + +use crate::forges::github::GitHub; +use crate::forges::github::policies::{Policy, PolicyResult}; + +pub struct SquashMergeOnly; + +#[async_trait] +impl Policy for SquashMergeOnly { + async fn evaluate(&self, _client: &GitHub, repo: &Repository) -> anyhow::Result { + let mut updates = BTreeMap::new(); + + if !repo.allow_squash_merge.unwrap_or_default() { + tracing::trace!( + "repo requires squash merge update: {:?}", + repo.allow_squash_merge + ); + + updates.insert("allow_squash_merge".to_string(), Value::Bool(true)); + updates.insert( + "squash_merge_commit_title".into(), + Value::String("PR_TITLE".into()), + ); + updates.insert( + "squash_merge_commit_message".into(), + Value::String("PR_BODY".into()), + ); + } + + if !repo.allow_merge_commit.unwrap_or(true) { + tracing::trace!( + "repo requires disabling merge commit: {:?}", + repo.allow_merge_commit + ); + + updates.insert("allow_merge_commit".into(), Value::Bool(false)); + } + + Ok(PolicyResult { + updates, + warnings: Vec::new(), + }) + } +} diff --git a/crates/forge-enforce/src/policies.rs b/crates/forge-enforce/src/policies.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/forge-enforce/src/policies.rs @@ -0,0 +1 @@ + diff --git a/templates/fe/configs/codeowners-check.toml b/templates/fe/configs/codeowners-check.toml new file mode 100644 index 0000000..7958d20 --- /dev/null +++ b/templates/fe/configs/codeowners-check.toml @@ -0,0 +1,16 @@ +#:schema ../schema.json + +mode = "warn" + +[github] +organisation = "understory-io" +credentials.token_env = "GITHUB_ACCESS_TOKEN" + +[filter] +allow = ["^canopy-.*$"] + +[schedule] +once = true + +[policies] +has_codeowners.enabled = true diff --git a/templates/fe/configs/github.com.toml b/templates/fe/configs/github.com.toml index 35086ff..7577b2b 100644 --- a/templates/fe/configs/github.com.toml +++ b/templates/fe/configs/github.com.toml @@ -1,15 +1,18 @@ #:schema ../schema.json +mode = "update" + [github] organisation = "understory-io" credentials.token_env = "GITHUB_ACCESS_TOKEN" [filter] -allow = ["^canopy-.*$"] -deny = ["^infrastructure-.*$", "^canopy-data-gateway$"] +allow = ["^canopy-data-gateway$"] +# deny = ["^infrastructure-.*$", "^canopy-data-gateway$"] [schedule] once = true [policies] squash_merge_only.enabled = true +allow_auto_merge.enabled = true diff --git a/templates/fe/schema.json b/templates/fe/schema.json index 9c10b4e..fa7d046 100644 --- a/templates/fe/schema.json +++ b/templates/fe/schema.json @@ -7,7 +7,10 @@ "$ref": "#/$defs/Filter" }, "policies": { - "$ref": "#/$defs/Policies" + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PolicyOption" + } }, "schedule": { "$ref": "#/$defs/ForgeSchedule" @@ -137,15 +140,6 @@ } ] }, - "Policies": { - "type": "object", - "properties": { - "squash_merge_only": { - "$ref": "#/$defs/PolicyOption" - } - }, - "additionalProperties": false - }, "PolicyOption": { "type": "object", "properties": {