feat: add basic github forge

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-02-03 23:00:39 +01:00
parent 9278fa25e5
commit 41d7b76685
9 changed files with 1480 additions and 62 deletions

View File

@@ -19,3 +19,5 @@ tokio-util = "0.7.18"
async-trait = "0.1.89"
regex = "1.12.3"
toml = "0.9.11"
nodrift = "0.3.5"
octocrab = "0.49.5"

View File

@@ -2,6 +2,7 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ForgeConfig {
#[serde(flatten)]
pub forge_type: ForgeConfigType,
@@ -19,12 +20,14 @@ pub struct ForgeConfig {
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policies {
#[serde(default)]
pub squash_merge_only: PolicyOption,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyOption {
#[serde(default)]
pub enabled: bool,
@@ -38,14 +41,17 @@ fn allow_all() -> Vec<ForgeRegex> {
#[derive(Clone, Debug, Deserialize)]
pub enum ForgeSchedule {
Cron {},
Interval {},
Once {},
#[serde(rename = "cron")]
Cron(String),
#[serde(rename = "interval")]
Interval(String),
#[serde(rename = "once")]
Once(bool),
}
impl Default for ForgeSchedule {
fn default() -> Self {
Self::Interval {}
Self::Interval("60".into())
}
}
@@ -54,6 +60,17 @@ pub struct ForgeRegex {
regex: Regex,
}
impl From<ForgeRegex> for Regex {
fn from(value: ForgeRegex) -> Self {
value.regex
}
}
impl From<&ForgeRegex> for Regex {
fn from(value: &ForgeRegex) -> Self {
value.clone().into()
}
}
impl std::ops::Deref for ForgeRegex {
type Target = Regex;
@@ -83,8 +100,19 @@ impl<'de> Deserialize<'de> for ForgeRegex {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
// #[serde(tag = "type")]
pub enum ForgeConfigType {
#[serde(rename = "github")]
GitHub {},
GitHub {
credentials: GitHubCredentials,
organisation: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum GitHubCredentials {
#[serde(rename = "token")]
Token(String),
#[serde(rename = "token_env")]
TokenEnv(String),
}

View File

@@ -1,11 +1,18 @@
use std::path::Path;
use std::{path::Path, time::Duration};
use anyhow::Context;
use async_trait::async_trait;
use nodrift::Drifter;
use notmad::{Component, MadError};
use tokio_util::sync::CancellationToken;
use crate::{State, forge_config::ForgeConfig};
use crate::{
State,
forge_config::{ForgeConfig, ForgeConfigType, ForgeSchedule, GitHubCredentials},
forges,
};
#[derive(Clone)]
pub struct ForgeService {
name: String,
config: ForgeConfig,
@@ -18,7 +25,71 @@ impl Component for ForgeService {
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
cancellation_token.cancelled().await;
let job_cancel = match &self.config.schedule {
ForgeSchedule::Cron(cron) => nodrift::schedule_drifter_cron(cron, self.clone())?,
ForgeSchedule::Interval(interval) => nodrift::schedule_drifter(
Duration::from_mins(
interval
.parse::<u64>()
.context("interval is not a minute duration")?,
),
self.clone(),
),
ForgeSchedule::Once(enabled) => {
if *enabled {
self.execute(cancellation_token.child_token()).await?;
}
cancellation_token.clone()
}
};
tokio::select! {
_ = cancellation_token.cancelled() => { job_cancel.cancel() },
_ = job_cancel.cancelled() => {},
}
Ok(())
}
}
#[async_trait]
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.allow.iter().map(|a| a.into()).collect(),
self.config.deny.iter().map(|a| a.into()).collect(),
)
.await?;
// 2. Get updates for each
// 3. Update repositories
}
}
Ok(())
}

View File

@@ -0,0 +1 @@
pub mod github;

View File

@@ -0,0 +1,100 @@
use anyhow::Context;
use octocrab::{Octocrab, models::Repository};
use regex::Regex;
pub struct GitHub {
credentials: GitHubCredentials,
client: Octocrab,
}
pub enum GitHubCredentials {
Token(String),
}
impl GitHub {
pub fn new(credentials: GitHubCredentials) -> Self {
let builder = Octocrab::builder();
let builder = match &credentials {
GitHubCredentials::Token(token) => builder.personal_token(token.clone()),
};
let client = builder
.build()
.expect("to be able to create octocrab client");
Self {
credentials,
client,
}
}
pub async fn get_repositories(
&self,
organisation: &str,
allow: Vec<Regex>,
deny: Vec<Regex>,
) -> anyhow::Result<Vec<Repository>> {
tracing::debug!(organisation, "fetching github repositories");
let mut current_page = self
.client
.orgs(organisation)
.list_repos()
.per_page(100)
.send()
.await
.context("failed to get github repositories")?;
let mut repos = current_page.take_items();
let new_repos = self
.client
.all_pages(current_page)
.await
.context("failed to get github pages")?;
repos.extend(new_repos);
let mut allowed_count = 0usize;
let mut denied_count = 0usize;
let fetched_count = repos.len();
let allowed_repos = repos
.into_iter()
.filter(|r| {
for a in &allow {
if a.is_match(&r.name) {
tracing::trace!(name = r.name, rule = a.to_string(), "allowed repository");
allowed_count += 1;
return true;
}
}
false
})
.filter(|r| {
for d in &deny {
if d.is_match(&r.name) {
tracing::trace!(name = r.name, rule = d.to_string(), "denied repository");
denied_count += 1;
return false;
}
}
true
})
.collect::<Vec<_>>();
tracing::debug!(
organisation,
fetched_count,
allowed_count,
denied_count,
total_count = allowed_repos.len(),
"github repositories"
);
Ok(allowed_repos)
}
}

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
mod state;
pub use state::*;
@@ -8,6 +10,8 @@ pub mod servehttp;
mod forge_config;
mod forge_services;
mod forges;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();