diff --git a/.gitignore b/.gitignore index 3325a4e..9e06cb1 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,11 @@ htmlcov/ *.bak # asdf version manager .tool-versions +target/ +node_modules/ +_build/ +deps/ +.elixir_ls/ +.cache/ +build/ +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e2bafd..50304b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ gitbot-fleet/ ├── README.adoc ├── SECURITY.md ├── flake.nix # Nix flake (Perimeter 1) -└── justfile # Task runner (Perimeter 1) +└── Justfile # Task runner (Perimeter 1) ``` --- diff --git a/bots/cipherbot/Cargo.lock b/bots/cipherbot/Cargo.lock index e39866f..9d73676 100644 --- a/bots/cipherbot/Cargo.lock +++ b/bots/cipherbot/Cargo.lock @@ -107,6 +107,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -198,6 +200,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -232,6 +245,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -241,6 +263,18 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -249,26 +283,48 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml", "tracing", "uuid", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.15.5" @@ -314,12 +370,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -364,6 +523,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -433,12 +602,42 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -529,12 +728,33 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -563,6 +783,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -681,6 +907,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -702,6 +937,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -719,6 +960,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -726,7 +978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -761,6 +1013,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.51.0" @@ -770,6 +1032,45 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -843,6 +1144,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -855,7 +1174,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -867,6 +1186,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "walkdir" version = "2.5.0" @@ -1131,6 +1456,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1219,6 +1550,89 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/bots/cipherbot/Cargo.toml b/bots/cipherbot/Cargo.toml index 530ce45..639a34e 100644 --- a/bots/cipherbot/Cargo.toml +++ b/bots/cipherbot/Cargo.toml @@ -24,6 +24,7 @@ clap = { version = "4", features = ["derive"] } # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" # Error handling anyhow = "1" diff --git a/bots/cipherbot/src/policy.rs b/bots/cipherbot/src/policy.rs index deb9e37..a4e1267 100644 --- a/bots/cipherbot/src/policy.rs +++ b/bots/cipherbot/src/policy.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: PMPL-1.0-or-later -//! Policy Engine — reads `.machine_readable/bot_directives/cipherbot.scm` -//! (with legacy `.bot_directives/cipherbot.scm` fallback) for repo-specific -//! crypto policy enforcement. +//! Policy Engine — reads `.machine_readable/bot_directives/cipherbot.a2ml` +//! for repo-specific crypto policy enforcement. //! //! Supports: //! - Minimum hash algorithm requirements @@ -9,12 +8,14 @@ //! - Post-quantum requirement enforcement //! - Maximum key age enforcement //! - Exception lists for legacy compatibility modules +//! +//! The SCM form was retired 2026-04-17; no fallback is supported. use serde::{Deserialize, Serialize}; use std::path::Path; /// Cipherbot policy configuration parsed from -/// `.machine_readable/bot_directives/cipherbot.scm` (legacy fallback supported). +/// `.machine_readable/bot_directives/cipherbot.a2ml`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CipherbotPolicy { /// Bot name (should be "cipherbot"). @@ -53,73 +54,131 @@ impl Default for CipherbotPolicy { } } +/// Raw A2ML shape for cipherbot policy. +#[derive(Debug, Default, Deserialize)] +struct PolicyFile { + #[serde(default)] + bot: Option, + #[serde(default)] + allow: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + mode: Option, + /// Nested [policy] block (lithoglyph-style) or flat top-level keys. + #[serde(default)] + policy: Option, + #[serde(default, rename = "min-hash")] + min_hash_top: Option, + #[serde(default, rename = "min-symmetric")] + min_symmetric_top: Option, + #[serde(default, rename = "require-pq")] + require_pq_top: Option, + #[serde(default, rename = "max-key-age-days")] + max_key_age_days_top: Option, + #[serde(default, rename = "allowed-exceptions")] + allowed_exceptions_top: Option>, +} + +#[derive(Debug, Default, Deserialize)] +struct PolicyBlock { + #[serde(default, rename = "min-hash")] + min_hash: Option, + #[serde(default, rename = "min-symmetric")] + min_symmetric: Option, + #[serde(default, rename = "require-pq")] + require_pq: Option, + #[serde(default, rename = "max-key-age-days")] + max_key_age_days: Option, + #[serde(default, rename = "allowed-exceptions")] + allowed_exceptions: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ScopeField { + One(String), + Many(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum AllowField { + Bool(bool), + Scopes(Vec), +} + impl CipherbotPolicy { - /// Load policy from `.machine_readable/bot_directives/cipherbot.scm` in the - /// given repo root (with legacy fallback). - /// - /// Returns default policy if the file doesn't exist or can't be parsed. + /// Load policy from `.machine_readable/bot_directives/cipherbot.a2ml` in + /// the given repo root. Returns default policy if the file doesn't exist + /// or can't be parsed. pub fn load(repo_root: &Path) -> Self { - let policy_path = if repo_root - .join(".machine_readable/bot_directives/cipherbot.scm") - .exists() - { - repo_root.join(".machine_readable/bot_directives/cipherbot.scm") - } else { - repo_root.join(".bot_directives/cipherbot.scm") - }; + let policy_path = repo_root.join(".machine_readable/bot_directives/cipherbot.a2ml"); if !policy_path.exists() { - tracing::info!("No cipherbot.scm policy found, using defaults"); + tracing::info!("No cipherbot.a2ml policy found, using defaults"); return Self::default(); } match std::fs::read_to_string(&policy_path) { - Ok(content) => Self::parse_scm(&content), + Ok(content) => Self::parse_a2ml(&content).unwrap_or_else(|e| { + tracing::warn!("Failed to parse cipherbot.a2ml: {}", e); + Self::default() + }), Err(e) => { - tracing::warn!("Failed to read cipherbot.scm: {}", e); + tracing::warn!("Failed to read cipherbot.a2ml: {}", e); Self::default() } } } - /// Parse a subset of S-expression policy format. - /// - /// This is a simplified parser that extracts key fields from the - /// bot-directive S-expression format. - fn parse_scm(content: &str) -> Self { + /// Parse cipherbot policy from A2ML/TOML content. + fn parse_a2ml(content: &str) -> Result { + let file: PolicyFile = toml::from_str(content)?; let mut policy = Self::default(); - // Extract simple key-value pairs from the S-expression - for line in content.lines() { - let trimmed = line.trim(); - - if let Some(rest) = trimmed.strip_prefix("(mode .") { - if let Some(mode) = extract_string_value(rest) { - policy.mode = mode; - } - } else if let Some(rest) = trimmed.strip_prefix("(min-hash .") { - if let Some(hash) = extract_string_value(rest) { - policy.min_hash = Some(hash); - } - } else if let Some(rest) = trimmed.strip_prefix("(min-symmetric .") { - if let Some(sym) = extract_string_value(rest) { - policy.min_symmetric = Some(sym); - } - } else if let Some(rest) = trimmed.strip_prefix("(require-pq .") { - policy.require_pq = rest.contains("#t"); - } else if let Some(rest) = trimmed.strip_prefix("(max-key-age-days .") { - if let Some(days) = extract_number_value(rest) { - policy.max_key_age_days = Some(days); - } - } else if let Some(rest) = trimmed.strip_prefix("(allow .") { - policy.allow = rest.contains("#t"); - } else if let Some(rest) = trimmed.strip_prefix("(scope .") { - policy.scope = extract_list_values(rest); - } else if let Some(rest) = trimmed.strip_prefix("(allowed-exceptions .") { - policy.allowed_exceptions = extract_list_values(rest); - } + if let Some(name) = file.bot { + policy.name = name; + } + + policy.allow = match file.allow { + Some(AllowField::Bool(b)) => b, + // List-of-scopes implies allow = true. + Some(AllowField::Scopes(_)) => true, + None => policy.allow, + }; + + if let Some(scope) = file.scope { + policy.scope = match scope { + ScopeField::One(s) => vec![s], + ScopeField::Many(v) => v, + }; + } + + if let Some(mode) = file.mode { + policy.mode = mode; + } + + // Nested [policy] block wins; top-level kebab keys are the fallback + // for single-bot files where the author did not introduce a section. + let pb = file.policy.unwrap_or_default(); + + if let Some(h) = pb.min_hash.or(file.min_hash_top) { + policy.min_hash = Some(h); + } + if let Some(s) = pb.min_symmetric.or(file.min_symmetric_top) { + policy.min_symmetric = Some(s); + } + if let Some(b) = pb.require_pq.or(file.require_pq_top) { + policy.require_pq = b; + } + if let Some(d) = pb.max_key_age_days.or(file.max_key_age_days_top) { + policy.max_key_age_days = Some(d); + } + if let Some(ex) = pb.allowed_exceptions.or(file.allowed_exceptions_top) { + policy.allowed_exceptions = ex; } - policy + Ok(policy) } /// Check if a file path is within the policy scope. @@ -140,35 +199,6 @@ impl CipherbotPolicy { } } -/// Extract a quoted string value from an S-expression fragment. -fn extract_string_value(s: &str) -> Option { - let start = s.find('"')?; - let end = s[start + 1..].find('"')?; - Some(s[start + 1..start + 1 + end].to_string()) -} - -/// Extract a numeric value from an S-expression fragment. -fn extract_number_value(s: &str) -> Option { - let trimmed = s.trim().trim_end_matches(')'); - trimmed.trim().parse().ok() -} - -/// Extract a list of string values from an S-expression fragment. -fn extract_list_values(s: &str) -> Vec { - let mut values = Vec::new(); - let mut rest = s; - while let Some(start) = rest.find('"') { - rest = &rest[start + 1..]; - if let Some(end) = rest.find('"') { - values.push(rest[..end].to_string()); - rest = &rest[end + 1..]; - } else { - break; - } - } - values -} - #[cfg(test)] mod tests { use super::*; @@ -183,25 +213,47 @@ mod tests { } #[test] - fn test_parse_scm() { + fn test_parse_a2ml_nested_policy_block() { let content = r#" -(bot-directive - (name . "cipherbot") - (allow . #t) - (scope . ("src" "lib")) - (mode . "regulator") - (policy - (min-hash . "shake3-512") - (min-symmetric . "xchacha20-poly1305") - (require-pq . #t) - (max-key-age-days . 90) - (allowed-exceptions . ("legacy-compat-module")))) +schema_version = "1.0" +bot = "cipherbot" +allow = true +scope = ["src", "lib"] +mode = "regulator" + +[policy] +min-hash = "shake3-512" +min-symmetric = "xchacha20-poly1305" +require-pq = true +max-key-age-days = 90 +allowed-exceptions = ["legacy-compat-module"] "#; - let policy = CipherbotPolicy::parse_scm(content); + let policy = CipherbotPolicy::parse_a2ml(content).unwrap(); assert_eq!(policy.mode, "regulator"); assert!(policy.require_pq); assert_eq!(policy.max_key_age_days, Some(90)); - assert!(policy.allowed_exceptions.contains(&"legacy-compat-module".to_string())); + assert!(policy + .allowed_exceptions + .contains(&"legacy-compat-module".to_string())); + } + + #[test] + fn test_parse_a2ml_flat_toplevel() { + // A directive where the author used only the top-level scalar keys + // (no nested [policy] section) — migrated from a minimal SCM. + let content = r#" +bot = "cipherbot" +allow = true +scope = "src" +mode = "advisor" +min-hash = "sha256" +require-pq = false +"#; + let policy = CipherbotPolicy::parse_a2ml(content).unwrap(); + assert_eq!(policy.mode, "advisor"); + assert_eq!(policy.scope, vec!["src".to_string()]); + assert_eq!(policy.min_hash, Some("sha256".to_string())); + assert!(!policy.require_pq); } #[test] @@ -219,22 +271,4 @@ mod tests { assert!(policy.is_exception(Path::new("src/legacy-compat/old.rs"))); assert!(!policy.is_exception(Path::new("src/main.rs"))); } - - #[test] - fn test_extract_string_value() { - assert_eq!(extract_string_value(r#" "hello")"#), Some("hello".to_string())); - assert_eq!(extract_string_value("no quotes"), None); - } - - #[test] - fn test_extract_number_value() { - assert_eq!(extract_number_value(" 90)"), Some(90)); - assert_eq!(extract_number_value(" abc)"), None); - } - - #[test] - fn test_extract_list_values() { - let values = extract_list_values(r#"("src" "lib" "bin"))"#); - assert_eq!(values, vec!["src", "lib", "bin"]); - } } diff --git a/bots/echidnabot/CHANGELOG.adoc b/bots/echidnabot/CHANGELOG.adoc index b810766..e738c63 100644 --- a/bots/echidnabot/CHANGELOG.adoc +++ b/bots/echidnabot/CHANGELOG.adoc @@ -11,7 +11,7 @@ All notable changes to echidnabot are documented here. === Changed -==== VQL → VCL + verisimdb → verisim Rename (2026-04-05) +==== VCL → VCL + verisimdb → verisim Rename (2026-04-05) Matches the ecosystem-wide rename. Internal identifiers updated, `github.com/hyperpolymath/verisimdb` URLs preserved. diff --git a/bots/echidnabot/Cargo.lock b/bots/echidnabot/Cargo.lock index 425001c..c3dd667 100644 --- a/bots/echidnabot/Cargo.lock +++ b/bots/echidnabot/Cargo.lock @@ -506,6 +506,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1554,21 +1556,43 @@ dependencies = [ "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml", "tracing", "uuid", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -2060,6 +2084,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -2168,6 +2202,18 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -2197,6 +2243,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" diff --git a/bots/echidnabot/RELEASE_CHECKLIST.md b/bots/echidnabot/RELEASE_CHECKLIST.md index 3829688..97979f2 100644 --- a/bots/echidnabot/RELEASE_CHECKLIST.md +++ b/bots/echidnabot/RELEASE_CHECKLIST.md @@ -7,7 +7,7 @@ Complete checklist for making echidnabot a perfect release. ### Done - [x] README.adoc - SEO-optimized, project-focused - [x] BRANDING.md - Visual identity and LLM art prompts -- [x] justfile - RSR canonical task runner +- [x] Justfile - RSR canonical task runner - [x] Nickel configuration (config/echidnabot.ncl) - [x] MCP configuration (.claude/settings/mcp.json) - [x] STATE.scm - Project checkpoint @@ -137,7 +137,7 @@ Complete checklist for making echidnabot a perfect release. - [x] Cargo.toml metadata complete - [x] guix.scm package definition - [x] Containerfile for Docker/Podman -- [x] justfile for task automation +- [x] Justfile for task automation ### To Add - [ ] flake.nix for Nix users diff --git a/bots/echidnabot/RSR_COMPLIANCE.adoc b/bots/echidnabot/RSR_COMPLIANCE.adoc index afed190..ae9660b 100644 --- a/bots/echidnabot/RSR_COMPLIANCE.adoc +++ b/bots/echidnabot/RSR_COMPLIANCE.adoc @@ -51,7 +51,7 @@ This document describes the Rhodium Standard Repository (RSR) compliance status |No restricted languages outside exemptions |✓ |Only Rust and Guile Scheme |.editorconfig present |✓ |Line length 100, spaces indent |.well-known/ directory |✓ |security.txt, ai.txt, humans.txt, consent-required.txt -|justfile present |✗ |TODO: Add justfile for task running +|justfile present |✗ |TODO: Add Justfile for task running |LICENSE.txt (AGPL + Palimpsest) |✓ |PMPL-1.0-or-later |Containerfile present |✓ |Multi-stage build with Chainguard base |guix.scm present |✓ |Guix package definition (primary) @@ -79,7 +79,7 @@ None == Action Items -* [ ] Add justfile for task running +* [ ] Add Justfile for task running * [ ] Add flake.nix for Nix users (fallback package manager) == References diff --git a/bots/echidnabot/SESSION_SUMMARY_2026-01-29.md b/bots/echidnabot/SESSION_SUMMARY_2026-01-29.md index aa9fe9d..621efda 100644 --- a/bots/echidnabot/SESSION_SUMMARY_2026-01-29.md +++ b/bots/echidnabot/SESSION_SUMMARY_2026-01-29.md @@ -27,7 +27,7 @@ cargo clean && cargo build **Problem:** Cargo.toml had incorrect author email ```toml -authors = ["Jonathan D.A. Jewell "] # WRONG +authors = ["Jonathan D.A. Jewell "] # WRONG ``` **Solution:** diff --git a/bots/echidnabot/flake.lock b/bots/echidnabot/flake.lock new file mode 100644 index 0000000..d8fe172 --- /dev/null +++ b/bots/echidnabot/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776395632, + "narHash": "sha256-Mi1uF5f2FsdBIvy+v7MtsqxD3Xjhd0ARJdwoqqqPtJo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8087ff1f47fff983a1fba70fa88b759f2fd8ae97", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/bots/echidnabot/src/adapters/github.rs b/bots/echidnabot/src/adapters/github.rs index e414f81..fc9db95 100644 --- a/bots/echidnabot/src/adapters/github.rs +++ b/bots/echidnabot/src/adapters/github.rs @@ -102,6 +102,13 @@ impl PlatformAdapter for GitHubAdapter { } async fn create_check_run(&self, repo: &RepoId, check: CheckRun) -> Result { + let full = format!("{}/{}", repo.owner, repo.name); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateCheckRun, + ) + .map_err(|e| Error::GitHub(e.to_string()))?; + let checks = self.client.checks(&repo.owner, &repo.name); use octocrab::params::checks::{CheckRunConclusion as OctoConclusion, CheckRunStatus as OctoStatus}; @@ -149,6 +156,13 @@ impl PlatformAdapter for GitHubAdapter { } async fn create_comment(&self, repo: &RepoId, pr: PrId, body: &str) -> Result { + let full = format!("{}/{}", repo.owner, repo.name); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateCheckRun, + ) + .map_err(|e| Error::GitHub(e.to_string()))?; + let pr_num: u64 = pr.0.parse().map_err(|_| Error::GitHub("Invalid PR ID".to_string()))?; let comment = self @@ -162,6 +176,13 @@ impl PlatformAdapter for GitHubAdapter { } async fn create_issue(&self, repo: &RepoId, issue: NewIssue) -> Result { + let full = format!("{}/{}", repo.owner, repo.name); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateIssue, + ) + .map_err(|e| Error::GitHub(e.to_string()))?; + let created = self .client .issues(&repo.owner, &repo.name) diff --git a/bots/echidnabot/src/api/webhooks.rs b/bots/echidnabot/src/api/webhooks.rs index 27701a8..7c101ee 100644 --- a/bots/echidnabot/src/api/webhooks.rs +++ b/bots/echidnabot/src/api/webhooks.rs @@ -10,7 +10,7 @@ use axum::{ routing::post, Router, }; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, Mac, KeyInit}; use sha2::Sha256; use std::sync::Arc; diff --git a/bots/echidnabot/src/dispatcher/mod.rs b/bots/echidnabot/src/dispatcher/mod.rs index 5514d74..b9c6f7b 100644 --- a/bots/echidnabot/src/dispatcher/mod.rs +++ b/bots/echidnabot/src/dispatcher/mod.rs @@ -29,7 +29,35 @@ pub enum ProofStatus { Unknown, } -/// Prover kind matching ECHIDNA's supported backends +/// Prover kind matching ECHIDNA's supported backends. +/// +/// # DRIFT NOTICE (2026-04-17) +/// +/// This local mirror carries **12 variants**, covering only the classic +/// Tier 1–3 backends. Upstream `echidna::provers::ProverKind` (see +/// `verification-ecosystem/echidna/src/rust/provers/mod.rs`) now carries +/// **89 variants** as of commit `8f573f1`: the classic 49 solver backends +/// plus the 40-variant HP-ecosystem TypeDiscipline family (linear, affine, +/// phantom, modal, tropical, epistemic, choreographic, …). +/// +/// The mirror is intentionally NOT extended here today. Compile-time effect +/// is zero — this enum stands on its own, and `from_extension` / `tier` / +/// `display_name` / `file_extensions` are all total over its 12 variants. +/// Runtime effect IS present: proofs tagged with an HP-ecosystem discipline +/// that reaches echidnabot will be routed as "unrecognised" by +/// `from_extension`, even though the upstream echidna dispatcher would +/// handle them fine. +/// +/// Phase-2 decision (tracked in AI-WORK-todo.md): either (a) extend this +/// mirror to the full 89 variants with a code-generation tool that reads +/// echidna's enum at build time, or (b) switch echidnabot to a path-dep on +/// echidna so `ProverKind` is shared by construction. Option (b) is cleaner +/// but requires breaking the current self-contained deployment. Option (a) +/// is a stepping stone. +/// +/// See `standards/testing-and-benchmarking/TESTING-TAXONOMY.adoc` § Part VI +/// CR-1 "Mirror-enum drift test" and CR-2 "Foreign-enum exhaustive-match +/// lint" for the general pattern and the proposed estate-wide fix. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProverKind { @@ -48,6 +76,13 @@ pub enum ProverKind { Pvs, Acl2, Hol4, + // Intentional gap vs echidna upstream — see DRIFT NOTICE above. + // NOT added here: FStar, Dafny, Why3, TLAPS, Twelf, Nuprl, Minlog, + // Imandra, Vampire, EProver, SPASS, AltErgo, GLPK, SCIP, MiniZinc, + // Chuffed, ORTools, TypedWasm, SPIN, CBMC, SeaHorn, CaDiCaL, Kissat, + // MiniSat, NuSMV, TLC, Alloy, Prism, UPPAAL, FramaC, Viper, Tamarin, + // ProVerif, KeY, DReal, ABC, Idris2 (the 37 classic backends beyond + // Tier 1–3) + the 40 HP-ecosystem TypeDiscipline variants. } impl ProverKind { diff --git a/bots/finishingbot/Cargo.lock b/bots/finishingbot/Cargo.lock index 33ffec5..b7b99e3 100644 --- a/bots/finishingbot/Cargo.lock +++ b/bots/finishingbot/Cargo.lock @@ -634,12 +634,15 @@ name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml", "tracing", "uuid", ] diff --git a/bots/finishingbot/src/analyzers/release.rs b/bots/finishingbot/src/analyzers/release.rs index 2787d0a..274e23b 100644 --- a/bots/finishingbot/src/analyzers/release.rs +++ b/bots/finishingbot/src/analyzers/release.rs @@ -359,11 +359,11 @@ impl ReleaseAnalyzer { { let mut hasher = Sha512::new(); hasher.update(&file_content); - format!("{:x}", hasher.finalize()) + hex::encode(hasher.finalize()) } else { let mut hasher = Sha256::new(); hasher.update(&file_content); - format!("{:x}", hasher.finalize()) + hex::encode(hasher.finalize()) }; if actual_hash.to_lowercase() != expected_hash.to_lowercase() { diff --git a/bots/gsbot/RSR.md b/bots/gsbot/RSR.md index 1d648a4..c096a73 100644 --- a/bots/gsbot/RSR.md +++ b/bots/gsbot/RSR.md @@ -28,7 +28,7 @@ We currently achieve **Bronze-level** RSR compliance for a Python project. ### ✅ Build System (Complete) -- ✅ justfile - Just build recipes (20+ commands) +- ✅ Justfile - Just build recipes (20+ commands) - ✅ Makefile - Make build recipes (traditional alternative) - ✅ setup.py - Python package configuration - ✅ requirements.txt - Dependency management @@ -109,7 +109,7 @@ We currently achieve **Bronze-level** RSR compliance for a Python project. **Required:** - ✅ All documentation files - ✅ .well-known/ directory -- ✅ Build system (justfile or Makefile) +- ✅ Build system (Justfile or Makefile) - ✅ CI/CD pipeline - ✅ Test suite with >80% coverage - ✅ TPCF declaration @@ -144,7 +144,7 @@ We currently achieve **Bronze-level** RSR compliance for a Python project. | Category | Status | Notes | |----------|--------|-------| | Documentation | ✅ Complete | 7 core docs + 3 .well-known | -| Build System | ✅ Complete | justfile + Makefile | +| Build System | ✅ Complete | Justfile + Makefile | | CI/CD | ✅ Complete | GitHub Actions, 4 Python versions | | Testing | ✅ Complete | Unit + integration, >80% coverage | | TPCF | ✅ Complete | Perimeter 3 declared | @@ -169,7 +169,7 @@ Or manually: ls -la *.md .well-known/ # Check build system -ls -la justfile Makefile setup.py +ls -la Justfile Makefile setup.py # Check tests pytest tests/ -v @@ -289,7 +289,7 @@ These are inherent to the bot's purpose and documented. - ✅ Bronze-level RSR compliance achieved - ✅ All required documentation - ✅ .well-known/ directory complete -- ✅ Build system (justfile + Makefile) +- ✅ Build system (Justfile + Makefile) - ✅ CI/CD pipeline operational - ✅ Test suite with good coverage - ✅ TPCF Perimeter 3 declared diff --git a/bots/panicbot/Cargo.lock b/bots/panicbot/Cargo.lock index 7687d34..9831b1c 100644 --- a/bots/panicbot/Cargo.lock +++ b/bots/panicbot/Cargo.lock @@ -107,6 +107,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -182,6 +184,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -216,6 +229,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -225,6 +247,18 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -233,26 +267,48 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml 1.1.2+spec-1.1.0", "tracing", "uuid", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.15.5" @@ -298,12 +354,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -348,6 +507,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -417,12 +586,42 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -524,17 +723,39 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml 0.8.23", "tracing", "tracing-subscriber", "uuid", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -563,6 +784,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -669,6 +896,24 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -690,6 +935,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -707,6 +958,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -714,7 +976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -749,6 +1011,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.51.0" @@ -758,6 +1030,86 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -831,6 +1183,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -843,7 +1213,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -855,6 +1225,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "walkdir" version = "2.5.0" @@ -1119,6 +1495,21 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1207,6 +1598,89 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/bots/panicbot/Cargo.toml b/bots/panicbot/Cargo.toml index 2ff3066..c8d92bf 100644 --- a/bots/panicbot/Cargo.toml +++ b/bots/panicbot/Cargo.toml @@ -24,6 +24,7 @@ clap = { version = "4", features = ["derive"] } # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" # Error handling anyhow = "1" diff --git a/bots/panicbot/src/directives.rs b/bots/panicbot/src/directives.rs index 98e1fbd..b3c892c 100644 --- a/bots/panicbot/src/directives.rs +++ b/bots/panicbot/src/directives.rs @@ -1,33 +1,37 @@ // SPDX-License-Identifier: PMPL-1.0-or-later -//! Directives — per-repo configuration parser for `.machine_readable/bot_directives/panicbot.scm`. +//! Directives — per-repo configuration parser for `.machine_readable/bot_directives/panicbot.a2ml`. //! -//! Each repository can customise panicbot's behaviour via a Scheme-formatted -//! directive file. If no directive file exists, safe defaults are used: +//! Each repository can customise panicbot's behaviour via a TOML-shaped A2ML +//! file. If no directive file exists, safe defaults are used: //! - Allow: `assail`, `adjudicate`, `diagnostics` (static analysis only) //! - Deny: all dynamic attack modes (`attack`, `assault`, `ambush`, etc.) //! - Min severity: `low` (report everything) //! - Timeout: 300 seconds //! -//! ## Directive Format +//! ## Directive Format (A2ML / TOML-shaped) //! -//! ```scheme -//! (bot-directive -//! (bot "panicbot") -//! (scope "static-analysis") -//! (allow ("assail" "adjudicate" "diagnostics")) -//! (deny ("attack" "assault" "ambush" "amuck" "abduct" "axial")) -//! (config -//! (min-severity "low") -//! (timeout-seconds 300))) +//! ```toml +//! schema_version = "1.0" +//! bot = "panicbot" +//! scope = "static-analysis" +//! allow = ["assail", "adjudicate", "diagnostics"] +//! deny = ["attack", "assault", "ambush", "amuck", "abduct", "axial"] +//! +//! [config] +//! min-severity = "low" +//! timeout-seconds = 300 //! ``` +//! +//! The SCM form was retired 2026-04-17; no fallback is supported. use crate::config::{AllowedCommands, MinSeverity, PanicbotConfig}; use anyhow::{Context, Result}; +use serde::Deserialize; use std::path::Path; use std::time::Duration; /// Path to the bot directive file within a repository. -const DIRECTIVE_PATH: &str = ".machine_readable/bot_directives/panicbot.scm"; +const DIRECTIVE_PATH: &str = ".machine_readable/bot_directives/panicbot.a2ml"; /// Load panicbot configuration from a repository's directive file. /// @@ -52,17 +56,34 @@ pub fn load_directives(repo_path: &Path) -> Result { .with_context(|| format!("Failed to parse {}", directive_file.display())) } -/// Parse a panicbot directive from its Scheme-formatted content. -/// -/// This is a lightweight parser that extracts key-value pairs from the -/// S-expression format without requiring a full Scheme interpreter. -/// We look for specific patterns rather than doing a full parse. +/// Raw A2ML shape. +#[derive(Debug, Default, Deserialize)] +struct DirectiveFile { + #[serde(default)] + allow: Option>, + #[serde(default)] + config: Option, + #[serde(default, rename = "min-severity")] + min_severity_top: Option, + #[serde(default, rename = "timeout-seconds")] + timeout_seconds_top: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct ConfigBlock { + #[serde(default, rename = "min-severity")] + min_severity: Option, + #[serde(default, rename = "timeout-seconds")] + timeout_seconds: Option, +} + +/// Parse a panicbot directive from its A2ML/TOML content. fn parse_directive(content: &str) -> Result { let mut config = PanicbotConfig::default(); + let file: DirectiveFile = toml::from_str(content).context("Failed to parse TOML")?; - // Extract allow list - if let Some(allow_str) = extract_list(content, "allow") { - let allowed: Vec = parse_string_list(&allow_str); + // Allow list + if let Some(allowed) = file.allow { config.allowed_commands = AllowedCommands { assail: allowed.iter().any(|s| s == "assail"), adjudicate: allowed.iter().any(|s| s == "adjudicate"), @@ -70,99 +91,33 @@ fn parse_directive(content: &str) -> Result { }; } - // Extract min-severity from config block - if let Some(severity_str) = extract_config_value(content, "min-severity") { - if let Some(severity) = MinSeverity::from_str(&severity_str) { - config.min_severity = severity; + // min-severity: prefer [config] block, fall back to top-level kebab key. + let severity_str = file + .config + .as_ref() + .and_then(|c| c.min_severity.clone()) + .or(file.min_severity_top); + if let Some(s) = severity_str { + if let Some(sev) = MinSeverity::from_str(&s) { + config.min_severity = sev; } else { - tracing::warn!("Unknown min-severity '{}', using default", severity_str); + tracing::warn!("Unknown min-severity '{}', using default", s); } } - // Extract timeout from config block - if let Some(timeout_str) = extract_config_value(content, "timeout-seconds") { - if let Ok(seconds) = timeout_str.parse::() { - config.timeout = Duration::from_secs(seconds); - } else { - tracing::warn!("Invalid timeout-seconds '{}', using default", timeout_str); - } + // timeout-seconds: same precedence. + let timeout_seconds = file + .config + .as_ref() + .and_then(|c| c.timeout_seconds) + .or(file.timeout_seconds_top); + if let Some(secs) = timeout_seconds { + config.timeout = Duration::from_secs(secs); } Ok(config) } -/// Extract a parenthesised list value for a given key. -/// -/// For input `(allow ("assail" "adjudicate"))`, returns `"assail" "adjudicate"`. -fn extract_list(content: &str, key: &str) -> Option { - let pattern = format!("({}", key); - let start = content.find(&pattern)?; - let rest = &content[start + pattern.len()..]; - - // Find the opening paren of the list - let list_start = rest.find('(')?; - let list_content = &rest[list_start + 1..]; - - // Find the matching closing paren - let list_end = list_content.find(')')?; - Some(list_content[..list_end].to_string()) -} - -/// Extract a config value for a given key from a `(config ...)` block. -/// -/// For input `(config (min-severity "low"))`, with key "min-severity", -/// returns "low". -fn extract_config_value(content: &str, key: &str) -> Option { - let pattern = format!("({}", key); - let start = content.find(&pattern)?; - let rest = &content[start + pattern.len()..]; - - // Find the value — could be a quoted string or a bare number - let trimmed = rest.trim_start(); - - if trimmed.starts_with('"') { - // Quoted string value - let after_quote = &trimmed[1..]; - let end_quote = after_quote.find('"')?; - Some(after_quote[..end_quote].to_string()) - } else { - // Bare value (number, identifier) - let end = trimmed.find(|c: char| c == ')' || c.is_whitespace())?; - Some(trimmed[..end].to_string()) - } -} - -/// Parse a space-separated list of quoted strings. -/// -/// Input: `"assail" "adjudicate" "diagnostics"` -/// Output: `["assail", "adjudicate", "diagnostics"]` -fn parse_string_list(input: &str) -> Vec { - let mut result = Vec::new(); - let mut chars = input.chars().peekable(); - - while let Some(&c) = chars.peek() { - if c == '"' { - chars.next(); // consume opening quote - let mut s = String::new(); - while let Some(&c2) = chars.peek() { - if c2 == '"' { - chars.next(); // consume closing quote - break; - } - s.push(c2); - chars.next(); - } - if !s.is_empty() { - result.push(s); - } - } else { - chars.next(); // skip whitespace/other - } - } - - result -} - #[cfg(test)] mod tests { use super::*; @@ -170,16 +125,15 @@ mod tests { #[test] fn test_parse_default_directive() { let content = r#" -;; SPDX-License-Identifier: PMPL-1.0-or-later - -(bot-directive - (bot "panicbot") - (scope "static-analysis") - (allow ("assail" "adjudicate" "diagnostics")) - (deny ("attack" "assault" "ambush" "amuck" "abduct" "axial")) - (config - (min-severity "low") - (timeout-seconds 300))) +schema_version = "1.0" +bot = "panicbot" +scope = "static-analysis" +allow = ["assail", "adjudicate", "diagnostics"] +deny = ["attack", "assault", "ambush", "amuck", "abduct", "axial"] + +[config] +min-severity = "low" +timeout-seconds = 300 "#; let config = parse_directive(content).unwrap(); @@ -193,13 +147,12 @@ mod tests { #[test] fn test_parse_restricted_directive() { let content = r#" -(bot-directive - (bot "panicbot") - (scope "static-analysis") - (allow ("assail")) - (config - (min-severity "high") - (timeout-seconds 60))) +bot = "panicbot" +allow = ["assail"] + +[config] +min-severity = "high" +timeout-seconds = 60 "#; let config = parse_directive(content).unwrap(); @@ -213,7 +166,7 @@ mod tests { #[test] fn test_parse_empty_content() { let config = parse_directive("").unwrap(); - // Should return defaults + // Should return defaults (TOML parses empty as empty struct) assert!(config.allowed_commands.assail); assert!(config.allowed_commands.adjudicate); assert!(config.allowed_commands.diagnostics); @@ -221,37 +174,18 @@ mod tests { } #[test] - fn test_extract_list() { - let content = r#"(allow ("assail" "adjudicate"))"#; - let list = extract_list(content, "allow").unwrap(); - assert_eq!(list, r#""assail" "adjudicate""#); - } - - #[test] - fn test_extract_config_value_string() { - let content = r#"(min-severity "high")"#; - let val = extract_config_value(content, "min-severity").unwrap(); - assert_eq!(val, "high"); - } - - #[test] - fn test_extract_config_value_number() { - let content = r#"(timeout-seconds 600)"#; - let val = extract_config_value(content, "timeout-seconds").unwrap(); - assert_eq!(val, "600"); - } - - #[test] - fn test_parse_string_list() { - let input = r#""assail" "adjudicate" "diagnostics""#; - let result = parse_string_list(input); - assert_eq!(result, vec!["assail", "adjudicate", "diagnostics"]); - } + fn test_parse_top_level_kebab_fallback() { + // Some older files flatten min-severity to the top level. + let content = r#" +bot = "panicbot" +allow = ["assail", "diagnostics"] +min-severity = "critical" +timeout-seconds = 120 +"#; - #[test] - fn test_parse_string_list_empty() { - let result = parse_string_list(""); - assert!(result.is_empty()); + let config = parse_directive(content).unwrap(); + assert_eq!(config.min_severity, MinSeverity::Critical); + assert_eq!(config.timeout, Duration::from_secs(120)); } #[test] diff --git a/bots/rhodibot/Cargo.lock b/bots/rhodibot/Cargo.lock index 9f359af..de2d22c 100644 --- a/bots/rhodibot/Cargo.lock +++ b/bots/rhodibot/Cargo.lock @@ -84,9 +84,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -115,9 +115,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -179,9 +179,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -232,11 +232,13 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -268,9 +270,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -290,9 +292,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -339,6 +341,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -357,6 +368,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -385,6 +402,20 @@ dependencies = [ "cmov", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -413,17 +444,6 @@ dependencies = [ "crypto-common 0.1.7", ] -[[package]] -name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", - "ctutils", -] - [[package]] name = "digest" version = "0.11.2" @@ -433,6 +453,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -495,9 +516,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -690,17 +711,33 @@ dependencies = [ "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml", "tracing", "uuid", ] @@ -712,13 +749,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" dependencies = [ "gix-actor", + "gix-archive", "gix-attributes", + "gix-blame", "gix-command", "gix-commitgraph", "gix-config", "gix-credentials", "gix-date", "gix-diff", + "gix-dir", "gix-discover", "gix-error", "gix-features", @@ -730,6 +770,7 @@ dependencies = [ "gix-ignore", "gix-index", "gix-lock", + "gix-merge", "gix-negotiate", "gix-object", "gix-odb", @@ -744,6 +785,7 @@ dependencies = [ "gix-revwalk", "gix-sec", "gix-shallow", + "gix-status", "gix-submodule", "gix-tempfile", "gix-trace", @@ -753,6 +795,8 @@ dependencies = [ "gix-utils", "gix-validate", "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", "nonempty", "smallvec", "thiserror", @@ -770,6 +814,19 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "gix-archive" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + [[package]] name = "gix-attributes" version = "0.31.0" @@ -796,6 +853,26 @@ dependencies = [ "gix-error", ] +[[package]] +name = "gix-blame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", +] + [[package]] name = "gix-chunk" version = "0.7.0" @@ -858,7 +935,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-path", "libc", @@ -903,8 +980,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" dependencies = [ "bstr", + "gix-command", + "gix-filter", + "gix-fs", "gix-hash", "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "imara-diff 0.1.8", + "imara-diff 0.2.0", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", "thiserror", ] @@ -938,6 +1045,7 @@ version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" dependencies = [ + "bytes", "crc32fast", "gix-path", "gix-trace", @@ -991,7 +1099,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-features", "gix-path", @@ -1005,6 +1113,7 @@ checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" dependencies = [ "faster-hex", "gix-features", + "sha1-checked", "thiserror", ] @@ -1038,7 +1147,7 @@ version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "filetime", "fnv", @@ -1071,13 +1180,39 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-merge" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "imara-diff 0.1.8", + "nonempty", + "thiserror", +] + [[package]] name = "gix-negotiate" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1177,7 +1312,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bstr", "gix-attributes", "gix-config-value", @@ -1280,6 +1415,7 @@ version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" dependencies = [ + "bitflags 2.11.1", "bstr", "gix-commitgraph", "gix-date", @@ -1287,6 +1423,7 @@ dependencies = [ "gix-hash", "gix-object", "gix-revwalk", + "gix-trace", "nonempty", ] @@ -1312,7 +1449,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-path", "libc", "windows-sys 0.61.2", @@ -1331,6 +1468,29 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-status" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror", +] + [[package]] name = "gix-submodule" version = "0.28.0" @@ -1352,6 +1512,7 @@ version = "21.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" dependencies = [ + "dashmap", "gix-fs", "libc", "parking_lot", @@ -1386,7 +1547,7 @@ version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1415,6 +1576,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" dependencies = [ + "bstr", "fastrand", "unicode-normalization", ] @@ -1446,6 +1608,48 @@ dependencies = [ "gix-validate", ] +[[package]] +name = "gix-worktree-state" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -1474,6 +1678,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1494,6 +1704,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heapless" version = "0.8.0" @@ -1609,15 +1825,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1780,14 +1995,33 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imara-diff" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1798,7 +2032,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -1812,6 +2046,16 @@ dependencies = [ "libc", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1881,11 +2125,21 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -1957,20 +2211,44 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] @@ -2093,7 +2371,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", @@ -2111,7 +2389,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -2189,6 +2467,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plain" version = "0.2.3" @@ -2203,9 +2487,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -2334,9 +2618,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -2367,16 +2651,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -2501,7 +2785,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2510,9 +2794,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "once_cell", "ring", @@ -2534,9 +2818,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", @@ -2572,9 +2856,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2651,6 +2935,27 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2658,8 +2963,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest 0.10.7", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2839,9 +3144,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2971,7 +3276,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -3142,9 +3447,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3158,6 +3463,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3209,9 +3520,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3222,9 +3533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3232,9 +3543,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3242,9 +3553,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -3255,9 +3566,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -3290,7 +3601,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -3298,9 +3609,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3318,13 +3629,29 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3334,6 +3661,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3645,7 +3978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", diff --git a/bots/rhodibot/Cargo.toml b/bots/rhodibot/Cargo.toml index 0095ba4..6c69424 100644 --- a/bots/rhodibot/Cargo.toml +++ b/bots/rhodibot/Cargo.toml @@ -32,7 +32,7 @@ toml = "1.1" # Crypto for webhook verification hmac = "0.13" -sha2 = "0.10" +sha2 = "0.11" hex = "0.4" # Logging @@ -50,7 +50,7 @@ anyhow = "1" clap = { version = "4", features = ["derive", "env"] } # Git operations -gix = { version = "0.81", default-features = false, features = ["blocking-network-client"] } +gix = { version = "0.81", default-features = false, features = ["blocking-network-client", "sha1"] } # Time chrono = { version = "0.4", features = ["serde"] } diff --git a/bots/rhodibot/README.adoc b/bots/rhodibot/README.adoc index 25ff9d0..df24e3e 100644 --- a/bots/rhodibot/README.adoc +++ b/bots/rhodibot/README.adoc @@ -104,7 +104,7 @@ Each RSR check has a severity (Required, Recommended, Optional) that varies by p | .editorconfig | Optional | Recommended | Required | Required | .gitattributes | Optional | Recommended | Required | Required | .gitignore | Optional | Recommended | Required | Required -| justfile | Optional | Optional | Recommended | Required +| Justfile | Optional | Optional | Recommended | Required | .bot_directives | Optional | Optional | Recommended | Required |=== @@ -200,7 +200,7 @@ Options: | .editorconfig | Structure | 2 | .gitattributes | Structure | 2 | .gitignore | Structure | 2 -| justfile | Structure | 2 +| Justfile | Structure | 2 | .bot_directives | Structure | 2 |=== diff --git a/bots/rhodibot/src/github.rs b/bots/rhodibot/src/github.rs index dd684c6..09b3c0c 100644 --- a/bots/rhodibot/src/github.rs +++ b/bots/rhodibot/src/github.rs @@ -133,6 +133,13 @@ impl GitHubClient { body: &str, labels: &[&str], ) -> Result { + let full = format!("{}/{}", owner, repo); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateIssue, + ) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let url = format!("{}/repos/{}/{}/issues", self.base_url, owner, repo); let mut request = self.client.post(&url); @@ -163,6 +170,13 @@ impl GitHubClient { repo: &str, check_run: &CreateCheckRun, ) -> Result { + let full = format!("{}/{}", owner, repo); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateCheckRun, + ) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let url = format!("{}/repos/{}/{}/check-runs", self.base_url, owner, repo); let mut request = self.client.post(&url); diff --git a/bots/rhodibot/src/webhook.rs b/bots/rhodibot/src/webhook.rs index f0f9d2f..78fd7be 100644 --- a/bots/rhodibot/src/webhook.rs +++ b/bots/rhodibot/src/webhook.rs @@ -11,7 +11,7 @@ //! - User-controlled content is sanitized before inclusion in markdown output. use anyhow::Result; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, Mac, KeyInit}; use serde::Deserialize; use sha2::Sha256; use tracing::{info, warn}; diff --git a/bots/seambot/Cargo.lock b/bots/seambot/Cargo.lock index 7b0cd89..0d7ed04 100644 --- a/bots/seambot/Cargo.lock +++ b/bots/seambot/Cargo.lock @@ -176,6 +176,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -530,17 +532,33 @@ dependencies = [ "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", + "git2", + "glob", "lexpr", "notify", "serde", "serde_json", "thiserror", "tokio", + "toml", "tracing", "uuid", ] @@ -925,6 +943,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -1015,12 +1043,36 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1256,6 +1308,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2252,6 +2310,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/bots/seambot/Justfile b/bots/seambot/Justfile new file mode 100644 index 0000000..4793144 --- /dev/null +++ b/bots/seambot/Justfile @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Justfile - hyperpolymath standard task runner + +default: + @just --list + +# Build the project +build: + @echo "Building..." + +# Run tests +test: + @echo "Testing..." + +# Run lints +lint: + @echo "Linting..." + +# Clean build artifacts +clean: + @echo "Cleaning..." + +# Format code +fmt: + @echo "Formatting..." + +# Run all checks +check: lint test + +# Prepare a release +release VERSION: + @echo "Releasing {{VERSION}}..." + diff --git a/bots/seambot/src/github.rs b/bots/seambot/src/github.rs index 3ae5cda..d7064e2 100644 --- a/bots/seambot/src/github.rs +++ b/bots/seambot/src/github.rs @@ -275,6 +275,12 @@ impl GitHubClient { head_sha: &str, name: &str, ) -> Result { + let full = format!("{}/{}", owner, repo); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateCheckRun, + )?; + let token = self.get_installation_token().await?; let url = format!( @@ -519,6 +525,12 @@ impl GitHubClient { pr_number: u64, result: &CheckResult, ) -> Result { + let full = format!("{}/{}", owner, repo); + gitbot_shared_context::registry_guard::check_github_write( + &full, + gitbot_shared_context::ExclusionAction::CreateCheckRun, + )?; + let token = self.get_installation_token().await?; let url = format!( diff --git a/bots/sustainabot/Cargo.toml b/bots/sustainabot/Cargo.toml index 03e5457..64d9fec 100644 --- a/bots/sustainabot/Cargo.toml +++ b/bots/sustainabot/Cargo.toml @@ -46,8 +46,6 @@ tokio = { version = "1.42", features = ["full"] } # Carbon APIs reqwest = { version = "0.12", features = ["json"] } -# S-expression parsing (for .bot_directives/*.scm) -lexpr = "0.2" [profile.release] lto = true diff --git a/bots/sustainabot/Justfile b/bots/sustainabot/Justfile new file mode 100644 index 0000000..0069518 --- /dev/null +++ b/bots/sustainabot/Justfile @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Justfile - hyperpolymath standard task runner + +default: + @just --list + +# Build the project +build: + @echo "Building..." + +# Run tests +test: + @echo "Testing..." + +# Run lints +lint: + @echo "Linting..." + +# Clean build artifacts +clean: + @echo "Cleaning..." + +# Format code +fmt: + @echo "Formatting..." + +# Run all checks +check: lint test + +# Prepare a release +release VERSION: + @echo "Releasing {{VERSION}}..." + +# Trigger automated checking + +github-scorecard: + @echo "Run manually: https://github.com/ossf/scorecard" + + diff --git a/bots/sustainabot/bot-integration/lib/bs/.sourcedirs.json b/bots/sustainabot/bot-integration/lib/bs/.sourcedirs.json deleted file mode 100644 index ae05e76..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/.sourcedirs.json +++ /dev/null @@ -1 +0,0 @@ -{"cmt_scan":[{"also_scan_build_root":true,"build_root":"lib/bs","scan_dirs":["bindings","src","src/tea"]}],"dirs":["bindings","src","src/tea"],"generated":[],"pkgs":[],"version":2} \ No newline at end of file diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.ast b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.ast deleted file mode 100644 index 499f2fb..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmi b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmi deleted file mode 100644 index dc2b3e1..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmj b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmj deleted file mode 100644 index d762ff6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmt b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmt deleted file mode 100644 index 74151c4..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res deleted file mode 100644 index 1fd7268..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res +++ /dev/null @@ -1,160 +0,0 @@ -// ReScript bindings for Deno runtime APIs - -module Env = { - @val @scope(("Deno", "env")) external get: string => option = "get" - @val @scope(("Deno", "env")) external set: (string, string) => unit = "set" -} - -module Request = { - type t - @get external url: t => string = "url" - @get external method_: t => string = "method" - @send external headers: t => Js.Dict.t = "headers" - @send external json: t => promise = "json" - @send external text: t => promise = "text" -} - -module Response = { - type t - @new @scope("globalThis") - external make: (string, {..}) => t = "Response" - @new @scope("globalThis") - external makeJson: (string, {..}) => t = "Response" -} - -module HttpServer = { - type t - @send external shutdown: t => promise = "shutdown" - @send external finished: t => promise = "finished" -} - -@val @scope("Deno") -external serve: ({..}, Request.t => promise) => HttpServer.t = "serve" - -// Legacy module for backwards compatibility -module Http = { - type request = Request.t - type response = Response.t - - type remoteAddr = {hostname: string, port: int} - type connInfo = {remoteAddr: remoteAddr} - - type handler = (request, connInfo) => promise - - type listenInfo = {hostname: string, port: int} - type serveOptions = { - port?: int, - hostname?: string, - onListen?: listenInfo => unit, - } - - @get external url: request => string = "url" - @get external method_: request => string = "method" - @get external headers: request => Js.Dict.t = "headers" - @send external json: request => promise = "json" - @send external text: request => promise = "text" - - @new @scope("globalThis") - external makeResponse: (string, {..}) => response = "Response" - - @new @scope("globalThis") - external makeJsonResponse: (string, {..}) => response = "Response" - - @val @scope("Deno") - external serve: (serveOptions, handler) => unit = "serve" -} - -module Crypto = { - type subtleCrypto - type cryptoKey - - @val @scope(("globalThis", "crypto")) - external subtle: subtleCrypto = "subtle" - - // Import key with Uint8Array (for HMAC) - @send - external importKey: ( - subtleCrypto, - string, - Js.TypedArray2.Uint8Array.t, - {..}, - bool, - array, - ) => promise = "importKey" - - // Import key with JsonWebKey (for RSA) - @send - external importKeyJwk: ( - subtleCrypto, - @as("jwk") _, - {..}, - {..}, - bool, - array, - ) => promise = "importKey" - - // Import key with PKCS8 format (for RSA private keys) - @send - external importKeyPkcs8: ( - subtleCrypto, - @as("pkcs8") _, - Js.TypedArray2.ArrayBuffer.t, - {..}, - bool, - array, - ) => promise = "importKey" - - @send - external sign: (subtleCrypto, string, cryptoKey, Js.TypedArray2.Uint8Array.t) => promise = - "sign" - - @send - external signWithAlgorithm: (subtleCrypto, {..}, cryptoKey, Js.TypedArray2.Uint8Array.t) => promise = - "sign" - - @send - external verify: ( - subtleCrypto, - string, - cryptoKey, - Js.TypedArray2.Uint8Array.t, - Js.TypedArray2.Uint8Array.t, - ) => promise = "verify" -} - -module ArrayBuffer = { - @new @scope("globalThis") - external makeUint8Array: Js.TypedArray2.ArrayBuffer.t => Js.TypedArray2.Uint8Array.t = "Uint8Array" -} - -module Base64 = { - @val @scope("globalThis") external btoa: string => string = "btoa" - @val @scope("globalThis") external atob: string => string = "atob" -} - -module TextEncoder = { - type t - - @new @scope("globalThis") external make: unit => t = "TextEncoder" - @send external encode: (t, string) => Js.TypedArray2.Uint8Array.t = "encode" -} - -module TextDecoder = { - type t - - @new @scope("globalThis") external make: unit => t = "TextDecoder" - @send external decode: (t, Js.TypedArray2.Uint8Array.t) => string = "decode" -} - -module Fetch = { - type response - - @val @scope("globalThis") - external fetch: (string, {..}) => promise = "fetch" - - @get external ok: response => bool = "ok" - @get external status: response => int = "status" - @get external statusText: response => string = "statusText" - @send external json: response => promise = "json" - @send external text: response => promise = "text" -} diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res.js b/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res.js deleted file mode 100644 index bfcadbb..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/bindings/Deno.res.js +++ /dev/null @@ -1,39 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - - -let Env = {}; - -let Request = {}; - -let Response = {}; - -let HttpServer = {}; - -let Http = {}; - -let Crypto = {}; - -let $$ArrayBuffer = {}; - -let Base64 = {}; - -let TextEncoder = {}; - -let TextDecoder = {}; - -let Fetch = {}; - -export { - Env, - Request, - Response, - HttpServer, - Http, - Crypto, - $$ArrayBuffer, - Base64, - TextEncoder, - TextDecoder, - Fetch, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.ast b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.ast deleted file mode 100644 index 9da5f15..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmi b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmi deleted file mode 100644 index 707c88f..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmj b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmj deleted file mode 100644 index d762ff6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmt b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmt deleted file mode 100644 index f94c1e7..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res deleted file mode 100644 index 3be3690..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res +++ /dev/null @@ -1,13 +0,0 @@ -// ReScript bindings for Fetch API (available in Deno) - -type response - -@val external fetch: (string, {..}) => promise = "fetch" - -module Response = { - @get external ok: response => bool = "ok" - @get external status: response => int = "status" - @get external statusText: response => string = "statusText" - @send external json: response => promise = "json" - @send external text: response => promise = "text" -} diff --git a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res.js b/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res.js deleted file mode 100644 index c3193b3..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/bindings/Fetch.res.js +++ /dev/null @@ -1,9 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - - -let Response = {}; - -export { - Response, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/build.ninja b/bots/sustainabot/bot-integration/lib/bs/build.ninja deleted file mode 100644 index e69de29..0000000 diff --git a/bots/sustainabot/bot-integration/lib/bs/compiler-info.json b/bots/sustainabot/bot-integration/lib/bs/compiler-info.json deleted file mode 100644 index 2744c93..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/compiler-info.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "12.1.0", - "bsc_path": "/var/home/hyper/.local/lib/node_modules/rescript/node_modules/@rescript/linux-x64/bin/bsc.exe", - "bsc_hash": "e71a4b341d54d39d77ed040eac08d002633dac2437fe69da2e0aa643219947ff", - "rescript_config_hash": "a7b411bbd12b50ab07abf31e57afaf1cc1ed53c7c57f2db040b8caf708a894f0", - "runtime_path": "/var/home/hyper/.local/lib/node_modules/rescript/node_modules/@rescript/runtime", - "generated_at": "1770426379323" -} \ No newline at end of file diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.ast b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.ast deleted file mode 100644 index fc6c97b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmi deleted file mode 100644 index 617096b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmj deleted file mode 100644 index 57ecc6b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmt deleted file mode 100644 index 592d0eb..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res deleted file mode 100644 index d792cc9..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res +++ /dev/null @@ -1,120 +0,0 @@ -// Analysis service client - -open Types - -// Fetch analysis from the analysis service -let analyzeRepository = async (endpoint: string, repoUrl: string, ref: string): result< - analysisResult, - string, -> => { - let body = Js.Json.object_( - Js.Dict.fromArray([("url", Js.Json.string(repoUrl)), ("ref", Js.Json.string(ref))]), - ) - - try { - let response = await Fetch.fetch( - `${endpoint}/repository`, - { - "method": "POST", - "headers": {"Content-Type": "application/json"}, - "body": Js.Json.stringify(body), - }, - ) - - if Fetch.Response.ok(response) { - let json = await Fetch.Response.json(response) - // In production, would properly decode the JSON - Ok(Obj.magic(json)) - } else { - Error(`Analysis failed: ${Fetch.Response.statusText(response)}`) - } - } catch { - | Js.Exn.Error(e) => - Error(`Analysis request failed: ${Js.Exn.message(e)->Belt.Option.getWithDefault("unknown")}`) - } -} - -let analyzeDiff = async ( - endpoint: string, - repoUrl: string, - base: string, - head: string, -): result => { - let body = Js.Json.object_( - Js.Dict.fromArray([ - ("url", Js.Json.string(repoUrl)), - ("base", Js.Json.string(base)), - ("head", Js.Json.string(head)), - ]), - ) - - try { - let response = await Fetch.fetch( - `${endpoint}/diff`, - { - "method": "POST", - "headers": {"Content-Type": "application/json"}, - "body": Js.Json.stringify(body), - }, - ) - - if Fetch.Response.ok(response) { - let json = await Fetch.Response.json(response) - Ok(Obj.magic(json)) - } else { - Error(`Diff analysis failed: ${Fetch.Response.statusText(response)}`) - } - } catch { - | Js.Exn.Error(e) => - Error(`Analysis request failed: ${Js.Exn.message(e)->Belt.Option.getWithDefault("unknown")}`) - } -} - -// Mock analysis for testing -let mockAnalysis = (): analysisResult => { - { - eco: { - carbonScore: 72.0, - energyScore: 68.0, - resourceScore: 75.0, - score: 71.5, - }, - econ: { - paretoDistance: 0.15, - allocationScore: 80.0, - debtScore: 65.0, - score: 72.0, - paretoStatus: Some({ - isOptimal: false, - distance: 0.15, - improvements: Some(["Reduce complexity in src/utils.rs", "Add memoization to hot path"]), - }), - }, - quality: { - complexityScore: 70.0, - couplingScore: 75.0, - coverageScore: 82.0, - score: 75.5, - }, - health: { - eco: 0.4, - econ: 0.3, - quality: 0.3, - total: 72.8, - grade: "C", - }, - violations: [], - recommendations: [ - { - entityId: "src/processing.rs", - action: "optimize_loop", - reason: "Hot loop could benefit from vectorization", - priority: PriorityMedium, - confidence: 0.78, - expectedImprovement: Js.Dict.fromArray([("carbonScore", 5.0), ("energyScore", 8.0)]), - }, - ], - timestamp: "2024-12-08T10:00:00Z", - commitSha: Some("abc123"), - } -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res.js deleted file mode 100644 index 135498c..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Analysis.res.js +++ /dev/null @@ -1,159 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Stdlib_Exn from "@rescript/runtime/lib/es6/Stdlib_Exn.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; -import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js"; - -async function analyzeRepository(endpoint, repoUrl, ref) { - let body = Js_dict.fromArray([ - [ - "url", - repoUrl - ], - [ - "ref", - ref - ] - ]); - try { - let response = await fetch(endpoint + `/repository`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(body) - }); - if (!response.ok) { - return { - TAG: "Error", - _0: `Analysis failed: ` + response.statusText - }; - } - let json = await response.json(); - return { - TAG: "Ok", - _0: json - }; - } catch (raw_e) { - let e = Primitive_exceptions.internalToException(raw_e); - if (e.RE_EXN_ID === Stdlib_Exn.$$Error) { - return { - TAG: "Error", - _0: `Analysis request failed: ` + Belt_Option.getWithDefault(e._1.message, "unknown") - }; - } - throw e; - } -} - -async function analyzeDiff(endpoint, repoUrl, base, head) { - let body = Js_dict.fromArray([ - [ - "url", - repoUrl - ], - [ - "base", - base - ], - [ - "head", - head - ] - ]); - try { - let response = await fetch(endpoint + `/diff`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(body) - }); - if (!response.ok) { - return { - TAG: "Error", - _0: `Diff analysis failed: ` + response.statusText - }; - } - let json = await response.json(); - return { - TAG: "Ok", - _0: json - }; - } catch (raw_e) { - let e = Primitive_exceptions.internalToException(raw_e); - if (e.RE_EXN_ID === Stdlib_Exn.$$Error) { - return { - TAG: "Error", - _0: `Analysis request failed: ` + Belt_Option.getWithDefault(e._1.message, "unknown") - }; - } - throw e; - } -} - -function mockAnalysis() { - return { - eco: { - carbonScore: 72.0, - energyScore: 68.0, - resourceScore: 75.0, - score: 71.5 - }, - econ: { - paretoDistance: 0.15, - allocationScore: 80.0, - debtScore: 65.0, - score: 72.0, - paretoStatus: { - isOptimal: false, - distance: 0.15, - improvements: [ - "Reduce complexity in src/utils.rs", - "Add memoization to hot path" - ] - } - }, - quality: { - complexityScore: 70.0, - couplingScore: 75.0, - coverageScore: 82.0, - score: 75.5 - }, - health: { - eco: 0.4, - econ: 0.3, - quality: 0.3, - total: 72.8, - grade: "C" - }, - violations: [], - recommendations: [{ - entityId: "src/processing.rs", - action: "optimize_loop", - reason: "Hot loop could benefit from vectorization", - priority: "PriorityMedium", - confidence: 0.78, - expectedImprovement: Js_dict.fromArray([ - [ - "carbonScore", - 5.0 - ], - [ - "energyScore", - 8.0 - ] - ]) - }], - timestamp: "2024-12-08T10:00:00Z", - commitSha: "abc123" - }; -} - -export { - analyzeRepository, - analyzeDiff, - mockAnalysis, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.ast b/bots/sustainabot/bot-integration/lib/bs/src/Config.ast deleted file mode 100644 index 06ba405..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Config.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Config.cmi deleted file mode 100644 index 29fc5f4..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Config.cmj deleted file mode 100644 index 95e1744..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Config.cmt deleted file mode 100644 index 556551c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Config.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.res b/bots/sustainabot/bot-integration/lib/bs/src/Config.res deleted file mode 100644 index d9749c4..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Config.res +++ /dev/null @@ -1,80 +0,0 @@ -// Configuration loading from environment - -open Types - -let getEnv = (key: string, ~default: option=?): option => { - switch Deno.Env.get(key) { - | Some(v) => Some(v) - | None => default - } -} - -let getEnvRequired = (key: string): result => { - switch Deno.Env.get(key) { - | Some(v) => Ok(v) - | None => Error(`Missing required environment variable: ${key}`) - } -} - -let getEnvInt = (key: string, ~default: int): int => { - switch Deno.Env.get(key) { - | Some(v) => - switch Belt.Int.fromString(v) { - | Some(i) => i - | None => default - } - | None => default - } -} - -let parseMode = (s: string): botMode => { - switch Js.String.toLowerCase(s) { - | "consultant" => Consultant - | "regulator" => Regulator - | _ => Advisor - } -} - -// Load private key from file or environment -// If GITHUB_PRIVATE_KEY_FILE is set, read from file -// Otherwise use GITHUB_PRIVATE_KEY directly -let loadPrivateKey = (): option => { - switch Deno.Env.get("GITHUB_PRIVATE_KEY_FILE") { - | Some(_path) => - // For file-based keys, the key should be loaded at startup - // For now, fall back to env var (file loading would need async) - getEnv("GITHUB_PRIVATE_KEY") - | None => getEnv("GITHUB_PRIVATE_KEY") - } -} - -let load = (): result => { - let modeStr = switch getEnv("BOT_MODE") { - | Some(m) => m - | None => "advisor" - } - let mode = parseMode(modeStr) - - let analysisEndpoint = switch getEnv("ANALYSIS_ENDPOINT") { - | Some(e) => e - | None => "http://localhost:8080/analyze" - } - - Ok({ - port: getEnvInt("PORT", ~default=3000), - mode, - analysisEndpoint, - githubWebhookSecret: getEnv("GITHUB_WEBHOOK_SECRET"), - gitlabWebhookSecret: getEnv("GITLAB_WEBHOOK_SECRET"), - githubAppId: getEnv("GITHUB_APP_ID"), - githubPrivateKey: loadPrivateKey(), - }) -} - -let modeToString = (mode: botMode): string => { - switch mode { - | Consultant => "consultant" - | Advisor => "advisor" - | Regulator => "regulator" - } -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Config.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Config.res.js deleted file mode 100644 index b155c1c..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Config.res.js +++ /dev/null @@ -1,99 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Belt_Int from "@rescript/runtime/lib/es6/Belt_Int.js"; - -function getEnv(key, $$default) { - let v = Deno.env.get(key); - if (v !== undefined) { - return v; - } else { - return $$default; - } -} - -function getEnvRequired(key) { - let v = Deno.env.get(key); - if (v !== undefined) { - return { - TAG: "Ok", - _0: v - }; - } else { - return { - TAG: "Error", - _0: `Missing required environment variable: ` + key - }; - } -} - -function getEnvInt(key, $$default) { - let v = Deno.env.get(key); - if (v === undefined) { - return $$default; - } - let i = Belt_Int.fromString(v); - if (i !== undefined) { - return i; - } else { - return $$default; - } -} - -function parseMode(s) { - let match = s.toLowerCase(); - switch (match) { - case "consultant" : - return "Consultant"; - case "regulator" : - return "Regulator"; - default: - return "Advisor"; - } -} - -function loadPrivateKey() { - Deno.env.get("GITHUB_PRIVATE_KEY_FILE"); - return getEnv("GITHUB_PRIVATE_KEY", undefined); -} - -function load() { - let m = getEnv("BOT_MODE", undefined); - let modeStr = m !== undefined ? m : "advisor"; - let mode = parseMode(modeStr); - let e = getEnv("ANALYSIS_ENDPOINT", undefined); - let analysisEndpoint = e !== undefined ? e : "http://localhost:8080/analyze"; - return { - TAG: "Ok", - _0: { - port: getEnvInt("PORT", 3000), - mode: mode, - analysisEndpoint: analysisEndpoint, - githubWebhookSecret: getEnv("GITHUB_WEBHOOK_SECRET", undefined), - gitlabWebhookSecret: getEnv("GITLAB_WEBHOOK_SECRET", undefined), - githubAppId: getEnv("GITHUB_APP_ID", undefined), - githubPrivateKey: loadPrivateKey() - } - }; -} - -function modeToString(mode) { - switch (mode) { - case "Consultant" : - return "consultant"; - case "Advisor" : - return "advisor"; - case "Regulator" : - return "regulator"; - } -} - -export { - getEnv, - getEnvRequired, - getEnvInt, - parseMode, - loadPrivateKey, - load, - modeToString, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.ast b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.ast deleted file mode 100644 index 27b3595..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmi b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmi deleted file mode 100644 index 468068e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmj b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmj deleted file mode 100644 index 02302bd..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmt b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmt deleted file mode 100644 index 9fe4a85..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res deleted file mode 100644 index ef21edd..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res +++ /dev/null @@ -1,177 +0,0 @@ -// GitHub REST API Client -// Authenticated API calls using installation access tokens - -open Deno - -let userAgent = "oikos-bot/0.1.0-beta" -let apiVersion = "2022-11-28" - -// Make an authenticated request to GitHub API -let apiRequest = async ( - token: string, - method: string, - endpoint: string, - ~body: option=?, -): result => { - let url = `https://api.github.com${endpoint}` - - let headers = { - "Authorization": `Bearer ${token}`, - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": apiVersion, - "User-Agent": userAgent, - "Content-Type": "application/json", - } - - try { - let response = switch body { - | Some(b) => - await Fetch.fetch( - url, - { - "method": method, - "headers": headers, - "body": Js.Json.stringify(b), - }, - ) - | None => - await Fetch.fetch( - url, - { - "method": method, - "headers": headers, - }, - ) - } - - if !Fetch.ok(response) { - let status = Fetch.status(response) - let errorBody = await Fetch.text(response) - Error(`GitHub API error ${Belt.Int.toString(status)}: ${errorBody}`) - } else { - let json = await Fetch.json(response) - Ok(json) - } - } catch { - | exn => - let msg = switch Js.Exn.asJsExn(exn) { - | Some(jsExn) => Js.Exn.message(jsExn)->Belt.Option.getWithDefault("Unknown error") - | None => "Unknown error" - } - Error(`API request failed: ${msg}`) - } -} - -// Post a comment on a pull request -let postPRComment = async ( - token: string, - owner: string, - repo: string, - prNumber: int, - body: string, -): result => { - let endpoint = `/repos/${owner}/${repo}/issues/${Belt.Int.toString(prNumber)}/comments` - let payload = Js.Json.object_(Js.Dict.fromArray([("body", Js.Json.string(body))])) - - let result = await apiRequest(token, "POST", endpoint, ~body=payload) - - switch result { - | Ok(json) => - switch Js.Json.decodeObject(json) { - | Some(obj) => - switch Js.Dict.get(obj, "id") { - | Some(id) => - switch Js.Json.decodeNumber(id) { - | Some(num) => Ok(Belt.Float.toInt(num)) - | None => Error("Invalid comment ID in response") - } - | None => Error("No comment ID in response") - } - | None => Error("Invalid JSON response") - } - | Error(e) => Error(e) - } -} - -// Update an existing comment -let updateComment = async ( - token: string, - owner: string, - repo: string, - commentId: int, - body: string, -): result => { - let endpoint = `/repos/${owner}/${repo}/issues/comments/${Belt.Int.toString(commentId)}` - let payload = Js.Json.object_(Js.Dict.fromArray([("body", Js.Json.string(body))])) - - let result = await apiRequest(token, "PATCH", endpoint, ~body=payload) - - switch result { - | Ok(_) => Ok() - | Error(e) => Error(e) - } -} - -// Create a check run (for CI status reporting) -let createCheckRun = async ( - token: string, - owner: string, - repo: string, - headSha: string, - name: string, - conclusion: string, // "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required" - ~title: string, - ~summary: string, -): result => { - let endpoint = `/repos/${owner}/${repo}/check-runs` - let payload = Js.Json.object_( - Js.Dict.fromArray([ - ("name", Js.Json.string(name)), - ("head_sha", Js.Json.string(headSha)), - ("status", Js.Json.string("completed")), - ("conclusion", Js.Json.string(conclusion)), - ( - "output", - Js.Json.object_( - Js.Dict.fromArray([("title", Js.Json.string(title)), ("summary", Js.Json.string(summary))]), - ), - ), - ]), - ) - - let result = await apiRequest(token, "POST", endpoint, ~body=payload) - - switch result { - | Ok(json) => - switch Js.Json.decodeObject(json) { - | Some(obj) => - switch Js.Dict.get(obj, "id") { - | Some(id) => - switch Js.Json.decodeNumber(id) { - | Some(num) => Ok(Belt.Float.toInt(num)) - | None => Error("Invalid check run ID in response") - } - | None => Error("No check run ID in response") - } - | None => Error("Invalid JSON response") - } - | Error(e) => Error(e) - } -} - -// Get pull request details -let getPullRequest = async ( - token: string, - owner: string, - repo: string, - prNumber: int, -): result => { - let endpoint = `/repos/${owner}/${repo}/pulls/${Belt.Int.toString(prNumber)}` - await apiRequest(token, "GET", endpoint) -} - -// Get repository details -let getRepository = async (token: string, owner: string, repo: string): result => { - let endpoint = `/repos/${owner}/${repo}` - await apiRequest(token, "GET", endpoint) -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res.js b/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res.js deleted file mode 100644 index d14ddc4..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/GitHubAPI.res.js +++ /dev/null @@ -1,205 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js"; -import * as Stdlib_Exn from "@rescript/runtime/lib/es6/Stdlib_Exn.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; -import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js"; -import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js"; - -let userAgent = "oikos-bot/0.1.0-beta"; - -let apiVersion = "2022-11-28"; - -async function apiRequest(token, method, endpoint, body) { - let url = `https://api.github.com` + endpoint; - let headers = { - Authorization: `Bearer ` + token, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": apiVersion, - "User-Agent": userAgent, - "Content-Type": "application/json" - }; - try { - let response = body !== undefined ? await globalThis.fetch(url, { - method: method, - headers: headers, - body: JSON.stringify(body) - }) : await globalThis.fetch(url, { - method: method, - headers: headers - }); - if (response.ok) { - let json = await response.json(); - return { - TAG: "Ok", - _0: json - }; - } - let status = response.status; - let errorBody = await response.text(); - return { - TAG: "Error", - _0: `GitHub API error ` + String(status) + `: ` + errorBody - }; - } catch (raw_exn) { - let exn = Primitive_exceptions.internalToException(raw_exn); - let jsExn = Stdlib_Exn.asJsExn(exn); - let msg = jsExn !== undefined ? Belt_Option.getWithDefault(Primitive_option.valFromOption(jsExn).message, "Unknown error") : "Unknown error"; - return { - TAG: "Error", - _0: `API request failed: ` + msg - }; - } -} - -async function postPRComment(token, owner, repo, prNumber, body) { - let endpoint = `/repos/` + owner + `/` + repo + `/issues/` + String(prNumber) + `/comments`; - let payload = Js_dict.fromArray([[ - "body", - body - ]]); - let result = await apiRequest(token, "POST", endpoint, payload); - if (result.TAG !== "Ok") { - return { - TAG: "Error", - _0: result._0 - }; - } - let obj = Js_json.decodeObject(result._0); - if (obj === undefined) { - return { - TAG: "Error", - _0: "Invalid JSON response" - }; - } - let id = Js_dict.get(obj, "id"); - if (id === undefined) { - return { - TAG: "Error", - _0: "No comment ID in response" - }; - } - let num = Js_json.decodeNumber(id); - if (num !== undefined) { - return { - TAG: "Ok", - _0: num | 0 - }; - } else { - return { - TAG: "Error", - _0: "Invalid comment ID in response" - }; - } -} - -async function updateComment(token, owner, repo, commentId, body) { - let endpoint = `/repos/` + owner + `/` + repo + `/issues/comments/` + String(commentId); - let payload = Js_dict.fromArray([[ - "body", - body - ]]); - let result = await apiRequest(token, "PATCH", endpoint, payload); - if (result.TAG === "Ok") { - return { - TAG: "Ok", - _0: undefined - }; - } else { - return { - TAG: "Error", - _0: result._0 - }; - } -} - -async function createCheckRun(token, owner, repo, headSha, name, conclusion, title, summary) { - let endpoint = `/repos/` + owner + `/` + repo + `/check-runs`; - let payload = Js_dict.fromArray([ - [ - "name", - name - ], - [ - "head_sha", - headSha - ], - [ - "status", - "completed" - ], - [ - "conclusion", - conclusion - ], - [ - "output", - Js_dict.fromArray([ - [ - "title", - title - ], - [ - "summary", - summary - ] - ]) - ] - ]); - let result = await apiRequest(token, "POST", endpoint, payload); - if (result.TAG !== "Ok") { - return { - TAG: "Error", - _0: result._0 - }; - } - let obj = Js_json.decodeObject(result._0); - if (obj === undefined) { - return { - TAG: "Error", - _0: "Invalid JSON response" - }; - } - let id = Js_dict.get(obj, "id"); - if (id === undefined) { - return { - TAG: "Error", - _0: "No check run ID in response" - }; - } - let num = Js_json.decodeNumber(id); - if (num !== undefined) { - return { - TAG: "Ok", - _0: num | 0 - }; - } else { - return { - TAG: "Error", - _0: "Invalid check run ID in response" - }; - } -} - -async function getPullRequest(token, owner, repo, prNumber) { - let endpoint = `/repos/` + owner + `/` + repo + `/pulls/` + String(prNumber); - return await apiRequest(token, "GET", endpoint, undefined); -} - -async function getRepository(token, owner, repo) { - let endpoint = `/repos/` + owner + `/` + repo; - return await apiRequest(token, "GET", endpoint, undefined); -} - -export { - userAgent, - apiVersion, - apiRequest, - postPRComment, - updateComment, - createCheckRun, - getPullRequest, - getRepository, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.ast b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.ast deleted file mode 100644 index a419314..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmi b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmi deleted file mode 100644 index 6d78746..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmj b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmj deleted file mode 100644 index 53cff4b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmt b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmt deleted file mode 100644 index 5666890..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res deleted file mode 100644 index 623e813..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res +++ /dev/null @@ -1,234 +0,0 @@ -// GitHub App JWT Authentication -// Generates RS256 JWTs for GitHub App authentication and manages installation tokens - -open Deno - -// Token cache - stores installation tokens to avoid unnecessary API calls -let tokenCache: Js.Dict.t = Js.Dict.empty() - -// Convert PEM private key to ArrayBuffer for Web Crypto API -let pemToArrayBuffer = (pem: string): Js.TypedArray2.ArrayBuffer.t => { - // Remove PEM header/footer and whitespace - let pemContents = pem - ->Js.String2.replaceByRe(%re("/-----BEGIN (?:RSA )?PRIVATE KEY-----/g"), "") - ->Js.String2.replaceByRe(%re("/-----END (?:RSA )?PRIVATE KEY-----/g"), "") - ->Js.String2.replaceByRe(%re("/\\s/g"), "") - - // Decode base64 to binary string - let binaryStr = Base64.atob(pemContents) - let len = Js.String.length(binaryStr) - - // Convert to Uint8Array - let bytes = Js.TypedArray2.Uint8Array.fromLength(len) - for i in 0 to len - 1 { - let charCode = Js.String2.charCodeAt(binaryStr, i)->Belt.Float.toInt - Js.TypedArray2.Uint8Array.unsafe_set(bytes, i, charCode) - } - - Js.TypedArray2.Uint8Array.buffer(bytes) -} - -// Base64URL encode (for JWT) -let base64UrlEncode = (data: Js.TypedArray2.Uint8Array.t): string => { - // Convert Uint8Array to binary string - let len = Js.TypedArray2.Uint8Array.length(data) - let binaryStr = ref("") - for i in 0 to len - 1 { - let byte = Js.TypedArray2.Uint8Array.unsafe_get(data, i) - binaryStr := binaryStr.contents ++ Js.String.fromCharCode(byte) - } - - // Base64 encode then make URL-safe - Base64.btoa(binaryStr.contents) - ->Js.String2.replaceByRe(%re("/\+/g"), "-") - ->Js.String2.replaceByRe(%re("/\//g"), "_") - ->Js.String2.replaceByRe(%re("/=+$/g"), "") -} - -// Base64URL encode a string -let base64UrlEncodeString = (str: string): string => { - Base64.btoa(str) - ->Js.String2.replaceByRe(%re("/\+/g"), "-") - ->Js.String2.replaceByRe(%re("/\//g"), "_") - ->Js.String2.replaceByRe(%re("/=+$/g"), "") -} - -// Generate JWT for GitHub App authentication -// JWT is valid for 10 minutes (GitHub requirement) -let generateJWT = async (appId: string, privateKeyPem: string): result => { - let nowSeconds = Js.Date.now() /. 1000.0 - - // JWT Header - let header = Js.Dict.fromArray([ - ("alg", Js.Json.string("RS256")), - ("typ", Js.Json.string("JWT")), - ]) - let headerB64 = base64UrlEncodeString(Js.Json.stringify(Js.Json.object_(header))) - - // JWT Payload - issued 60 seconds ago to account for clock drift - let payload = Js.Dict.fromArray([ - ("iss", Js.Json.string(appId)), - ("iat", Js.Json.number(nowSeconds -. 60.0)), - ("exp", Js.Json.number(nowSeconds +. 600.0)), // 10 minutes - ]) - let payloadB64 = base64UrlEncodeString(Js.Json.stringify(Js.Json.object_(payload))) - - // Message to sign - let message = `${headerB64}.${payloadB64}` - - try { - // Import RSA private key - let keyBuffer = pemToArrayBuffer(privateKeyPem) - let encoder = TextEncoder.make() - - let key = await Crypto.importKeyPkcs8( - Crypto.subtle, - keyBuffer, - {"name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256"}, - false, - ["sign"], - ) - - // Sign the message - let messageBytes = TextEncoder.encode(encoder, message) - let signatureBuffer = await Crypto.signWithAlgorithm( - Crypto.subtle, - {"name": "RSASSA-PKCS1-v1_5"}, - key, - messageBytes, - ) - - // Convert signature to base64url - let signatureBytes = ArrayBuffer.makeUint8Array(signatureBuffer) - let signatureB64 = base64UrlEncode(signatureBytes) - - Ok(`${message}.${signatureB64}`) - } catch { - | exn => - let msg = switch Js.Exn.asJsExn(exn) { - | Some(jsExn) => Js.Exn.message(jsExn)->Belt.Option.getWithDefault("Unknown error") - | None => "Unknown error" - } - Error(`Failed to generate JWT: ${msg}`) - } -} - -// Get installation access token from GitHub -// Tokens are valid for 1 hour -let getInstallationToken = async (jwt: string, installationId: int): result => { - let cacheKey = Belt.Int.toString(installationId) - - // Check cache first - switch Js.Dict.get(tokenCache, cacheKey) { - | Some(cached) if cached.expiresAt > Js.Date.now() +. 60000.0 => - // Return cached token if it has at least 1 minute remaining - Ok(cached) - | _ => - // Fetch new token - try { - let response = await Fetch.fetch( - `https://api.github.com/app/installations/${cacheKey}/access_tokens`, - { - "method": "POST", - "headers": { - "Authorization": `Bearer ${jwt}`, - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "oikos-bot", - }, - }, - ) - - if !Fetch.ok(response) { - let status = Fetch.status(response) - let body = await Fetch.text(response) - Error(`GitHub API error ${Belt.Int.toString(status)}: ${body}`) - } else { - let json = await Fetch.json(response) - switch Js.Json.decodeObject(json) { - | Some(obj) => - let token = switch Js.Dict.get(obj, "token") { - | Some(t) => Js.Json.decodeString(t)->Belt.Option.getWithDefault("") - | None => "" - } - let expiresAtStr = switch Js.Dict.get(obj, "expires_at") { - | Some(e) => Js.Json.decodeString(e)->Belt.Option.getWithDefault("") - | None => "" - } - - if token == "" { - Error("No token in response") - } else { - // Parse ISO 8601 date to timestamp (getTime returns ms since epoch) - let expiresAtDate = Js.Date.fromString(expiresAtStr) - let expiresAt = Js.Date.getTime(expiresAtDate) - let installToken: Types.installationToken = { - token, - expiresAt, - } - - // Cache the token - Js.Dict.set(tokenCache, cacheKey, installToken) - - Ok(installToken) - } - | None => Error("Invalid JSON response") - } - } - } catch { - | exn => - let msg = switch Js.Exn.asJsExn(exn) { - | Some(jsExn) => Js.Exn.message(jsExn)->Belt.Option.getWithDefault("Unknown error") - | None => "Unknown error" - } - Error(`Failed to get installation token: ${msg}`) - } - } -} - -// Extract installation ID from webhook payload -let extractInstallationId = (payload: Js.Json.t): option => { - switch Js.Json.decodeObject(payload) { - | Some(obj) => - switch Js.Dict.get(obj, "installation") { - | Some(inst) => - switch Js.Json.decodeObject(inst) { - | Some(instObj) => - switch Js.Dict.get(instObj, "id") { - | Some(id) => - switch Js.Json.decodeNumber(id) { - | Some(num) => Some(Belt.Float.toInt(num)) - | None => None - } - | None => None - } - | None => None - } - | None => None - } - | None => None - } -} - -// Get an authenticated token for a repository installation -// This is the main entry point for authentication -let getAuthToken = async (config: Types.config, payload: Js.Json.t): result => { - switch (config.githubAppId, config.githubPrivateKey) { - | (Some(appId), Some(privateKey)) => - switch extractInstallationId(payload) { - | Some(installationId) => - let jwtResult = await generateJWT(appId, privateKey) - switch jwtResult { - | Ok(jwt) => - let tokenResult = await getInstallationToken(jwt, installationId) - switch tokenResult { - | Ok(installToken) => Ok(installToken.token) - | Error(e) => Error(e) - } - | Error(e) => Error(e) - } - | None => Error("No installation ID in payload") - } - | _ => Error("GitHub App credentials not configured") - } -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res.js b/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res.js deleted file mode 100644 index 48795ae..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/GitHubApp.res.js +++ /dev/null @@ -1,238 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js"; -import * as Stdlib_Exn from "@rescript/runtime/lib/es6/Stdlib_Exn.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; -import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js"; -import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js"; - -let tokenCache = {}; - -function pemToArrayBuffer(pem) { - let pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/g, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/g, "").replace(/\\s/g, ""); - let binaryStr = globalThis.atob(pemContents); - let len = binaryStr.length; - let bytes = new Uint8Array(len); - for (let i = 0; i < len; ++i) { - let charCode = binaryStr.charCodeAt(i) | 0; - bytes[i] = charCode; - } - return bytes.buffer; -} - -function base64UrlEncode(data) { - let len = data.length; - let binaryStr = ""; - for (let i = 0; i < len; ++i) { - let byte = data[i]; - binaryStr = binaryStr + String.fromCharCode(byte); - } - return globalThis.btoa(binaryStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -function base64UrlEncodeString(str) { - return globalThis.btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -async function generateJWT(appId, privateKeyPem) { - let nowSeconds = Date.now() / 1000.0; - let header = Js_dict.fromArray([ - [ - "alg", - "RS256" - ], - [ - "typ", - "JWT" - ] - ]); - let headerB64 = base64UrlEncodeString(JSON.stringify(header)); - let payload = Js_dict.fromArray([ - [ - "iss", - appId - ], - [ - "iat", - nowSeconds - 60.0 - ], - [ - "exp", - nowSeconds + 600.0 - ] - ]); - let payloadB64 = base64UrlEncodeString(JSON.stringify(payload)); - let message = headerB64 + `.` + payloadB64; - try { - let keyBuffer = pemToArrayBuffer(privateKeyPem); - let encoder = new (globalThis.TextEncoder)(); - let key = await globalThis.crypto.subtle.importKey("pkcs8", keyBuffer, { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256" - }, false, ["sign"]); - let messageBytes = encoder.encode(message); - let signatureBuffer = await globalThis.crypto.subtle.sign({ - name: "RSASSA-PKCS1-v1_5" - }, key, messageBytes); - let signatureBytes = new (globalThis.Uint8Array)(signatureBuffer); - let signatureB64 = base64UrlEncode(signatureBytes); - return { - TAG: "Ok", - _0: message + `.` + signatureB64 - }; - } catch (raw_exn) { - let exn = Primitive_exceptions.internalToException(raw_exn); - let jsExn = Stdlib_Exn.asJsExn(exn); - let msg = jsExn !== undefined ? Belt_Option.getWithDefault(Primitive_option.valFromOption(jsExn).message, "Unknown error") : "Unknown error"; - return { - TAG: "Error", - _0: `Failed to generate JWT: ` + msg - }; - } -} - -async function getInstallationToken(jwt, installationId) { - let cacheKey = String(installationId); - let cached = Js_dict.get(tokenCache, cacheKey); - if (cached !== undefined && cached.expiresAt > Date.now() + 60000.0) { - return { - TAG: "Ok", - _0: cached - }; - } - try { - let response = await globalThis.fetch(`https://api.github.com/app/installations/` + cacheKey + `/access_tokens`, { - method: "POST", - headers: { - Authorization: `Bearer ` + jwt, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "oikos-bot" - } - }); - if (response.ok) { - let json = await response.json(); - let obj = Js_json.decodeObject(json); - if (obj === undefined) { - return { - TAG: "Error", - _0: "Invalid JSON response" - }; - } - let t = Js_dict.get(obj, "token"); - let token = t !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(t), "") : ""; - let e = Js_dict.get(obj, "expires_at"); - let expiresAtStr = e !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(e), "") : ""; - if (token === "") { - return { - TAG: "Error", - _0: "No token in response" - }; - } - let expiresAtDate = new Date(expiresAtStr); - let expiresAt = expiresAtDate.getTime(); - let installToken = { - token: token, - expiresAt: expiresAt - }; - tokenCache[cacheKey] = installToken; - return { - TAG: "Ok", - _0: installToken - }; - } - let status = response.status; - let body = await response.text(); - return { - TAG: "Error", - _0: `GitHub API error ` + String(status) + `: ` + body - }; - } catch (raw_exn) { - let exn = Primitive_exceptions.internalToException(raw_exn); - let jsExn = Stdlib_Exn.asJsExn(exn); - let msg = jsExn !== undefined ? Belt_Option.getWithDefault(Primitive_option.valFromOption(jsExn).message, "Unknown error") : "Unknown error"; - return { - TAG: "Error", - _0: `Failed to get installation token: ` + msg - }; - } -} - -function extractInstallationId(payload) { - let obj = Js_json.decodeObject(payload); - if (obj === undefined) { - return; - } - let inst = Js_dict.get(obj, "installation"); - if (inst === undefined) { - return; - } - let instObj = Js_json.decodeObject(inst); - if (instObj === undefined) { - return; - } - let id = Js_dict.get(instObj, "id"); - if (id === undefined) { - return; - } - let num = Js_json.decodeNumber(id); - if (num !== undefined) { - return num | 0; - } -} - -async function getAuthToken(config, payload) { - let match = config.githubAppId; - let match$1 = config.githubPrivateKey; - if (match === undefined) { - return { - TAG: "Error", - _0: "GitHub App credentials not configured" - }; - } - if (match$1 === undefined) { - return { - TAG: "Error", - _0: "GitHub App credentials not configured" - }; - } - let installationId = extractInstallationId(payload); - if (installationId === undefined) { - return { - TAG: "Error", - _0: "No installation ID in payload" - }; - } - let jwtResult = await generateJWT(match, match$1); - if (jwtResult.TAG !== "Ok") { - return { - TAG: "Error", - _0: jwtResult._0 - }; - } - let tokenResult = await getInstallationToken(jwtResult._0, installationId); - if (tokenResult.TAG === "Ok") { - return { - TAG: "Ok", - _0: tokenResult._0.token - }; - } else { - return { - TAG: "Error", - _0: tokenResult._0 - }; - } -} - -export { - tokenCache, - pemToArrayBuffer, - base64UrlEncode, - base64UrlEncodeString, - generateJWT, - getInstallationToken, - extractInstallationId, - getAuthToken, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.ast b/bots/sustainabot/bot-integration/lib/bs/src/Main.ast deleted file mode 100644 index 6690562..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Main.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Main.cmi deleted file mode 100644 index 2e23f67..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Main.cmj deleted file mode 100644 index b5d37a6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Main.cmt deleted file mode 100644 index cb27e21..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Main.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.res b/bots/sustainabot/bot-integration/lib/bs/src/Main.res deleted file mode 100644 index 6216b98..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Main.res +++ /dev/null @@ -1,422 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// SPDX-FileCopyrightText: 2024-2025 hyperpolymath -// -// Oikos Bot Main Entry Point -// ReScript + Deno implementation - -open Deno -open Types - -// Logger helper -let log = (level: string, msg: string, data: option) => { - let timestamp = Js.Date.toISOString(Js.Date.make()) - let logObj = switch data { - | Some(d) => - Js.Dict.fromArray([ - ("timestamp", Js.Json.string(timestamp)), - ("level", Js.Json.string(level)), - ("message", Js.Json.string(msg)), - ("data", d), - ]) - | None => - Js.Dict.fromArray([ - ("timestamp", Js.Json.string(timestamp)), - ("level", Js.Json.string(level)), - ("message", Js.Json.string(msg)), - ]) - } - Js.Console.log(Js.Json.stringify(Js.Json.object_(logObj))) -} - -let info = (msg, ~data=?) => log("info", msg, data) -let error = (msg, ~data=?) => log("error", msg, data) - -// Extract PR info from GitHub payload -let extractPRInfo = (payload: Js.Json.t): (int, string, string) => { - switch Js.Json.decodeObject(payload) { - | Some(obj) => - let prNumber = switch Js.Dict.get(obj, "number") { - | Some(n) => - switch Js.Json.decodeNumber(n) { - | Some(num) => Belt.Float.toInt(num) - | None => 0 - } - | None => 0 - } - let (baseSha, headSha) = switch Js.Dict.get(obj, "pull_request") { - | Some(pr) => - switch Js.Json.decodeObject(pr) { - | Some(prObj) => - let base = switch Js.Dict.get(prObj, "base") { - | Some(b) => - switch Js.Json.decodeObject(b) { - | Some(baseObj) => - switch Js.Dict.get(baseObj, "sha") { - | Some(s) => Js.Json.decodeString(s)->Belt.Option.getWithDefault("") - | None => "" - } - | None => "" - } - | None => "" - } - let head = switch Js.Dict.get(prObj, "head") { - | Some(h) => - switch Js.Json.decodeObject(h) { - | Some(headObj) => - switch Js.Dict.get(headObj, "sha") { - | Some(s) => Js.Json.decodeString(s)->Belt.Option.getWithDefault("") - | None => "" - } - | None => "" - } - | None => "" - } - (base, head) - | None => ("", "") - } - | None => ("", "") - } - (prNumber, baseSha, headSha) - | None => (0, "", "") - } -} - -// Extract MR info from GitLab payload -let extractMRInfo = (payload: Js.Json.t): (int, string, string) => { - switch Js.Json.decodeObject(payload) { - | Some(obj) => - switch Js.Dict.get(obj, "object_attributes") { - | Some(attrs) => - switch Js.Json.decodeObject(attrs) { - | Some(attrsObj) => - let mrIid = switch Js.Dict.get(attrsObj, "iid") { - | Some(n) => - switch Js.Json.decodeNumber(n) { - | Some(num) => Belt.Float.toInt(num) - | None => 0 - } - | None => 0 - } - let baseSha = switch Js.Dict.get(attrsObj, "diff_refs") { - | Some(refs) => - switch Js.Json.decodeObject(refs) { - | Some(refsObj) => - switch Js.Dict.get(refsObj, "base_sha") { - | Some(s) => Js.Json.decodeString(s)->Belt.Option.getWithDefault("") - | None => "" - } - | None => "" - } - | None => "" - } - let headSha = switch Js.Dict.get(attrsObj, "last_commit") { - | Some(commit) => - switch Js.Json.decodeObject(commit) { - | Some(commitObj) => - switch Js.Dict.get(commitObj, "id") { - | Some(s) => Js.Json.decodeString(s)->Belt.Option.getWithDefault("") - | None => "" - } - | None => "" - } - | None => "" - } - (mrIid, baseSha, headSha) - | None => (0, "", "") - } - | None => (0, "", "") - } - | None => (0, "", "") - } -} - -// JSON response helper -let jsonResponse = (data: Js.Json.t, ~status=200): Http.response => { - Http.makeJsonResponse( - Js.Json.stringify(data), - { - "status": status, - "headers": {"Content-Type": "application/json"}, - }, - ) -} - -// Validate GitHub signature if configured -let validateGitHubSignature = async ( - config: config, - headers: Js.Dict.t, - body: string, -): option => { - switch config.githubWebhookSecret { - | Some(secret) => - let signature = Js.Dict.get(headers, "x-hub-signature-256")->Belt.Option.getWithDefault("") - let valid = await Webhook.verifyGitHubSignature(body, signature, secret) - if !valid { - error("Invalid GitHub webhook signature") - Some(Http.makeResponse(`{"error": "Invalid signature"}`, {"status": 401})) - } else { - None - } - | None => None - } -} - -// Handle GitHub webhook -let handleGitHubWebhook = async ( - config: config, - headers: Js.Dict.t, - body: string, -): Http.response => { - // Verify signature if secret is configured - let signatureError = await validateGitHubSignature(config, headers, body) - switch signatureError { - | Some(errResponse) => errResponse - | None => - // Parse payload - let parseResult = try { - Some(Js.Json.parseExn(body)) - } catch { - | _ => None - } - - switch parseResult { - | None => Http.makeResponse(`{"error": "Invalid JSON"}`, {"status": 400}) - | Some(payload) => - let event = Webhook.parseGitHubEvent(headers, payload) - switch event { - | Some(e) => - info( - `GitHub event: ${e.eventType}`, - ~data=Js.Json.object_( - Js.Dict.fromArray([ - ("repo", Js.Json.string(`${e.repository.owner}/${e.repository.name}`)), - ("action", Js.Json.string(e.action->Belt.Option.getWithDefault(""))), - ]), - ), - ) - - // Handle pull request events - if e.eventType == "pull_request" { - let action = e.action->Belt.Option.getWithDefault("") - if action == "opened" || action == "synchronize" { - // Extract PR info from payload - let (prNumber, baseSha, headSha) = extractPRInfo(payload) - - // Call real analyzer - let analysisResult = await Analysis.analyzeDiff( - config.analysisEndpoint, - e.repository.url, - baseSha, - headSha, - ) - - let comment = switch analysisResult { - | Ok(analysis) => - Report.generatePRComment(analysis, config.mode) - | Error(err) => - error(`Analysis failed: ${err}`) - let analysis = Analysis.mockAnalysis() - Report.generatePRComment(analysis, config.mode) - } - - // Post comment to GitHub if authenticated - let authResult = await GitHubApp.getAuthToken(config, payload) - switch authResult { - | Ok(token) => - let postResult = await GitHubAPI.postPRComment( - token, - e.repository.owner, - e.repository.name, - prNumber, - comment, - ) - switch postResult { - | Ok(commentId) => - info( - `Posted PR comment`, - ~data=Js.Json.object_( - Js.Dict.fromArray([ - ("pr", Js.Json.number(Belt.Int.toFloat(prNumber))), - ("commentId", Js.Json.number(Belt.Int.toFloat(commentId))), - ]), - ), - ) - | Error(err) => - error(`Failed to post PR comment: ${err}`) - } - | Error(err) => - // Not configured for GitHub App - log comment instead - info( - `GitHub App not configured, comment not posted: ${err}`, - ~data=Js.Json.string(comment), - ) - } - } - } - - jsonResponse(Js.Json.object_(Js.Dict.fromArray([("status", Js.Json.string("processed"))]))) - | None => - error("Failed to parse GitHub event") - Http.makeResponse(`{"error": "Invalid event"}`, {"status": 400}) - } - } - } -} - -// Validate GitLab token if configured -let validateGitLabToken = ( - config: config, - headers: Js.Dict.t, -): option => { - switch config.gitlabWebhookSecret { - | Some(secret) => - let token = Js.Dict.get(headers, "x-gitlab-token")->Belt.Option.getWithDefault("") - if !Webhook.verifyGitLabToken(token, secret) { - error("Invalid GitLab webhook token") - Some(Http.makeResponse(`{"error": "Invalid token"}`, {"status": 401})) - } else { - None - } - | None => None - } -} - -// Handle GitLab webhook -let handleGitLabWebhook = async ( - config: config, - headers: Js.Dict.t, - body: string, -): Http.response => { - // Verify token if secret is configured - let tokenError = validateGitLabToken(config, headers) - switch tokenError { - | Some(errResponse) => errResponse - | None => - // Parse payload - let parseResult = try { - Some(Js.Json.parseExn(body)) - } catch { - | _ => None - } - - switch parseResult { - | None => Http.makeResponse(`{"error": "Invalid JSON"}`, {"status": 400}) - | Some(payload) => - let event = Webhook.parseGitLabEvent(headers, payload) - switch event { - | Some(e) => - info( - `GitLab event: ${e.eventType}`, - ~data=Js.Json.object_( - Js.Dict.fromArray([("repo", Js.Json.string(`${e.repository.owner}/${e.repository.name}`))]), - ), - ) - - // Handle merge request events - if e.eventType == "Merge Request Hook" { - let (mrIid, baseSha, headSha) = extractMRInfo(payload) - - let analysisResult = await Analysis.analyzeDiff( - config.analysisEndpoint, - e.repository.url, - baseSha, - headSha, - ) - - switch analysisResult { - | Ok(analysis) => - let comment = Report.generatePRComment(analysis, config.mode) - info(`Generated MR comment for MR !${Belt.Int.toString(mrIid)}`, ~data=Js.Json.string(comment)) - | Error(err) => - error(`Analysis failed: ${err}`) - let analysis = Analysis.mockAnalysis() - let comment = Report.generatePRComment(analysis, config.mode) - info(`Generated fallback MR comment`, ~data=Js.Json.string(comment)) - } - } - - jsonResponse(Js.Json.object_(Js.Dict.fromArray([("status", Js.Json.string("processed"))]))) - | None => - error("Failed to parse GitLab event") - Http.makeResponse(`{"error": "Invalid event"}`, {"status": 400}) - } - } - } -} - -// Main request handler -let handler = (config: config): Http.handler => { - async (req, _connInfo) => { - let url = Http.url(req) - let method = Http.method_(req) - let path = Js.String2.replaceByRe(url, %re("/^https?:\/\/[^\/]+/"), "") - - // Route requests - switch (method, path) { - | ("GET", "/health") => - jsonResponse( - Js.Json.object_( - Js.Dict.fromArray([ - ("status", Js.Json.string("healthy")), - ("mode", Js.Json.string(Config.modeToString(config.mode))), - ]), - ), - ) - - | ("POST", "/webhooks/github") => - let body = await Http.text(req) - let headers = Http.headers(req) - await handleGitHubWebhook(config, headers, body) - - | ("POST", "/webhooks/gitlab") => - let body = await Http.text(req) - let headers = Http.headers(req) - await handleGitLabWebhook(config, headers, body) - - | ("GET", "/metrics") => - // OpenTelemetry metrics endpoint - jsonResponse( - Js.Json.object_( - Js.Dict.fromArray([ - ("oikos_bot_requests_total", Js.Json.number(0.0)), - ("oikos_bot_analyses_total", Js.Json.number(0.0)), - ]), - ), - ) - - | _ => Http.makeResponse("Not Found", {"status": 404}) - } - } -} - -// Main entry point -let main = () => { - switch Config.load() { - | Ok(config) => - info( - `Starting Oikos Bot`, - ~data=Js.Json.object_( - Js.Dict.fromArray([ - ("port", Js.Json.number(Belt.Int.toFloat(config.port))), - ("mode", Js.Json.string(Config.modeToString(config.mode))), - ]), - ), - ) - - Http.serve( - { - port: config.port, - onListen: ({hostname, port}) => { - info(`Server listening on ${hostname}:${Belt.Int.toString(port)}`) - }, - }, - handler(config), - ) - - | Error(e) => - error(`Failed to load config: ${e}`) - } -} - -// Run -main() diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Main.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Main.res.js deleted file mode 100644 index 3049a4a..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Main.res.js +++ /dev/null @@ -1,451 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Config from "./Config.res.js"; -import * as Report from "./Report.res.js"; -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js"; -import * as Webhook from "./Webhook.res.js"; -import * as Analysis from "./Analysis.res.js"; -import * as GitHubAPI from "./GitHubAPI.res.js"; -import * as GitHubApp from "./GitHubApp.res.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; -import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js"; - -function log(level, msg, data) { - let timestamp = new Date().toISOString(); - let logObj = data !== undefined ? Js_dict.fromArray([ - [ - "timestamp", - timestamp - ], - [ - "level", - level - ], - [ - "message", - msg - ], - [ - "data", - data - ] - ]) : Js_dict.fromArray([ - [ - "timestamp", - timestamp - ], - [ - "level", - level - ], - [ - "message", - msg - ] - ]); - console.log(JSON.stringify(logObj)); -} - -function info(msg, data) { - log("info", msg, data); -} - -function error(msg, data) { - log("error", msg, data); -} - -function extractPRInfo(payload) { - let obj = Js_json.decodeObject(payload); - if (obj === undefined) { - return [ - 0, - "", - "" - ]; - } - let n = Js_dict.get(obj, "number"); - let prNumber; - if (n !== undefined) { - let num = Js_json.decodeNumber(n); - prNumber = num !== undefined ? num | 0 : 0; - } else { - prNumber = 0; - } - let pr = Js_dict.get(obj, "pull_request"); - let match; - if (pr !== undefined) { - let prObj = Js_json.decodeObject(pr); - if (prObj !== undefined) { - let b = Js_dict.get(prObj, "base"); - let base; - if (b !== undefined) { - let baseObj = Js_json.decodeObject(b); - if (baseObj !== undefined) { - let s = Js_dict.get(baseObj, "sha"); - base = s !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(s), "") : ""; - } else { - base = ""; - } - } else { - base = ""; - } - let h = Js_dict.get(prObj, "head"); - let head; - if (h !== undefined) { - let headObj = Js_json.decodeObject(h); - if (headObj !== undefined) { - let s$1 = Js_dict.get(headObj, "sha"); - head = s$1 !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(s$1), "") : ""; - } else { - head = ""; - } - } else { - head = ""; - } - match = [ - base, - head - ]; - } else { - match = [ - "", - "" - ]; - } - } else { - match = [ - "", - "" - ]; - } - return [ - prNumber, - match[0], - match[1] - ]; -} - -function extractMRInfo(payload) { - let obj = Js_json.decodeObject(payload); - if (obj === undefined) { - return [ - 0, - "", - "" - ]; - } - let attrs = Js_dict.get(obj, "object_attributes"); - if (attrs === undefined) { - return [ - 0, - "", - "" - ]; - } - let attrsObj = Js_json.decodeObject(attrs); - if (attrsObj === undefined) { - return [ - 0, - "", - "" - ]; - } - let n = Js_dict.get(attrsObj, "iid"); - let mrIid; - if (n !== undefined) { - let num = Js_json.decodeNumber(n); - mrIid = num !== undefined ? num | 0 : 0; - } else { - mrIid = 0; - } - let refs = Js_dict.get(attrsObj, "diff_refs"); - let baseSha; - if (refs !== undefined) { - let refsObj = Js_json.decodeObject(refs); - if (refsObj !== undefined) { - let s = Js_dict.get(refsObj, "base_sha"); - baseSha = s !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(s), "") : ""; - } else { - baseSha = ""; - } - } else { - baseSha = ""; - } - let commit = Js_dict.get(attrsObj, "last_commit"); - let headSha; - if (commit !== undefined) { - let commitObj = Js_json.decodeObject(commit); - if (commitObj !== undefined) { - let s$1 = Js_dict.get(commitObj, "id"); - headSha = s$1 !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(s$1), "") : ""; - } else { - headSha = ""; - } - } else { - headSha = ""; - } - return [ - mrIid, - baseSha, - headSha - ]; -} - -function jsonResponse(data, statusOpt) { - let status = statusOpt !== undefined ? statusOpt : 200; - return new (globalThis.Response)(JSON.stringify(data), { - status: status, - headers: { - "Content-Type": "application/json" - } - }); -} - -async function validateGitHubSignature(config, headers, body) { - let secret = config.githubWebhookSecret; - if (secret === undefined) { - return; - } - let signature = Belt_Option.getWithDefault(Js_dict.get(headers, "x-hub-signature-256"), ""); - let valid = await Webhook.verifyGitHubSignature(body, signature, secret); - if (!valid) { - log("error", "Invalid GitHub webhook signature", undefined); - return Primitive_option.some(new (globalThis.Response)(`{"error": "Invalid signature"}`, { - status: 401 - })); - } -} - -async function handleGitHubWebhook(config, headers, body) { - let signatureError = await validateGitHubSignature(config, headers, body); - if (signatureError !== undefined) { - return Primitive_option.valFromOption(signatureError); - } - let parseResult; - try { - parseResult = JSON.parse(body); - } catch (exn) { - parseResult = undefined; - } - if (parseResult === undefined) { - return new (globalThis.Response)(`{"error": "Invalid JSON"}`, { - status: 400 - }); - } - let event = Webhook.parseGitHubEvent(headers, parseResult); - if (event !== undefined) { - log("info", `GitHub event: ` + event.eventType, Js_dict.fromArray([ - [ - "repo", - event.repository.owner + `/` + event.repository.name - ], - [ - "action", - Belt_Option.getWithDefault(event.action, "") - ] - ])); - if (event.eventType === "pull_request") { - let action = Belt_Option.getWithDefault(event.action, ""); - if (action === "opened" || action === "synchronize") { - let match = extractPRInfo(parseResult); - let prNumber = match[0]; - let analysisResult = await Analysis.analyzeDiff(config.analysisEndpoint, event.repository.url, match[1], match[2]); - let comment; - if (analysisResult.TAG === "Ok") { - comment = Report.generatePRComment(analysisResult._0, config.mode); - } else { - log("error", `Analysis failed: ` + analysisResult._0, undefined); - let analysis = Analysis.mockAnalysis(); - comment = Report.generatePRComment(analysis, config.mode); - } - let authResult = await GitHubApp.getAuthToken(config, parseResult); - if (authResult.TAG === "Ok") { - let postResult = await GitHubAPI.postPRComment(authResult._0, event.repository.owner, event.repository.name, prNumber, comment); - if (postResult.TAG === "Ok") { - log("info", `Posted PR comment`, Js_dict.fromArray([ - [ - "pr", - prNumber - ], - [ - "commentId", - postResult._0 - ] - ])); - } else { - log("error", `Failed to post PR comment: ` + postResult._0, undefined); - } - } else { - log("info", `GitHub App not configured, comment not posted: ` + authResult._0, comment); - } - } - } - return jsonResponse(Js_dict.fromArray([[ - "status", - "processed" - ]]), undefined); - } - log("error", "Failed to parse GitHub event", undefined); - return new (globalThis.Response)(`{"error": "Invalid event"}`, { - status: 400 - }); -} - -function validateGitLabToken(config, headers) { - let secret = config.gitlabWebhookSecret; - if (secret === undefined) { - return; - } - let token = Belt_Option.getWithDefault(Js_dict.get(headers, "x-gitlab-token"), ""); - if (!Webhook.verifyGitLabToken(token, secret)) { - log("error", "Invalid GitLab webhook token", undefined); - return Primitive_option.some(new (globalThis.Response)(`{"error": "Invalid token"}`, { - status: 401 - })); - } -} - -async function handleGitLabWebhook(config, headers, body) { - let tokenError = validateGitLabToken(config, headers); - if (tokenError !== undefined) { - return Primitive_option.valFromOption(tokenError); - } - let parseResult; - try { - parseResult = JSON.parse(body); - } catch (exn) { - parseResult = undefined; - } - if (parseResult === undefined) { - return new (globalThis.Response)(`{"error": "Invalid JSON"}`, { - status: 400 - }); - } - let event = Webhook.parseGitLabEvent(headers, parseResult); - if (event !== undefined) { - log("info", `GitLab event: ` + event.eventType, Js_dict.fromArray([[ - "repo", - event.repository.owner + `/` + event.repository.name - ]])); - if (event.eventType === "Merge Request Hook") { - let match = extractMRInfo(parseResult); - let analysisResult = await Analysis.analyzeDiff(config.analysisEndpoint, event.repository.url, match[1], match[2]); - if (analysisResult.TAG === "Ok") { - let comment = Report.generatePRComment(analysisResult._0, config.mode); - log("info", `Generated MR comment for MR !` + String(match[0]), comment); - } else { - log("error", `Analysis failed: ` + analysisResult._0, undefined); - let analysis = Analysis.mockAnalysis(); - let comment$1 = Report.generatePRComment(analysis, config.mode); - log("info", `Generated fallback MR comment`, comment$1); - } - } - return jsonResponse(Js_dict.fromArray([[ - "status", - "processed" - ]]), undefined); - } - log("error", "Failed to parse GitLab event", undefined); - return new (globalThis.Response)(`{"error": "Invalid event"}`, { - status: 400 - }); -} - -function handler(config) { - return async (req, _connInfo) => { - let url = req.url; - let method = req.method; - let path = url.replace(/^https?:\/\/[^\/]+/, ""); - switch (method) { - case "GET" : - switch (path) { - case "/health" : - return jsonResponse(Js_dict.fromArray([ - [ - "status", - "healthy" - ], - [ - "mode", - Config.modeToString(config.mode) - ] - ]), undefined); - case "/metrics" : - return jsonResponse(Js_dict.fromArray([ - [ - "oikos_bot_requests_total", - 0.0 - ], - [ - "oikos_bot_analyses_total", - 0.0 - ] - ]), undefined); - default: - return new (globalThis.Response)("Not Found", { - status: 404 - }); - } - case "POST" : - switch (path) { - case "/webhooks/github" : - let body = await req.text(); - let headers = req.headers; - return await handleGitHubWebhook(config, headers, body); - case "/webhooks/gitlab" : - let body$1 = await req.text(); - let headers$1 = req.headers; - return await handleGitLabWebhook(config, headers$1, body$1); - default: - return new (globalThis.Response)("Not Found", { - status: 404 - }); - } - default: - return new (globalThis.Response)("Not Found", { - status: 404 - }); - } - }; -} - -function main() { - let config = Config.load(); - if (config.TAG !== "Ok") { - return log("error", `Failed to load config: ` + config._0, undefined); - } - let config$1 = config._0; - log("info", `Starting Oikos Bot`, Js_dict.fromArray([ - [ - "port", - config$1.port - ], - [ - "mode", - Config.modeToString(config$1.mode) - ] - ])); - Deno.serve({ - port: config$1.port, - onListen: param => log("info", `Server listening on ` + param.hostname + `:` + String(param.port), undefined) - }, handler(config$1)); -} - -main(); - -export { - log, - info, - error, - extractPRInfo, - extractMRInfo, - jsonResponse, - validateGitHubSignature, - handleGitHubWebhook, - validateGitLabToken, - handleGitLabWebhook, - handler, - main, -} -/* Not a pure module */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.ast b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.ast deleted file mode 100644 index b458ed7..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmi deleted file mode 100644 index 392a56c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmj deleted file mode 100644 index f1d2252..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmt deleted file mode 100644 index b741692..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res deleted file mode 100644 index db5b339..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res +++ /dev/null @@ -1,265 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Oikos - Ecological & Economic Code Analysis -// οἶκος: Greek root of both "ecology" and "economy" -// TEA Architecture - Model-Update-Subscriptions pattern - -open ServerTea - -// ============================================================================= -// MODEL -// ============================================================================= - -type botMode = - | Advisor - | Consultant - | Regulator - -type webhookSource = - | GitHub - | GitLab - -type analysisStatus = - | Pending - | InProgress - | Completed(Types.analysisResult) - | Failed(string) - -type pendingAnalysis = { - id: string, - repo: string, - prNumber: int, - status: analysisStatus, - createdAt: float, -} - -type model = { - mode: botMode, - port: int, - webhookSecret: option, - appId: option, - privateKeyPath: option, - pendingAnalyses: array, - totalProcessed: int, - startTime: float, - healthy: bool, -} - -// ============================================================================= -// MESSAGES -// ============================================================================= - -type msg = - // Webhook events - | WebhookReceived(webhookSource, Js.Json.t) - | WebhookVerified(webhookSource, Js.Json.t) - | WebhookRejected(string) - // Analysis lifecycle - | AnalysisRequested(string, string, int) // id, repo, prNumber - | AnalysisStarted(string) - | AnalysisCompleted(string, Types.analysisResult) - | AnalysisFailed(string, string) - // GitHub API responses - | CommentPosted(string, int) - | CommentFailed(string, string) - // System - | HealthCheck - | Tick - | Shutdown - -// ============================================================================= -// INIT -// ============================================================================= - -type flags = { - port: int, - mode: string, - webhookSecret: option, - appId: option, - privateKeyPath: option, -} - -let modeFromString = str => - switch str { - | "consultant" => Consultant - | "regulator" => Regulator - | _ => Advisor - } - -let init = (flags: flags) => { - let model = { - mode: modeFromString(flags.mode), - port: flags.port, - webhookSecret: flags.webhookSecret, - appId: flags.appId, - privateKeyPath: flags.privateKeyPath, - pendingAnalyses: [], - totalProcessed: 0, - startTime: Js.Date.now(), - healthy: true, - } - - Js.Console.log(`🏛️ Oikos Bot starting...`) - Js.Console.log(` Mode: ${flags.mode}`) - Js.Console.log(` Port: ${flags.port->Belt.Int.toString}`) - - (model, Cmd.none) -} - -// ============================================================================= -// UPDATE -// ============================================================================= - -let update = (msg: msg, model: model) => { - switch msg { - | WebhookReceived(source, payload) => { - let sourceStr = switch source { - | GitHub => "GitHub" - | GitLab => "GitLab" - } - Js.Console.log(`📨 Webhook received from ${sourceStr}`) - // TODO: Verify signature then dispatch WebhookVerified - (model, Cmd.perform(async () => payload, p => WebhookVerified(source, p))) - } - - | WebhookVerified(source, payload) => { - Js.Console.log(`✓ Webhook verified`) - // Parse the webhook and determine action - let _ = source - let _ = payload - // TODO: Parse event type, extract PR info, start analysis - ({...model, totalProcessed: model.totalProcessed + 1}, Cmd.none) - } - - | WebhookRejected(reason) => { - Js.Console.error(`✗ Webhook rejected: ${reason}`) - (model, Cmd.none) - } - - | AnalysisRequested(id, repo, prNumber) => { - Js.Console.log(`🔍 Analysis requested: ${repo}#${prNumber->Belt.Int.toString}`) - let analysis = { - id, - repo, - prNumber, - status: Pending, - createdAt: Js.Date.now(), - } - ( - {...model, pendingAnalyses: model.pendingAnalyses->Js.Array2.concat([analysis])}, - Cmd.none, - ) - } - - | AnalysisStarted(id) => { - let pendingAnalyses = - model.pendingAnalyses->Js.Array2.map(a => - if a.id == id { - {...a, status: InProgress} - } else { - a - } - ) - ({...model, pendingAnalyses}, Cmd.none) - } - - | AnalysisCompleted(id, result) => { - Js.Console.log(`✓ Analysis completed: ${id}`) - let pendingAnalyses = - model.pendingAnalyses->Js.Array2.map(a => - if a.id == id { - {...a, status: Completed(result)} - } else { - a - } - ) - ({...model, pendingAnalyses}, Cmd.none) - } - - | AnalysisFailed(id, error) => { - Js.Console.error(`✗ Analysis failed: ${id} - ${error}`) - let pendingAnalyses = - model.pendingAnalyses->Js.Array2.map(a => - if a.id == id { - {...a, status: Failed(error)} - } else { - a - } - ) - ({...model, pendingAnalyses}, Cmd.none) - } - - | CommentPosted(repo, prNumber) => { - Js.Console.log(`💬 Comment posted to ${repo}#${prNumber->Belt.Int.toString}`) - (model, Cmd.none) - } - - | CommentFailed(repo, error) => { - Js.Console.error(`✗ Failed to post comment to ${repo}: ${error}`) - (model, Cmd.none) - } - - | HealthCheck => { - Js.Console.log(`💚 Health check - processed: ${model.totalProcessed->Belt.Int.toString}`) - (model, Cmd.none) - } - - | Tick => { - // Periodic cleanup of old analyses - let now = Js.Date.now() - let oneHour = 60.0 *. 60.0 *. 1000.0 - let pendingAnalyses = - model.pendingAnalyses->Js.Array2.filter(a => now -. a.createdAt < oneHour) - ({...model, pendingAnalyses}, Cmd.none) - } - - | Shutdown => { - Js.Console.log(`👋 Shutting down...`) - ({...model, healthy: false}, Cmd.none) - } - } -} - -// ============================================================================= -// SUBSCRIPTIONS -// ============================================================================= - -let subscriptions = (model: model) => { - if model.healthy { - Sub.batch([ - Sub.httpServer(model.port, json => Some(WebhookReceived(GitHub, json))), - Sub.every(60000, () => Tick), - ]) - } else { - Sub.none - } -} - -// ============================================================================= -// RUN -// ============================================================================= - -let run = () => { - let port = switch Deno.Env.get("PORT") { - | Some(p) => Belt.Int.fromString(p)->Belt.Option.getWithDefault(3000) - | None => 3000 - } - let mode = Deno.Env.get("BOT_MODE")->Belt.Option.getWithDefault("advisor") - let webhookSecret = Deno.Env.get("GITHUB_WEBHOOK_SECRET") - let appId = Deno.Env.get("GITHUB_APP_ID") - let privateKeyPath = Deno.Env.get("GITHUB_PRIVATE_KEY_PATH") - - let flags = { - port, - mode, - webhookSecret, - appId, - privateKeyPath, - } - - Runtime.make(~init, ~update, ~subscriptions, ~flags) -} - -// Auto-run when imported as main module -let _ = run() diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res.js deleted file mode 100644 index 1904207..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Oikos.res.js +++ /dev/null @@ -1,309 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Belt_Int from "@rescript/runtime/lib/es6/Belt_Int.js"; -import * as ServerTea from "./tea/ServerTea.res.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; - -function modeFromString(str) { - switch (str) { - case "consultant" : - return "Consultant"; - case "regulator" : - return "Regulator"; - default: - return "Advisor"; - } -} - -function init(flags) { - let model_mode = modeFromString(flags.mode); - let model_port = flags.port; - let model_webhookSecret = flags.webhookSecret; - let model_appId = flags.appId; - let model_privateKeyPath = flags.privateKeyPath; - let model_pendingAnalyses = []; - let model_startTime = Date.now(); - let model = { - mode: model_mode, - port: model_port, - webhookSecret: model_webhookSecret, - appId: model_appId, - privateKeyPath: model_privateKeyPath, - pendingAnalyses: model_pendingAnalyses, - totalProcessed: 0, - startTime: model_startTime, - healthy: true - }; - console.log(`🏛️ Oikos Bot starting...`); - console.log(` Mode: ` + flags.mode); - console.log(` Port: ` + String(flags.port)); - return [ - model, - ServerTea.Cmd.none - ]; -} - -function update(msg, model) { - if (typeof msg !== "object") { - switch (msg) { - case "HealthCheck" : - console.log(`💚 Health check - processed: ` + String(model.totalProcessed)); - return [ - model, - ServerTea.Cmd.none - ]; - case "Tick" : - let now = Date.now(); - let oneHour = 60.0 * 60.0 * 1000.0; - let pendingAnalyses = model.pendingAnalyses.filter(a => now - a.createdAt < oneHour); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: pendingAnalyses, - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "Shutdown" : - console.log(`👋 Shutting down...`); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: model.pendingAnalyses, - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: false - }, - ServerTea.Cmd.none - ]; - } - } else { - switch (msg.TAG) { - case "WebhookReceived" : - let payload = msg._1; - let source = msg._0; - let sourceStr; - sourceStr = source === "GitHub" ? "GitHub" : "GitLab"; - console.log(`📨 Webhook received from ` + sourceStr); - return [ - model, - ServerTea.Cmd.perform(async () => payload, p => ({ - TAG: "WebhookVerified", - _0: source, - _1: p - })) - ]; - case "WebhookVerified" : - console.log(`✓ Webhook verified`); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: model.pendingAnalyses, - totalProcessed: model.totalProcessed + 1 | 0, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "WebhookRejected" : - console.error(`✗ Webhook rejected: ` + msg._0); - return [ - model, - ServerTea.Cmd.none - ]; - case "AnalysisRequested" : - let prNumber = msg._2; - let repo = msg._1; - console.log(`🔍 Analysis requested: ` + repo + `#` + String(prNumber)); - let analysis_id = msg._0; - let analysis_createdAt = Date.now(); - let analysis = { - id: analysis_id, - repo: repo, - prNumber: prNumber, - status: "Pending", - createdAt: analysis_createdAt - }; - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: model.pendingAnalyses.concat([analysis]), - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "AnalysisStarted" : - let id = msg._0; - let pendingAnalyses$1 = model.pendingAnalyses.map(a => { - if (a.id === id) { - return { - id: a.id, - repo: a.repo, - prNumber: a.prNumber, - status: "InProgress", - createdAt: a.createdAt - }; - } else { - return a; - } - }); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: pendingAnalyses$1, - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "AnalysisCompleted" : - let result = msg._1; - let id$1 = msg._0; - console.log(`✓ Analysis completed: ` + id$1); - let pendingAnalyses$2 = model.pendingAnalyses.map(a => { - if (a.id === id$1) { - return { - id: a.id, - repo: a.repo, - prNumber: a.prNumber, - status: { - TAG: "Completed", - _0: result - }, - createdAt: a.createdAt - }; - } else { - return a; - } - }); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: pendingAnalyses$2, - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "AnalysisFailed" : - let error = msg._1; - let id$2 = msg._0; - console.error(`✗ Analysis failed: ` + id$2 + ` - ` + error); - let pendingAnalyses$3 = model.pendingAnalyses.map(a => { - if (a.id === id$2) { - return { - id: a.id, - repo: a.repo, - prNumber: a.prNumber, - status: { - TAG: "Failed", - _0: error - }, - createdAt: a.createdAt - }; - } else { - return a; - } - }); - return [ - { - mode: model.mode, - port: model.port, - webhookSecret: model.webhookSecret, - appId: model.appId, - privateKeyPath: model.privateKeyPath, - pendingAnalyses: pendingAnalyses$3, - totalProcessed: model.totalProcessed, - startTime: model.startTime, - healthy: model.healthy - }, - ServerTea.Cmd.none - ]; - case "CommentPosted" : - console.log(`💬 Comment posted to ` + msg._0 + `#` + String(msg._1)); - return [ - model, - ServerTea.Cmd.none - ]; - case "CommentFailed" : - console.error(`✗ Failed to post comment to ` + msg._0 + `: ` + msg._1); - return [ - model, - ServerTea.Cmd.none - ]; - } - } -} - -function subscriptions(model) { - if (model.healthy) { - return ServerTea.Sub.batch([ - ServerTea.Sub.httpServer(model.port, json => ({ - TAG: "WebhookReceived", - _0: "GitHub", - _1: json - })), - ServerTea.Sub.every(60000, () => "Tick") - ]); - } else { - return ServerTea.Sub.none; - } -} - -function run() { - let p = Deno.env.get("PORT"); - let port = p !== undefined ? Belt_Option.getWithDefault(Belt_Int.fromString(p), 3000) : 3000; - let mode = Belt_Option.getWithDefault(Deno.env.get("BOT_MODE"), "advisor"); - let webhookSecret = Deno.env.get("GITHUB_WEBHOOK_SECRET"); - let appId = Deno.env.get("GITHUB_APP_ID"); - let privateKeyPath = Deno.env.get("GITHUB_PRIVATE_KEY_PATH"); - let flags = { - port: port, - mode: mode, - webhookSecret: webhookSecret, - appId: appId, - privateKeyPath: privateKeyPath - }; - return ServerTea.Runtime.make(init, update, subscriptions, flags); -} - -run(); - -export { - modeFromString, - init, - update, - subscriptions, - run, -} -/* Not a pure module */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.ast b/bots/sustainabot/bot-integration/lib/bs/src/Report.ast deleted file mode 100644 index edf6f04..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Report.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Report.cmi deleted file mode 100644 index 9b3d9d1..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Report.cmj deleted file mode 100644 index 17a7cdf..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Report.cmt deleted file mode 100644 index e2a682e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Report.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.res b/bots/sustainabot/bot-integration/lib/bs/src/Report.res deleted file mode 100644 index f0d564b..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Report.res +++ /dev/null @@ -1,212 +0,0 @@ -// Report generation for PR comments and SARIF output - -open Types - -let getGrade = (score: float): string => { - if score >= 90.0 { - "A" - } else if score >= 80.0 { - "B" - } else if score >= 70.0 { - "C" - } else if score >= 60.0 { - "D" - } else { - "F" - } -} - -let getGradeEmoji = (grade: string): string => { - switch grade { - | "A" => "🏆" - | "B" => "✨" - | "C" => "👍" - | "D" => "⚠️" - | _ => "🚨" - } -} - -let getStatusEmoji = (score: float): string => { - if score >= 70.0 { - "✅" - } else if score >= 50.0 { - "⚠️" - } else { - "❌" - } -} - -let severityToString = (s: severity): string => { - switch s { - | Blocking => "blocking" - | High => "high" - | Medium => "medium" - | Low => "low" - | Info => "info" - } -} - -let generatePRComment = (analysis: analysisResult, mode: botMode): string => { - let grade = getGrade(analysis.health.total) - let gradeEmoji = getGradeEmoji(grade) - - let header = `## 🏛️ Oikos Analysis\n\n` - - let healthLine = `### Overall Health: ${gradeEmoji} ${grade} (${Belt.Float.toString( - analysis.health.total, - )}/100)\n\n` - - let scoreTable = - `| Metric | Score | Status |\n` ++ - `|--------|-------|--------|\n` ++ - `| 🌍 Ecological | ${Belt.Float.toString(analysis.eco.score)} | ${getStatusEmoji( - analysis.eco.score, - )} |\n` ++ - `| 📊 Economic | ${Belt.Float.toString(analysis.econ.score)} | ${getStatusEmoji( - analysis.econ.score, - )} |\n` ++ - `| ⚙️ Quality | ${Belt.Float.toString(analysis.quality.score)} | ${getStatusEmoji( - analysis.quality.score, - )} |\n\n` - - // Violations section - let violationsSection = if Belt.Array.length(analysis.violations) > 0 { - let violationLines = - analysis.violations - ->Belt.Array.map(v => { - let icon = v.severity == Blocking ? "🚫" : "⚠️" - `${icon} **${v.policy}**: ${v.message}\n` - }) - ->Belt.Array.joinWith("", s => s) - - `### ⚠️ Policy Violations\n\n${violationLines}\n` - } else { - "" - } - - // Recommendations section (limited by mode) - let recommendationsSection = if Belt.Array.length(analysis.recommendations) > 0 && mode != Regulator { - let maxRecs = mode == Consultant ? 10 : 5 - let topRecs = Belt.Array.slice(analysis.recommendations, ~offset=0, ~len=maxRecs) - - let recLines = - topRecs - ->Belt.Array.map(r => { - let confidence = Belt.Float.toInt(r.confidence *. 100.0) - `- **${r.action}** (${Belt.Int.toString(confidence)}% confidence): ${r.reason}\n` - }) - ->Belt.Array.joinWith("", s => s) - - `### 💡 Recommendations\n\n${recLines}\n` - } else { - "" - } - - // Pareto section - let paretoSection = switch analysis.econ.paretoStatus { - | Some(ps) => - let status = if ps.isOptimal { - `✅ This code is on the Pareto frontier - no dominated trade-offs detected.\n\n` - } else { - let improvements = switch ps.improvements { - | Some(imps) => - imps->Belt.Array.map(i => `- ${i}\n`)->Belt.Array.joinWith("", s => s) - | None => "" - } - `📍 Distance from Pareto frontier: ${Belt.Float.toString(ps.distance)}\n\n` ++ - (improvements != "" ? `Potential Pareto improvements:\n${improvements}\n` : "") - } - `### 📈 Pareto Analysis\n\n${status}` - | None => "" - } - - // Footer - let footer = - `---\n` ++ - `*Analyzed by [Oikos Bot](https://github.com/hyperpolymath/oikos-bot) | ` ++ - `Mode: ${Config.modeToString(mode)} | ` ++ - `[Learn more about eco-friendly coding](https://greensoftware.foundation/)*\n` - - header ++ healthLine ++ scoreTable ++ violationsSection ++ recommendationsSection ++ paretoSection ++ footer -} - -// Generate SARIF for code scanning integration -let generateSARIF = (analysis: analysisResult): Js.Json.t => { - let rules = [ - Js.Dict.fromArray([ - ("id", Js.Json.string("eco/eco-minimum")), - ("name", Js.Json.string("EcoMinimum")), - ( - "shortDescription", - Js.Json.object_( - Js.Dict.fromArray([("text", Js.Json.string("Eco minimum threshold not met"))]), - ), - ), - ]), - Js.Dict.fromArray([ - ("id", Js.Json.string("eco/eco-standard")), - ("name", Js.Json.string("EcoStandard")), - ( - "shortDescription", - Js.Json.object_( - Js.Dict.fromArray([("text", Js.Json.string("Eco standard threshold not met"))]), - ), - ), - ]), - ] - - let results = - analysis.violations->Belt.Array.map(v => { - Js.Dict.fromArray([ - ("ruleId", Js.Json.string(`eco/${Js.String.replace("_", "-", v.policy)}`)), - ("level", Js.Json.string(v.severity == Blocking ? "error" : "warning")), - ( - "message", - Js.Json.object_(Js.Dict.fromArray([("text", Js.Json.string(v.message))])), - ), - ]) - }) - - Js.Json.object_( - Js.Dict.fromArray([ - ( - "$schema", - Js.Json.string( - "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", - ), - ), - ("version", Js.Json.string("2.1.0")), - ( - "runs", - Js.Json.array([ - Js.Json.object_( - Js.Dict.fromArray([ - ( - "tool", - Js.Json.object_( - Js.Dict.fromArray([ - ( - "driver", - Js.Json.object_( - Js.Dict.fromArray([ - ("name", Js.Json.string("oikos-bot")), - ("version", Js.Json.string("0.1.0-beta")), - ( - "informationUri", - Js.Json.string("https://github.com/hyperpolymath/oikos-bot"), - ), - ("rules", Js.Json.array(rules->Belt.Array.map(Js.Json.object_))), - ]), - ), - ), - ]), - ), - ), - ("results", Js.Json.array(results->Belt.Array.map(Js.Json.object_))), - ]), - ), - ]), - ), - ]), - ) -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Report.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Report.res.js deleted file mode 100644 index 0af1e64..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Report.res.js +++ /dev/null @@ -1,217 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Config from "./Config.res.js"; -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Js_string from "@rescript/runtime/lib/es6/Js_string.js"; -import * as Belt_Array from "@rescript/runtime/lib/es6/Belt_Array.js"; - -function getGrade(score) { - if (score >= 90.0) { - return "A"; - } else if (score >= 80.0) { - return "B"; - } else if (score >= 70.0) { - return "C"; - } else if (score >= 60.0) { - return "D"; - } else { - return "F"; - } -} - -function getGradeEmoji(grade) { - switch (grade) { - case "A" : - return "🏆"; - case "B" : - return "✨"; - case "C" : - return "👍"; - case "D" : - return "⚠️"; - default: - return "🚨"; - } -} - -function getStatusEmoji(score) { - if (score >= 70.0) { - return "✅"; - } else if (score >= 50.0) { - return "⚠️"; - } else { - return "❌"; - } -} - -function severityToString(s) { - switch (s) { - case "Blocking" : - return "blocking"; - case "High" : - return "high"; - case "Medium" : - return "medium"; - case "Low" : - return "low"; - case "Info" : - return "info"; - } -} - -function generatePRComment(analysis, mode) { - let grade = getGrade(analysis.health.total); - let gradeEmoji = getGradeEmoji(grade); - let healthLine = `### Overall Health: ` + gradeEmoji + ` ` + grade + ` (` + String(analysis.health.total) + `/100)\n\n`; - let scoreTable = `| Metric | Score | Status |\n|--------|-------|--------|\n` + (`| 🌍 Ecological | ` + String(analysis.eco.score) + ` | ` + getStatusEmoji(analysis.eco.score) + ` |\n`) + (`| 📊 Economic | ` + String(analysis.econ.score) + ` | ` + getStatusEmoji(analysis.econ.score) + ` |\n`) + (`| ⚙️ Quality | ` + String(analysis.quality.score) + ` | ` + getStatusEmoji(analysis.quality.score) + ` |\n\n`); - let violationsSection; - if (analysis.violations.length !== 0) { - let violationLines = Belt_Array.joinWith(Belt_Array.map(analysis.violations, v => { - let icon = v.severity === "Blocking" ? "🚫" : "⚠️"; - return icon + ` **` + v.policy + `**: ` + v.message + `\n`; - }), "", s => s); - violationsSection = `### ⚠️ Policy Violations\n\n` + violationLines + `\n`; - } else { - violationsSection = ""; - } - let recommendationsSection; - if (analysis.recommendations.length !== 0 && mode !== "Regulator") { - let maxRecs = mode === "Consultant" ? 10 : 5; - let topRecs = Belt_Array.slice(analysis.recommendations, 0, maxRecs); - let recLines = Belt_Array.joinWith(Belt_Array.map(topRecs, r => { - let confidence = r.confidence * 100.0 | 0; - return `- **` + r.action + `** (` + String(confidence) + `% confidence): ` + r.reason + `\n`; - }), "", s => s); - recommendationsSection = `### 💡 Recommendations\n\n` + recLines + `\n`; - } else { - recommendationsSection = ""; - } - let ps = analysis.econ.paretoStatus; - let paretoSection; - if (ps !== undefined) { - let status; - if (ps.isOptimal) { - status = `✅ This code is on the Pareto frontier - no dominated trade-offs detected.\n\n`; - } else { - let imps = ps.improvements; - let improvements = imps !== undefined ? Belt_Array.joinWith(Belt_Array.map(imps, i => `- ` + i + `\n`), "", s => s) : ""; - status = `📍 Distance from Pareto frontier: ` + String(ps.distance) + `\n\n` + ( - improvements !== "" ? `Potential Pareto improvements:\n` + improvements + `\n` : "" - ); - } - paretoSection = `### 📈 Pareto Analysis\n\n` + status; - } else { - paretoSection = ""; - } - let footer = `---\n*Analyzed by [Oikos Bot](https://github.com/hyperpolymath/oikos-bot) | ` + (`Mode: ` + Config.modeToString(mode) + ` | `) + `[Learn more about eco-friendly coding](https://greensoftware.foundation/)*\n`; - return `## 🏛️ Oikos Analysis\n\n` + healthLine + scoreTable + violationsSection + recommendationsSection + paretoSection + footer; -} - -function generateSARIF(analysis) { - let rules = [ - Js_dict.fromArray([ - [ - "id", - "eco/eco-minimum" - ], - [ - "name", - "EcoMinimum" - ], - [ - "shortDescription", - Js_dict.fromArray([[ - "text", - "Eco minimum threshold not met" - ]]) - ] - ]), - Js_dict.fromArray([ - [ - "id", - "eco/eco-standard" - ], - [ - "name", - "EcoStandard" - ], - [ - "shortDescription", - Js_dict.fromArray([[ - "text", - "Eco standard threshold not met" - ]]) - ] - ]) - ]; - let results = Belt_Array.map(analysis.violations, v => Js_dict.fromArray([ - [ - "ruleId", - `eco/` + Js_string.replace("_", "-", v.policy) - ], - [ - "level", - v.severity === "Blocking" ? "error" : "warning" - ], - [ - "message", - Js_dict.fromArray([[ - "text", - v.message - ]]) - ] - ])); - return Js_dict.fromArray([ - [ - "$schema", - "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json" - ], - [ - "version", - "2.1.0" - ], - [ - "runs", - [Js_dict.fromArray([ - [ - "tool", - Js_dict.fromArray([[ - "driver", - Js_dict.fromArray([ - [ - "name", - "oikos-bot" - ], - [ - "version", - "0.1.0-beta" - ], - [ - "informationUri", - "https://github.com/hyperpolymath/oikos-bot" - ], - [ - "rules", - Belt_Array.map(rules, prim => prim) - ] - ]) - ]]) - ], - [ - "results", - Belt_Array.map(results, prim => prim) - ] - ])] - ] - ]); -} - -export { - getGrade, - getGradeEmoji, - getStatusEmoji, - severityToString, - generatePRComment, - generateSARIF, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.ast b/bots/sustainabot/bot-integration/lib/bs/src/Router.ast deleted file mode 100644 index bf8e3f5..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Router.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Router.cmi deleted file mode 100644 index 7e6fc2c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Router.cmj deleted file mode 100644 index a12dd6e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Router.cmt deleted file mode 100644 index c2a90ef..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Router.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.res b/bots/sustainabot/bot-integration/lib/bs/src/Router.res deleted file mode 100644 index 74aeba1..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Router.res +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// HTTP Router for Oikos Bot -// Adapted from hyperpolymath/rescript-wasm-runtime - -type method = GET | POST | PUT | DELETE | PATCH | OPTIONS | HEAD - -type handler = Deno.Request.t => promise - -type middleware = (Deno.Request.t, unit => promise) => promise - -type route = { - method: method, - path: string, - handler: handler, -} - -type t = { - routes: array, - middlewares: array, - notFoundHandler: option, -} - -let make = (): t => { - routes: [], - middlewares: [], - notFoundHandler: None, -} - -let methodToString = (method: method): string => { - switch method { - | GET => "GET" - | POST => "POST" - | PUT => "PUT" - | DELETE => "DELETE" - | PATCH => "PATCH" - | OPTIONS => "OPTIONS" - | HEAD => "HEAD" - } -} - -let methodFromString = (str: string): option => { - switch str { - | "GET" => Some(GET) - | "POST" => Some(POST) - | "PUT" => Some(PUT) - | "DELETE" => Some(DELETE) - | "PATCH" => Some(PATCH) - | "OPTIONS" => Some(OPTIONS) - | "HEAD" => Some(HEAD) - | _ => None - } -} - -// Path matching with named parameters -let matchPath = (pattern: string, path: string): option> => { - let patternParts = pattern->Js.String2.split("/")->Js.Array2.filter(p => p !== "") - let pathParts = path->Js.String2.split("/")->Js.Array2.filter(p => p !== "") - - if Js.Array2.length(patternParts) !== Js.Array2.length(pathParts) { - None - } else { - let params = Js.Dict.empty() - let matches = ref(true) - - patternParts->Js.Array2.forEachi((patternPart, i) => { - let pathPart = pathParts->Js.Array2.unsafe_get(i) - - if Js.String2.startsWith(patternPart, ":") { - let paramName = Js.String2.sliceToEnd(patternPart, ~from=1) - Js.Dict.set(params, paramName, pathPart) - } else if patternPart !== pathPart { - matches := false - } - }) - - if matches.contents { - Some(params) - } else { - None - } - } -} - -// Add route -let addRoute = (router: t, method: method, path: string, handler: handler): t => { - let newRoute = {method, path, handler} - {...router, routes: router.routes->Js.Array2.concat([newRoute])} -} - -// Route registration helpers -let get = (router: t, path: string, handler: handler): t => { - addRoute(router, GET, path, handler) -} - -let post = (router: t, path: string, handler: handler): t => { - addRoute(router, POST, path, handler) -} - -let put = (router: t, path: string, handler: handler): t => { - addRoute(router, PUT, path, handler) -} - -let delete = (router: t, path: string, handler: handler): t => { - addRoute(router, DELETE, path, handler) -} - -let patch = (router: t, path: string, handler: handler): t => { - addRoute(router, PATCH, path, handler) -} - -let options = (router: t, path: string, handler: handler): t => { - addRoute(router, OPTIONS, path, handler) -} - -// Add middleware -let use = (router: t, middleware: middleware): t => { - {...router, middlewares: router.middlewares->Js.Array2.concat([middleware])} -} - -// Set custom 404 handler -let notFound = (router: t, handler: handler): t => { - {...router, notFoundHandler: Some(handler)} -} - -// URL parsing helper -module Url = { - type t - @new external make: string => t = "URL" - @get external pathname: t => string = "pathname" -} - -// Handle request -let handle = async (router: t, req: Deno.Request.t): Deno.Response.t => { - let methodStr = Deno.Request.method_(req) - let url = Deno.Request.url(req) - let urlObj = Url.make(url) - let path = Url.pathname(urlObj) - - let methodEnum = methodFromString(methodStr) - - switch methodEnum { - | None => Deno.Response.make("Method not allowed", {"status": 405}) - | Some(m) => { - // Find matching route - let matchingRoute = ref(None) - - router.routes->Js.Array2.forEach(route => { - if route.method === m { - switch matchPath(route.path, path) { - | Some(_params) => matchingRoute := Some(route) - | None => () - } - } - }) - - switch matchingRoute.contents { - | None => { - // No route found, use 404 handler - switch router.notFoundHandler { - | Some(handler) => await handler(req) - | None => Deno.Response.make("Not Found", {"status": 404}) - } - } - | Some(route) => { - // Apply middlewares (in reverse order to build the chain) - let finalHandler = () => route.handler(req) - - let wrappedHandler = router.middlewares->Js.Array2.reduceRight( - (next, middleware) => { - () => middleware(req, next) - }, - finalHandler, - ) - - await wrappedHandler() - } - } - } - } -} - -// Start server with router -let serve = (router: t, ~port: int) => { - Deno.serve( - {"port": port}, - async (req) => await handle(router, req), - ) -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Router.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Router.res.js deleted file mode 100644 index c247655..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Router.res.js +++ /dev/null @@ -1,199 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - - -function make() { - return { - routes: [], - middlewares: [], - notFoundHandler: undefined - }; -} - -function methodToString(method) { - switch (method) { - case "GET" : - return "GET"; - case "POST" : - return "POST"; - case "PUT" : - return "PUT"; - case "DELETE" : - return "DELETE"; - case "PATCH" : - return "PATCH"; - case "OPTIONS" : - return "OPTIONS"; - case "HEAD" : - return "HEAD"; - } -} - -function methodFromString(str) { - switch (str) { - case "DELETE" : - return "DELETE"; - case "GET" : - return "GET"; - case "HEAD" : - return "HEAD"; - case "OPTIONS" : - return "OPTIONS"; - case "PATCH" : - return "PATCH"; - case "POST" : - return "POST"; - case "PUT" : - return "PUT"; - default: - return; - } -} - -function matchPath(pattern, path) { - let patternParts = pattern.split("/").filter(p => p !== ""); - let pathParts = path.split("/").filter(p => p !== ""); - if (patternParts.length !== pathParts.length) { - return; - } - let params = {}; - let matches = { - contents: true - }; - patternParts.forEach((patternPart, i) => { - let pathPart = pathParts[i]; - if (!patternPart.startsWith(":")) { - if (patternPart !== pathPart) { - matches.contents = false; - return; - } else { - return; - } - } - let paramName = patternPart.slice(1); - params[paramName] = pathPart; - }); - if (matches.contents) { - return params; - } -} - -function addRoute(router, method, path, handler) { - let newRoute = { - method: method, - path: path, - handler: handler - }; - return { - routes: router.routes.concat([newRoute]), - middlewares: router.middlewares, - notFoundHandler: router.notFoundHandler - }; -} - -function get(router, path, handler) { - return addRoute(router, "GET", path, handler); -} - -function post(router, path, handler) { - return addRoute(router, "POST", path, handler); -} - -function put(router, path, handler) { - return addRoute(router, "PUT", path, handler); -} - -function $$delete(router, path, handler) { - return addRoute(router, "DELETE", path, handler); -} - -function patch(router, path, handler) { - return addRoute(router, "PATCH", path, handler); -} - -function options(router, path, handler) { - return addRoute(router, "OPTIONS", path, handler); -} - -function use(router, middleware) { - return { - routes: router.routes, - middlewares: router.middlewares.concat([middleware]), - notFoundHandler: router.notFoundHandler - }; -} - -function notFound(router, handler) { - return { - routes: router.routes, - middlewares: router.middlewares, - notFoundHandler: handler - }; -} - -let Url = {}; - -async function handle(router, req) { - let methodStr = req.method; - let url = req.url; - let urlObj = new URL(url); - let path = urlObj.pathname; - let methodEnum = methodFromString(methodStr); - if (methodEnum === undefined) { - return new (globalThis.Response)("Method not allowed", { - status: 405 - }); - } - let matchingRoute = { - contents: undefined - }; - router.routes.forEach(route => { - if (route.method !== methodEnum) { - return; - } - let _params = matchPath(route.path, path); - if (_params !== undefined) { - matchingRoute.contents = route; - return; - } - }); - let route = matchingRoute.contents; - if (route !== undefined) { - let finalHandler = () => route.handler(req); - let wrappedHandler = router.middlewares.reduceRight((next, middleware) => (() => middleware(req, next)), finalHandler); - return await wrappedHandler(); - } - let handler = router.notFoundHandler; - if (handler !== undefined) { - return await handler(req); - } else { - return new (globalThis.Response)("Not Found", { - status: 404 - }); - } -} - -function serve(router, port) { - return Deno.serve({ - port: port - }, async req => await handle(router, req)); -} - -export { - make, - methodToString, - methodFromString, - matchPath, - addRoute, - get, - post, - put, - $$delete, - patch, - options, - use, - notFound, - Url, - handle, - serve, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.ast b/bots/sustainabot/bot-integration/lib/bs/src/Types.ast deleted file mode 100644 index bd4f4fb..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Types.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Types.cmi deleted file mode 100644 index 03bd9dc..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Types.cmj deleted file mode 100644 index 4fd79db..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Types.cmt deleted file mode 100644 index 22c6225..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Types.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.res b/bots/sustainabot/bot-integration/lib/bs/src/Types.res deleted file mode 100644 index 07c3926..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Types.res +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// SPDX-FileCopyrightText: 2024-2025 hyperpolymath -// -// Core types for Oikos Bot analysis - -type codeLocation = { - file: string, - line: int, - column: option, -} - -type ecoMetrics = { - carbonScore: float, - energyScore: float, - resourceScore: float, - score: float, -} - -type paretoStatus = { - isOptimal: bool, - distance: float, - improvements: option>, -} - -type econMetrics = { - paretoDistance: float, - allocationScore: float, - debtScore: float, - score: float, - paretoStatus: option, -} - -type qualityMetrics = { - complexityScore: float, - couplingScore: float, - coverageScore: float, - score: float, -} - -type healthIndex = { - eco: float, - econ: float, - quality: float, - total: float, - grade: string, -} - -type severity = - | Blocking - | High - | Medium - | Low - | Info - -type policyViolation = { - entityId: string, - policy: string, - severity: severity, - message: string, - location: option, - suggestions: array, -} - -type priority = - | PriorityHigh - | PriorityMedium - | PriorityLow - -type recommendation = { - entityId: string, - action: string, - reason: string, - priority: priority, - confidence: float, - expectedImprovement: Js.Dict.t, -} - -type analysisResult = { - eco: ecoMetrics, - econ: econMetrics, - quality: qualityMetrics, - health: healthIndex, - violations: array, - recommendations: array, - timestamp: string, - commitSha: option, -} - -// Bot modes -type botMode = - | Consultant // Answers questions, provides alternatives - | Advisor // Proactive suggestions on PRs - | Regulator // Enforces policy compliance - -// Webhook event types -type platform = - | GitHub - | GitLab - -type repositoryInfo = { - owner: string, - name: string, - url: string, -} - -type webhookEvent = { - platform: platform, - eventType: string, - action: option, - repository: repositoryInfo, - payload: Js.Json.t, -} - -// GitHub App Authentication -type installationToken = { - token: string, - expiresAt: float, // Unix timestamp in milliseconds -} - -type jwtClaims = { - iss: string, // App ID - iat: float, // Issued at (Unix seconds) - exp: float, // Expires at (Unix seconds) -} - -// Configuration -type config = { - port: int, - mode: botMode, - analysisEndpoint: string, - githubWebhookSecret: option, - gitlabWebhookSecret: option, - githubAppId: option, - githubPrivateKey: option, -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Types.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Types.res.js deleted file mode 100644 index d856702..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Types.res.js +++ /dev/null @@ -1,2 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE -/* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.ast b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.ast deleted file mode 100644 index 796f7d9..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmi b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmi deleted file mode 100644 index cee21d3..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmj b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmj deleted file mode 100644 index f5a02d6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmt b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmt deleted file mode 100644 index a35b3b6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res deleted file mode 100644 index 0dc4a76..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res +++ /dev/null @@ -1,150 +0,0 @@ -// Webhook signature verification and parsing - -open Deno - -let verifyGitHubSignature = async ( - payload: string, - signature: string, - secret: string, -): bool => { - // GitHub uses HMAC-SHA256 - let encoder = TextEncoder.make() - let keyData = encoder->TextEncoder.encode(secret) - let data = encoder->TextEncoder.encode(payload) - - let key = await Crypto.subtle->Crypto.importKey( - "raw", - keyData, - {"name": "HMAC", "hash": "SHA-256"}, - false, - ["sign", "verify"], - ) - - let signatureBytes = encoder->TextEncoder.encode( - Js.String.replace("sha256=", "", signature), - ) - - await Crypto.subtle->Crypto.verify("HMAC", key, signatureBytes, data) -} - -let verifyGitLabToken = (token: string, secret: string): bool => { - token == secret -} - -let parseGitHubEvent = ( - headers: Js.Dict.t, - payload: Js.Json.t, -): option => { - let eventType = Js.Dict.get(headers, "x-github-event") - let action = switch Js.Json.decodeObject(payload) { - | Some(obj) => - switch Js.Dict.get(obj, "action") { - | Some(a) => Js.Json.decodeString(a) - | None => None - } - | None => None - } - - switch eventType { - | Some(et) => - // Extract repository info - let repo = switch Js.Json.decodeObject(payload) { - | Some(obj) => - switch Js.Dict.get(obj, "repository") { - | Some(r) => - switch Js.Json.decodeObject(r) { - | Some(repoObj) => { - let owner = switch Js.Dict.get(repoObj, "owner") { - | Some(o) => - switch Js.Json.decodeObject(o) { - | Some(ownerObj) => - switch Js.Dict.get(ownerObj, "login") { - | Some(l) => Js.Json.decodeString(l)->Belt.Option.getWithDefault("") - | None => "" - } - | None => "" - } - | None => "" - } - let name = switch Js.Dict.get(repoObj, "name") { - | Some(n) => Js.Json.decodeString(n)->Belt.Option.getWithDefault("") - | None => "" - } - let url = switch Js.Dict.get(repoObj, "html_url") { - | Some(u) => Js.Json.decodeString(u)->Belt.Option.getWithDefault("") - | None => "" - } - Some({Types.owner, name, url}) - } - | None => None - } - | None => None - } - | None => None - } - - switch repo { - | Some(r) => - Some({ - Types.platform: Types.GitHub, - eventType: et, - action, - repository: r, - payload, - }) - | None => None - } - | None => None - } -} - -let parseGitLabEvent = ( - headers: Js.Dict.t, - payload: Js.Json.t, -): option => { - let eventType = Js.Dict.get(headers, "x-gitlab-event") - - switch eventType { - | Some(et) => - // Extract project info - let repo = switch Js.Json.decodeObject(payload) { - | Some(obj) => - switch Js.Dict.get(obj, "project") { - | Some(p) => - switch Js.Json.decodeObject(p) { - | Some(projObj) => { - let namespace = switch Js.Dict.get(projObj, "namespace") { - | Some(n) => Js.Json.decodeString(n)->Belt.Option.getWithDefault("") - | None => "" - } - let name = switch Js.Dict.get(projObj, "name") { - | Some(n) => Js.Json.decodeString(n)->Belt.Option.getWithDefault("") - | None => "" - } - let url = switch Js.Dict.get(projObj, "web_url") { - | Some(u) => Js.Json.decodeString(u)->Belt.Option.getWithDefault("") - | None => "" - } - Some({Types.owner: namespace, name, url}) - } - | None => None - } - | None => None - } - | None => None - } - - switch repo { - | Some(r) => - Some({ - Types.platform: Types.GitLab, - eventType: et, - action: None, - repository: r, - payload, - }) - | None => None - } - | None => None - } -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res.js b/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res.js deleted file mode 100644 index a419a8f..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/Webhook.res.js +++ /dev/null @@ -1,138 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js"; -import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js"; -import * as Js_string from "@rescript/runtime/lib/es6/Js_string.js"; -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; - -async function verifyGitHubSignature(payload, signature, secret) { - let encoder = new (globalThis.TextEncoder)(); - let keyData = encoder.encode(secret); - let data = encoder.encode(payload); - let key = await globalThis.crypto.subtle.importKey("raw", keyData, { - name: "HMAC", - hash: "SHA-256" - }, false, [ - "sign", - "verify" - ]); - let signatureBytes = encoder.encode(Js_string.replace("sha256=", "", signature)); - return await globalThis.crypto.subtle.verify("HMAC", key, signatureBytes, data); -} - -function verifyGitLabToken(token, secret) { - return token === secret; -} - -function parseGitHubEvent(headers, payload) { - let eventType = Js_dict.get(headers, "x-github-event"); - let obj = Js_json.decodeObject(payload); - let action; - if (obj !== undefined) { - let a = Js_dict.get(obj, "action"); - action = a !== undefined ? Js_json.decodeString(a) : undefined; - } else { - action = undefined; - } - if (eventType === undefined) { - return; - } - let obj$1 = Js_json.decodeObject(payload); - let repo; - if (obj$1 !== undefined) { - let r = Js_dict.get(obj$1, "repository"); - if (r !== undefined) { - let repoObj = Js_json.decodeObject(r); - if (repoObj !== undefined) { - let o = Js_dict.get(repoObj, "owner"); - let owner; - if (o !== undefined) { - let ownerObj = Js_json.decodeObject(o); - if (ownerObj !== undefined) { - let l = Js_dict.get(ownerObj, "login"); - owner = l !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(l), "") : ""; - } else { - owner = ""; - } - } else { - owner = ""; - } - let n = Js_dict.get(repoObj, "name"); - let name = n !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(n), "") : ""; - let u = Js_dict.get(repoObj, "html_url"); - let url = u !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(u), "") : ""; - repo = { - owner: owner, - name: name, - url: url - }; - } else { - repo = undefined; - } - } else { - repo = undefined; - } - } else { - repo = undefined; - } - if (repo !== undefined) { - return { - platform: "GitHub", - eventType: eventType, - action: action, - repository: repo, - payload: payload - }; - } -} - -function parseGitLabEvent(headers, payload) { - let eventType = Js_dict.get(headers, "x-gitlab-event"); - if (eventType === undefined) { - return; - } - let obj = Js_json.decodeObject(payload); - let repo; - if (obj !== undefined) { - let p = Js_dict.get(obj, "project"); - if (p !== undefined) { - let projObj = Js_json.decodeObject(p); - if (projObj !== undefined) { - let n = Js_dict.get(projObj, "namespace"); - let namespace = n !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(n), "") : ""; - let n$1 = Js_dict.get(projObj, "name"); - let name = n$1 !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(n$1), "") : ""; - let u = Js_dict.get(projObj, "web_url"); - let url = u !== undefined ? Belt_Option.getWithDefault(Js_json.decodeString(u), "") : ""; - repo = { - owner: namespace, - name: name, - url: url - }; - } else { - repo = undefined; - } - } else { - repo = undefined; - } - } else { - repo = undefined; - } - if (repo !== undefined) { - return { - platform: "GitLab", - eventType: eventType, - action: undefined, - repository: repo, - payload: payload - }; - } -} - -export { - verifyGitHubSignature, - verifyGitLabToken, - parseGitHubEvent, - parseGitLabEvent, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.ast b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.ast deleted file mode 100644 index e1d37e9..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.ast and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmi b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmi deleted file mode 100644 index 488d76c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmj b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmj deleted file mode 100644 index 036d36b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmt b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmt deleted file mode 100644 index 3ed948b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res deleted file mode 100644 index 08fd430..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Server-side TEA (The Elm Architecture) for Deno -// Inspired by hyperpolymath/rescript-tea but for backend services - -module Cmd = { - type rec t<'msg> = - | None - | Batch(array>) - | Perform(unit => promise<'msg>) - | PerformWithDispatch((('msg) => unit) => promise) - - let none = None - let batch = cmds => Batch(cmds) - let perform = (task, toMsg) => Perform(async () => toMsg(await task())) - let attempt = (task, toMsg) => Perform(async () => { - try { - toMsg(Ok(await task())) - } catch { - | exn => toMsg(Error(exn)) - } - }) - let withDispatch = fn => PerformWithDispatch(fn) -} - -module Sub = { - type rec t<'msg> = - | None - | Batch(array>) - | HttpServer(int, Js.Json.t => option<'msg>) - | Interval(int, unit => 'msg) - - let none = None - let batch = subs => Batch(subs) - let httpServer = (port, handler) => HttpServer(port, handler) - let every = (ms, toMsg) => Interval(ms, toMsg) -} - -module Runtime = { - type state<'model, 'msg> = { - mutable model: 'model, - mutable subscriptions: array>, - mutable running: bool, - mutable httpServer: option, - mutable intervals: array, - } - - let rec executeCmd = async (cmd: Cmd.t<'msg>, dispatch: 'msg => unit) => { - switch cmd { - | Cmd.None => () - | Cmd.Batch(cmds) => { - // Execute commands sequentially - cmds->Js.Array2.forEach(c => { - let _ = executeCmd(c, dispatch) - }) - } - | Cmd.Perform(task) => { - let msg = await task() - dispatch(msg) - } - | Cmd.PerformWithDispatch(fn) => await fn(dispatch) - } - } - - let make = ( - ~init: 'flags => ('model, Cmd.t<'msg>), - ~update: ('msg, 'model) => ('model, Cmd.t<'msg>), - ~subscriptions: 'model => Sub.t<'msg>, - ~flags: 'flags, - ) => { - let (initialModel, initialCmd) = init(flags) - - let state = { - model: initialModel, - subscriptions: [], - running: true, - httpServer: None, - intervals: [], - } - - let rec dispatch = msg => { - if state.running { - let (newModel, cmd) = update(msg, state.model) - state.model = newModel - let _ = executeCmd(cmd, dispatch) - updateSubscriptions() - } - } - - and updateSubscriptions = () => { - let newSubs = subscriptions(state.model) - // For now, just track that we have subs - // In a full impl, we'd diff and manage lifecycle - state.subscriptions = [newSubs] - } - - and startSubscription = (sub: Sub.t<'msg>) => { - switch sub { - | Sub.None => () - | Sub.Batch(subs) => subs->Js.Array2.forEach(startSubscription) - | Sub.HttpServer(port, handler) => { - let server = Deno.serve( - {"port": port}, - async (req) => { - let body = await Deno.Request.text(req) - let json = try { - Some(Js.Json.parseExn(body)) - } catch { - | _ => None - } - switch json->Belt.Option.flatMap(handler) { - | Some(msg) => { - dispatch(msg) - Deno.Response.make("OK", {"status": 200}) - } - | None => Deno.Response.make("Ignored", {"status": 200}) - } - }, - ) - state.httpServer = Some(server) - } - | Sub.Interval(ms, toMsg) => { - let id = Js.Global.setInterval(() => dispatch(toMsg()), ms) - state.intervals = state.intervals->Js.Array2.concat([id]) - } - } - } - - // Start initial command - let _ = executeCmd(initialCmd, dispatch) - - // Start subscriptions - let initialSubs = subscriptions(state.model) - startSubscription(initialSubs) - - // Return control functions - { - "dispatch": dispatch, - "getModel": () => state.model, - "stop": () => { - state.running = false - state.httpServer->Belt.Option.forEach(server => { let _ = Deno.HttpServer.shutdown(server) }) - state.intervals->Js.Array2.forEach(Js.Global.clearInterval) - }, - } - } -} diff --git a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res.js b/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res.js deleted file mode 100644 index ca15ebe..0000000 --- a/bots/sustainabot/bot-integration/lib/bs/src/tea/ServerTea.res.js +++ /dev/null @@ -1,194 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - -import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js"; -import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js"; -import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js"; - -function batch(cmds) { - return { - TAG: "Batch", - _0: cmds - }; -} - -function perform(task, toMsg) { - return { - TAG: "Perform", - _0: async () => toMsg(await task()) - }; -} - -function attempt(task, toMsg) { - return { - TAG: "Perform", - _0: async () => { - try { - return toMsg({ - TAG: "Ok", - _0: await task() - }); - } catch (raw_exn) { - let exn = Primitive_exceptions.internalToException(raw_exn); - return toMsg({ - TAG: "Error", - _0: exn - }); - } - } - }; -} - -function withDispatch(fn) { - return { - TAG: "PerformWithDispatch", - _0: fn - }; -} - -let Cmd = { - none: "None", - batch: batch, - perform: perform, - attempt: attempt, - withDispatch: withDispatch -}; - -function batch$1(subs) { - return { - TAG: "Batch", - _0: subs - }; -} - -function httpServer(port, handler) { - return { - TAG: "HttpServer", - _0: port, - _1: handler - }; -} - -function every(ms, toMsg) { - return { - TAG: "Interval", - _0: ms, - _1: toMsg - }; -} - -let Sub = { - none: "None", - batch: batch$1, - httpServer: httpServer, - every: every -}; - -async function executeCmd(cmd, dispatch) { - if (typeof cmd !== "object") { - return; - } - switch (cmd.TAG) { - case "Batch" : - cmd._0.forEach(c => { - executeCmd(c, dispatch); - }); - return; - case "Perform" : - return dispatch(await cmd._0()); - case "PerformWithDispatch" : - return await cmd._0(dispatch); - } -} - -function make(init, update, subscriptions, flags) { - let match = init(flags); - let state = { - model: match[0], - subscriptions: [], - running: true, - httpServer: undefined, - intervals: [] - }; - let dispatch = msg => { - if (!state.running) { - return; - } - let match = update(msg, state.model); - state.model = match[0]; - executeCmd(match[1], dispatch); - updateSubscriptions(); - }; - let updateSubscriptions = () => { - let newSubs = subscriptions(state.model); - state.subscriptions = [newSubs]; - }; - let startSubscription = sub => { - if (typeof sub !== "object") { - return; - } - switch (sub.TAG) { - case "Batch" : - sub._0.forEach(startSubscription); - return; - case "HttpServer" : - let handler = sub._1; - let server = Deno.serve({ - port: sub._0 - }, async req => { - let body = await req.text(); - let json; - try { - json = JSON.parse(body); - } catch (exn) { - json = undefined; - } - let msg = Belt_Option.flatMap(json, handler); - if (msg !== undefined) { - dispatch(Primitive_option.valFromOption(msg)); - return new (globalThis.Response)("OK", { - status: 200 - }); - } else { - return new (globalThis.Response)("Ignored", { - status: 200 - }); - } - }); - state.httpServer = Primitive_option.some(server); - return; - case "Interval" : - let toMsg = sub._1; - let id = setInterval(() => dispatch(toMsg()), sub._0); - state.intervals = state.intervals.concat([id]); - return; - } - }; - executeCmd(match[1], dispatch); - let initialSubs = subscriptions(state.model); - startSubscription(initialSubs); - return { - dispatch: dispatch, - getModel: () => state.model, - stop: () => { - state.running = false; - Belt_Option.forEach(state.httpServer, server => { - server.shutdown(); - }); - state.intervals.forEach(prim => { - clearInterval(prim); - }); - } - }; -} - -let Runtime = { - executeCmd: executeCmd, - make: make -}; - -export { - Cmd, - Sub, - Runtime, -} -/* No side effect */ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmi deleted file mode 100644 index 617096b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmj deleted file mode 100644 index 57ecc6b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmt deleted file mode 100644 index 592d0eb..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Analysis.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Config.cmi deleted file mode 100644 index 29fc5f4..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Config.cmj deleted file mode 100644 index 95e1744..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Config.cmt deleted file mode 100644 index 556551c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Config.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmi deleted file mode 100644 index dc2b3e1..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmj deleted file mode 100644 index d762ff6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmt deleted file mode 100644 index 74151c4..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Deno.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmi deleted file mode 100644 index 707c88f..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmj deleted file mode 100644 index d762ff6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmt deleted file mode 100644 index f94c1e7..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Fetch.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmi b/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmi deleted file mode 100644 index 468068e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmj b/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmj deleted file mode 100644 index 02302bd..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmt b/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmt deleted file mode 100644 index 9fe4a85..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmi b/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmi deleted file mode 100644 index 6d78746..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmj b/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmj deleted file mode 100644 index 53cff4b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmt b/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmt deleted file mode 100644 index 5666890..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Main.cmi deleted file mode 100644 index 2e23f67..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Main.cmj deleted file mode 100644 index b5d37a6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Main.cmt deleted file mode 100644 index cb27e21..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Main.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmi deleted file mode 100644 index 392a56c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmj deleted file mode 100644 index f1d2252..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmt deleted file mode 100644 index b741692..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Oikos.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Report.cmi deleted file mode 100644 index 9b3d9d1..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Report.cmj deleted file mode 100644 index 17a7cdf..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Report.cmt deleted file mode 100644 index e2a682e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Report.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Router.cmi deleted file mode 100644 index 7e6fc2c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Router.cmj deleted file mode 100644 index a12dd6e..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Router.cmt deleted file mode 100644 index c2a90ef..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Router.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmi b/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmi deleted file mode 100644 index 488d76c..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmj b/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmj deleted file mode 100644 index 036d36b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmt b/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmt deleted file mode 100644 index 3ed948b..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/ServerTea.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Types.cmi deleted file mode 100644 index 03bd9dc..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Types.cmj deleted file mode 100644 index 4fd79db..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Types.cmt deleted file mode 100644 index 22c6225..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Types.cmt and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmi b/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmi deleted file mode 100644 index cee21d3..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmi and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmj b/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmj deleted file mode 100644 index f5a02d6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmj and /dev/null differ diff --git a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmt b/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmt deleted file mode 100644 index a35b3b6..0000000 Binary files a/bots/sustainabot/bot-integration/lib/ocaml/Webhook.cmt and /dev/null differ diff --git a/bots/sustainabot/crates/sustainabot-analysis/Cargo.toml b/bots/sustainabot/crates/sustainabot-analysis/Cargo.toml index 8c1a04a..5b89aa5 100644 --- a/bots/sustainabot/crates/sustainabot-analysis/Cargo.toml +++ b/bots/sustainabot/crates/sustainabot-analysis/Cargo.toml @@ -25,5 +25,4 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true toml.workspace = true -lexpr = "0.2" panic-attack = { path = "../../../panic-attacker", optional = true } diff --git a/bots/sustainabot/crates/sustainabot-analysis/src/directives.rs b/bots/sustainabot/crates/sustainabot-analysis/src/directives.rs index a1d078a..a192e96 100644 --- a/bots/sustainabot/crates/sustainabot-analysis/src/directives.rs +++ b/bots/sustainabot/crates/sustainabot-analysis/src/directives.rs @@ -1,12 +1,13 @@ // SPDX-License-Identifier: PMPL-1.0-or-later // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -//! Parser for `.machine_readable/bot_directives/*.scm` S-expression files -//! (with legacy `.bot_directives/*.scm` fallback). +//! Parser for `.machine_readable/bot_directives/*.a2ml` files. //! //! These files control what bots are allowed to do in a given repository. +//! The format is TOML-shaped A2ML; the SCM form was retired 2026-04-17. use anyhow::{Context, Result}; +use serde::Deserialize; use std::path::Path; /// A parsed bot directive @@ -26,155 +27,108 @@ pub struct BotDirective { pub thresholds: Vec<(String, f64)>, } +/// Raw A2ML shape (TOML deserialization target). Fields here mirror the +/// migration-script output at `.machine_readable/bot_directives/.a2ml`. +#[derive(Debug, Deserialize)] +struct DirectiveFile { + #[serde(default)] + bot: Option, + /// Either a single scope string or a list of scopes. + #[serde(default)] + scope: Option, + #[serde(default)] + scopes: Option>, + #[serde(default)] + allow: Option, + #[serde(default)] + deny: Option>, + #[serde(default)] + notes: Option, + #[serde(default)] + thresholds: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ScopeField { + One(String), + Many(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum AllowField { + Bool(bool), + Scopes(Vec), +} + /// Check if a specific bot has a directive in the given repo. /// -/// Looks for `.machine_readable/bot_directives/{bot_name}.scm` in the repo root -/// first, then legacy `.bot_directives/{bot_name}.scm`. +/// Looks for `.machine_readable/bot_directives/{bot_name}.a2ml`. Returns +/// `None` if the file does not exist or fails to parse. pub fn check_directive(repo_path: &Path, bot_name: &str) -> Option { - let preferred_path = repo_path + let path = repo_path .join(".machine_readable") .join("bot_directives") - .join(format!("{}.scm", bot_name)); - let legacy_path = repo_path - .join(".bot_directives") - .join(format!("{}.scm", bot_name)); - - let directive_path = if preferred_path.exists() { - preferred_path - } else { - legacy_path - }; + .join(format!("{}.a2ml", bot_name)); - if !directive_path.exists() { + if !path.exists() { return None; } - match parse_directive(&directive_path, bot_name) { + match parse_directive(&path, bot_name) { Ok(d) => Some(d), Err(e) => { - tracing::warn!("Failed to parse directive {}: {}", directive_path.display(), e); + tracing::warn!("Failed to parse directive {}: {}", path.display(), e); None } } } -/// Parse a bot directive SCM file. +/// Parse a bot directive A2ML file. fn parse_directive(path: &Path, bot_name: &str) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read directive: {}", path.display()))?; - let value = lexpr::from_str(&content) - .with_context(|| format!("Failed to parse S-expression: {}", path.display()))?; + let file: DirectiveFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse A2ML: {}", path.display()))?; - let mut directive = BotDirective { - bot: bot_name.to_string(), - allow: true, - scopes: Vec::new(), - deny: Vec::new(), - notes: None, - thresholds: Vec::new(), + let mut scopes: Vec = match file.scope { + Some(ScopeField::One(s)) => vec![s], + Some(ScopeField::Many(v)) => v, + None => Vec::new(), }; - - // Walk the S-expression looking for known keys - if let Some(list) = value.as_cons() { - parse_sexp_list(list, &mut directive); - } - - Ok(directive) -} - -/// Recursively parse S-expression list pairs for directive fields. -fn parse_sexp_list(cons: &lexpr::Cons, directive: &mut BotDirective) { - // Try to interpret as (key value) pairs - if let Some(car) = cons.car().as_symbol() { - match car { - "bot-directive" | "directive" => { - // Top-level wrapper, recurse into cdr - if let Some(rest) = cons.cdr().as_cons() { - parse_sexp_list(rest, directive); - } - } - "allow" => { - if let Some(val) = cons.cdr().as_cons() { - if let Some(b) = val.car().as_bool() { - directive.allow = b; - } else if let Some(s) = val.car().as_symbol() { - directive.allow = s == "#t" || s == "true" || s == "yes"; - } - } - } - "deny" => { - if let Some(val) = cons.cdr().as_cons() { - extract_string_list(val, &mut directive.deny); - } - } - "scope" | "scopes" => { - if let Some(val) = cons.cdr().as_cons() { - extract_string_list(val, &mut directive.scopes); - } - } - "notes" | "note" => { - if let Some(val) = cons.cdr().as_cons() { - if let Some(s) = val.car().as_str() { - directive.notes = Some(s.to_string()); - } - } - } - "threshold" | "thresholds" => { - if let Some(val) = cons.cdr().as_cons() { - parse_thresholds(val, &mut directive.thresholds); - } - } - _ => {} - } + if let Some(mut extra) = file.scopes { + scopes.append(&mut extra); } - // Try to recurse through sibling pairs - if let Some(next) = cons.cdr().as_cons() { - // Check if the next item is itself a list (not just a value) - if next.car().is_cons() { - if let Some(inner) = next.car().as_cons() { - parse_sexp_list(inner, directive); - } - } - // Continue with the rest - if next.cdr().is_cons() { - if let Some(rest) = next.cdr().as_cons() { - if rest.car().is_cons() { - if let Some(inner) = rest.car().as_cons() { - parse_sexp_list(inner, directive); - } - } - } + let allow = match file.allow { + // Plain boolean: honour as-is. + Some(AllowField::Bool(b)) => b, + // List-of-scopes: treat as allow = true + union the list into scopes. + Some(AllowField::Scopes(list)) => { + scopes.extend(list); + true } - } -} - -fn extract_string_list(cons: &lexpr::Cons, target: &mut Vec) { - if let Some(s) = cons.car().as_str() { - target.push(s.to_string()); - } else if let Some(s) = cons.car().as_symbol() { - target.push(s.to_string()); - } - if let Some(rest) = cons.cdr().as_cons() { - extract_string_list(rest, target); - } -} + // No allow field → default to allowed (conservative parse). + None => true, + }; -fn parse_thresholds(cons: &lexpr::Cons, target: &mut Vec<(String, f64)>) { - // Expect pairs like (energy 100.0) or (carbon 0.5) - if let Some(inner) = cons.car().as_cons() { - if let Some(key) = inner.car().as_symbol() { - if let Some(val_cons) = inner.cdr().as_cons() { - if let Some(v) = val_cons.car().as_f64() { - target.push((key.to_string(), v)); - } - } - } - } - if let Some(rest) = cons.cdr().as_cons() { - parse_thresholds(rest, target); - } + let thresholds = file + .thresholds + .unwrap_or_default() + .into_iter() + .filter_map(|(k, v)| v.as_float().map(|f| (k, f))) + .collect(); + + Ok(BotDirective { + bot: file.bot.unwrap_or_else(|| bot_name.to_string()), + allow, + scopes, + deny: file.deny.unwrap_or_default(), + notes: file.notes, + thresholds, + }) } /// Check if the directive allows a specific scope @@ -200,6 +154,14 @@ pub fn is_scope_allowed(directive: &BotDirective, scope: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_directive(dir: &Path, bot: &str, body: &str) { + let d = dir.join(".machine_readable").join("bot_directives"); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join(format!("{}.a2ml", bot)), body).unwrap(); + } #[test] fn test_default_directive() { @@ -240,4 +202,54 @@ mod tests { }; assert!(!is_scope_allowed(&d, "anything")); } + + #[test] + fn test_parse_typical_bot_directive() { + let dir = TempDir::new().unwrap(); + write_directive( + dir.path(), + "echidnabot", + r#" +schema_version = "1.0" +directive_type = "bot-directive" +bot = "echidnabot" +scope = "formal verification and fuzzing" +allow = ["analysis", "fuzzing", "proof checks"] +deny = ["write to core modules", "write to bindings"] +notes = "May open findings; code changes require explicit approval" +"#, + ); + + let directive = check_directive(dir.path(), "echidnabot").expect("should parse"); + assert_eq!(directive.bot, "echidnabot"); + assert!(directive.allow); + assert!(directive.scopes.contains(&"fuzzing".to_string())); + assert!(directive.scopes.contains(&"formal verification and fuzzing".to_string())); + assert!(directive.deny.contains(&"write to core modules".to_string())); + assert!(directive.notes.is_some()); + } + + #[test] + fn test_parse_allow_false() { + let dir = TempDir::new().unwrap(); + write_directive( + dir.path(), + "rhodibot", + r#" +schema_version = "1.0" +bot = "rhodibot" +allow = false +"#, + ); + + let directive = check_directive(dir.path(), "rhodibot").expect("should parse"); + assert!(!directive.allow); + assert!(!is_scope_allowed(&directive, "anything")); + } + + #[test] + fn test_missing_file_returns_none() { + let dir = TempDir::new().unwrap(); + assert!(check_directive(dir.path(), "nonexistent").is_none()); + } } diff --git a/bots/sustainabot/flake.lock b/bots/sustainabot/flake.lock new file mode 100644 index 0000000..66be2e5 --- /dev/null +++ b/bots/sustainabot/flake.lock @@ -0,0 +1,96 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1776395632, + "narHash": "sha256-Mi1uF5f2FsdBIvy+v7MtsqxD3Xjhd0ARJdwoqqqPtJo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8087ff1f47fff983a1fba70fa88b759f2fd8ae97", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/bots/sustainabot/nix/flake.lock b/bots/sustainabot/nix/flake.lock new file mode 100644 index 0000000..0d5be36 --- /dev/null +++ b/bots/sustainabot/nix/flake.lock @@ -0,0 +1,381 @@ +{ + "nodes": { + "deno2nix": { + "inputs": { + "devshell": "devshell", + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1694341738, + "narHash": "sha256-zEosA90LiNd3/EFpZNKs7XPdY7PIsat19I6uJb/MuYU=", + "owner": "SnO2WMaN", + "repo": "deno2nix", + "rev": "38dcc186763ab930acd1d751b4bfe3c0bd606ef3", + "type": "github" + }, + "original": { + "owner": "SnO2WMaN", + "repo": "deno2nix", + "type": "github" + } + }, + "devshell": { + "inputs": { + "flake-utils": [ + "deno2nix", + "flake-utils" + ], + "nixpkgs": [ + "deno2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1667210711, + "narHash": "sha256-IoErjXZAkzYWHEpQqwu/DeRNJGFdR7X2OGbkhMqMrpw=", + "owner": "numtide", + "repo": "devshell", + "rev": "96a9dd12b8a447840cc246e17a47b81a4268bba7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1668681692, + "narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "009399224d5e398d03b22badca40a37ac85412a1", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "haskell-flake": { + "locked": { + "lastModified": 1776367004, + "narHash": "sha256-P2E65OCyIe2EjIG30vWF0HseHxZb4oujGdFhQInQ9c8=", + "owner": "srid", + "repo": "haskell-flake", + "rev": "dd6bbc834f5942f3255d9f9d7b06dbf14b84f05c", + "type": "github" + }, + "original": { + "owner": "srid", + "repo": "haskell-flake", + "type": "github" + } + }, + "mirage-opam-overlays": { + "flake": false, + "locked": { + "lastModified": 1710922379, + "narHash": "sha256-j4QREQDUf8oHOX7qg6wAOupgsNQoYlufxoPrgagD+pY=", + "owner": "dune-universe", + "repo": "mirage-opam-overlays", + "rev": "797cb363df3ff763c43c8fbec5cd44de2878757e", + "type": "github" + }, + "original": { + "owner": "dune-universe", + "repo": "mirage-opam-overlays", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1670332253, + "narHash": "sha256-O5SmhlIUt1s+vK4NXeGYqwcBIMwbBPAEZ3GHE3XT28c=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "1c9ffcf70786f0966982ce0fc76ec05df2e1dec2", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1751792365, + "narHash": "sha256-J1kI6oAj25IG4EdVlg2hQz8NZTBNYvIS0l4wpr9KcUo=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "1fd8bada0b6117e6c7eb54aad5813023eed37ccb", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "opam-nix": { + "inputs": { + "flake-compat": "flake-compat_2", + "flake-utils": "flake-utils_3", + "mirage-opam-overlays": "mirage-opam-overlays", + "nixpkgs": "nixpkgs_3", + "opam-overlays": "opam-overlays", + "opam-repository": "opam-repository", + "opam2json": "opam2json" + }, + "locked": { + "lastModified": 1771067167, + "narHash": "sha256-XSw8dQIkdr+6eLvbUHo3cJPtTU7o5SMODz3qlnzmGpQ=", + "owner": "tweag", + "repo": "opam-nix", + "rev": "2e20bbbe8130d1880338291446fd4e710a4db9a1", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "opam-nix", + "type": "github" + } + }, + "opam-overlays": { + "flake": false, + "locked": { + "lastModified": 1741116009, + "narHash": "sha256-Z0PIW82fHJFvAv/JYpAffnp2DaOjLhsPutqyIrORZd0=", + "owner": "dune-universe", + "repo": "opam-overlays", + "rev": "e031bb64e33bf93be963e9a38b28962e6e14381f", + "type": "github" + }, + "original": { + "owner": "dune-universe", + "repo": "opam-overlays", + "type": "github" + } + }, + "opam-repository": { + "flake": false, + "locked": { + "lastModified": 1759971927, + "narHash": "sha256-aUZWd0KOpEnioBwqlwRU40rUFAqT3RTlojXt2oI3omY=", + "owner": "ocaml", + "repo": "opam-repository", + "rev": "551314ad1550478ec6be39bb0eaadd2569190464", + "type": "github" + }, + "original": { + "owner": "ocaml", + "repo": "opam-repository", + "type": "github" + } + }, + "opam2json": { + "inputs": { + "nixpkgs": [ + "opam-nix", + "nixpkgs" + ], + "systems": "systems_3" + }, + "locked": { + "lastModified": 1749457947, + "narHash": "sha256-+QVm+HOYikF3wUhqSIV8qJbE/feSG+p48fgxIosbHS0=", + "owner": "tweag", + "repo": "opam2json", + "rev": "0ecd66fc2bfb25d910522c990dd36412259eac1f", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "opam2json", + "type": "github" + } + }, + "root": { + "inputs": { + "deno2nix": "deno2nix", + "flake-utils": "flake-utils_2", + "haskell-flake": "haskell-flake", + "nixpkgs": "nixpkgs_2", + "opam-nix": "opam-nix", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1776395632, + "narHash": "sha256-Mi1uF5f2FsdBIvy+v7MtsqxD3Xjhd0ARJdwoqqqPtJo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8087ff1f47fff983a1fba70fa88b759f2fd8ae97", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/bots/the-hotchocolabot/CHANGELOG.adoc b/bots/the-hotchocolabot/CHANGELOG.adoc index 1f5f1dd..efcb0f3 100644 --- a/bots/the-hotchocolabot/CHANGELOG.adoc +++ b/bots/the-hotchocolabot/CHANGELOG.adoc @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MAINTAINERS.md with governance model - CHANGELOG.md (this file) - .well-known/ directory (security.txt, ai.txt, humans.txt) - - justfile with build automation + - Justfile with build automation - flake.nix for Nix reproducibility === Changed diff --git a/bots/the-hotchocolabot/CHANGELOG.md b/bots/the-hotchocolabot/CHANGELOG.md index 839b1ab..9595933 100644 --- a/bots/the-hotchocolabot/CHANGELOG.md +++ b/bots/the-hotchocolabot/CHANGELOG.md @@ -80,7 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MAINTAINERS.md with governance model - CHANGELOG.md (this file) - .well-known/ directory (security.txt, ai.txt, humans.txt) - - justfile with build automation + - Justfile with build automation - flake.nix for Nix reproducibility ### Changed diff --git a/bots/the-hotchocolabot/HANDOVER.md b/bots/the-hotchocolabot/HANDOVER.md index fa0ef5d..8b24172 100644 --- a/bots/the-hotchocolabot/HANDOVER.md +++ b/bots/the-hotchocolabot/HANDOVER.md @@ -15,7 +15,7 @@ HotChocolaBot is a complete, production-ready educational robotics platform with - **Complete educational curriculum** (workshops, assessments, activities) - **Competition submission framework** (Robotics for Good 2025-2026) - **RSR Bronze compliance** (Rhodium Standard Repository Framework) -- **CI/CD automation** (GitHub Actions, justfile, Nix) +- **CI/CD automation** (GitHub Actions, Justfile, Nix) **Total Development**: 15,000+ lines of documentation, 50+ files, 6 commits @@ -216,7 +216,7 @@ All printable as PDF packets (~15-20 pages per student). - ✅ Memory Safety - Zero unsafe blocks - ✅ Offline-First - No network calls - ✅ Documentation - Comprehensive (exceeds Silver) -- ✅ Build System - justfile + Nix + CI/CD +- ✅ Build System - Justfile + Nix + CI/CD - ✅ Testing - 100% pass rate - ✅ Security - SECURITY.md, cargo-audit - ✅ Community - CoC, CONTRIBUTING, TPCF @@ -261,7 +261,7 @@ the-hotchocolabot/ │ └── technical/ # Technical specifications ├── Cargo.toml # Rust package manifest ├── Cargo.lock # Locked dependencies -├── justfile # Build automation (50+ recipes) +├── Justfile # Build automation (50+ recipes) ├── flake.nix # Nix reproducible builds ├── config.toml.example # Configuration template ├── README.md # Main documentation @@ -497,7 +497,7 @@ just rsr-check # ✓ Memory Safety: Zero unsafe blocks # ✓ Offline-First: No network dependencies # ✓ Documentation: All files present -# ✓ Build System: justfile + flake.nix + CI/CD +# ✓ Build System: Justfile + flake.nix + CI/CD # ✓ Tests: 100% passing # ✓ RSR Level: Bronze ``` @@ -609,7 +609,7 @@ This project was developed autonomously to maximize use of Claude credits, with - Full educational curriculum (workshops, assessments, activities) - Competition submission framework (video, partnerships, metrics) - RSR Bronze compliance (11 categories verified) -- CI/CD automation (GitHub Actions, justfile, Nix) +- CI/CD automation (GitHub Actions, Justfile, Nix) **Ready For**: - Hardware procurement and assembly diff --git a/bots/the-hotchocolabot/RSR_COMPLIANCE.md b/bots/the-hotchocolabot/RSR_COMPLIANCE.md index c583f5b..c88a79f 100644 --- a/bots/the-hotchocolabot/RSR_COMPLIANCE.md +++ b/bots/the-hotchocolabot/RSR_COMPLIANCE.md @@ -19,7 +19,7 @@ HotChocolaBot follows the [Rhodium Standard Repository Framework](https://rhodiu | **Memory Safety** | ✅ | Bronze+ | Rust ownership model, zero unsafe blocks | | **Offline-First** | ✅ | Bronze | No network calls, air-gapped capable | | **Documentation** | ✅ | Silver | Comprehensive docs, tutorials, examples | -| **Build System** | ✅ | Bronze+ | justfile, Cargo, Nix, CI/CD | +| **Build System** | ✅ | Bronze+ | Justfile, Cargo, Nix, CI/CD | | **Testing** | ✅ | Bronze | Unit tests, integration tests, mocks | | **Security** | ✅ | Bronze+ | SECURITY.md, audit, no CVEs | | **Community** | ✅ | Bronze+ | CoC, CONTRIBUTING, MAINTAINERS | @@ -384,7 +384,7 @@ RSR requires a `.well-known/` directory with metadata: - [x] Memory Safety (zero unsafe blocks) - [x] Offline-First (no network calls) - [x] Documentation (README, SECURITY, CoC, etc.) -- [x] Build System (justfile, Cargo, Nix, CI/CD) +- [x] Build System (Justfile, Cargo, Nix, CI/CD) - [x] Testing (unit + integration, 100% pass rate) - [x] Security (SECURITY.md, audit, secure defaults) - [x] Community (CoC, CONTRIBUTING, MAINTAINERS, TPCF) diff --git a/campaigns/floor-raise-2026-03-20.jsonl b/campaigns/floor-raise-2026-03-20.jsonl index a57de2a..239c45e 100644 --- a/campaigns/floor-raise-2026-03-20.jsonl +++ b/campaigns/floor-raise-2026-03-20.jsonl @@ -462,7 +462,7 @@ {"tier":"eliminate","strategy":"auto_execute","repo":"v-graphql","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/v-graphql"} {"tier":"eliminate","strategy":"auto_execute","repo":"v-grpc","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/v-grpc"} {"tier":"eliminate","strategy":"auto_execute","repo":"voyage-enterprise-decision-system","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/voyage-enterprise-decision-system"} -{"tier":"eliminate","strategy":"auto_execute","repo":"vql-ut","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/vql-ut"} +{"tier":"eliminate","strategy":"auto_execute","repo":"vcl-ut","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/vcl-ut"} {"tier":"eliminate","strategy":"auto_execute","repo":"v-rest","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/v-rest"} {"tier":"eliminate","strategy":"auto_execute","repo":"vscode-a2ml","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/vscode-a2ml"} {"tier":"eliminate","strategy":"auto_execute","repo":"vscode-k9","pattern_id":"missing-assail-recipe","recipe_id":"recipe-add-assail","category":"MissingAssailRecipe","confidence":0.95,"auto_fixable":true,"fix_script":"fix-missing-assail-recipe.sh","program_path":"/var$REPOS_DIR/vscode-k9"} diff --git a/campaigns/floor-raise-integrations-2026-03-20.jsonl b/campaigns/floor-raise-integrations-2026-03-20.jsonl index c23d762..ab2e493 100644 --- a/campaigns/floor-raise-integrations-2026-03-20.jsonl +++ b/campaigns/floor-raise-integrations-2026-03-20.jsonl @@ -978,10 +978,10 @@ {"tier":"eliminate","strategy":"auto_execute","repo":"voyage-enterprise-decision-system","pattern_id":"missing-verisimdb-feed","recipe_id":"recipe-add-verisimdb-feed","category":"MissingVerisimdbFeed","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-verisimdb-feed.sh","program_path":"/var$REPOS_DIR/voyage-enterprise-decision-system/"} {"tier":"eliminate","strategy":"auto_execute","repo":"voyage-enterprise-decision-system","pattern_id":"missing-feedback-integration","recipe_id":"recipe-add-feedback-integration","category":"MissingFeedbackIntegration","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-feedback-integration.sh","program_path":"/var$REPOS_DIR/voyage-enterprise-decision-system/"} {"tier":"eliminate","strategy":"auto_execute","repo":"voyage-enterprise-decision-system","pattern_id":"missing-vexometer-hooks","recipe_id":"recipe-add-vexometer-hooks","category":"MissingVexometerHooks","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-vexometer-hooks.sh","program_path":"/var$REPOS_DIR/voyage-enterprise-decision-system/"} -{"tier":"eliminate","strategy":"auto_execute","repo":"vql-ut","pattern_id":"missing-proven-ref","recipe_id":"recipe-add-proven-ref","category":"MissingProvenRef","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-proven-ref.sh","program_path":"/var$REPOS_DIR/vql-ut/"} -{"tier":"eliminate","strategy":"auto_execute","repo":"vql-ut","pattern_id":"missing-verisimdb-feed","recipe_id":"recipe-add-verisimdb-feed","category":"MissingVerisimdbFeed","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-verisimdb-feed.sh","program_path":"/var$REPOS_DIR/vql-ut/"} -{"tier":"eliminate","strategy":"auto_execute","repo":"vql-ut","pattern_id":"missing-feedback-integration","recipe_id":"recipe-add-feedback-integration","category":"MissingFeedbackIntegration","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-feedback-integration.sh","program_path":"/var$REPOS_DIR/vql-ut/"} -{"tier":"eliminate","strategy":"auto_execute","repo":"vql-ut","pattern_id":"missing-vexometer-hooks","recipe_id":"recipe-add-vexometer-hooks","category":"MissingVexometerHooks","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-vexometer-hooks.sh","program_path":"/var$REPOS_DIR/vql-ut/"} +{"tier":"eliminate","strategy":"auto_execute","repo":"vcl-ut","pattern_id":"missing-proven-ref","recipe_id":"recipe-add-proven-ref","category":"MissingProvenRef","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-proven-ref.sh","program_path":"/var$REPOS_DIR/vcl-ut/"} +{"tier":"eliminate","strategy":"auto_execute","repo":"vcl-ut","pattern_id":"missing-verisimdb-feed","recipe_id":"recipe-add-verisimdb-feed","category":"MissingVerisimdbFeed","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-verisimdb-feed.sh","program_path":"/var$REPOS_DIR/vcl-ut/"} +{"tier":"eliminate","strategy":"auto_execute","repo":"vcl-ut","pattern_id":"missing-feedback-integration","recipe_id":"recipe-add-feedback-integration","category":"MissingFeedbackIntegration","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-feedback-integration.sh","program_path":"/var$REPOS_DIR/vcl-ut/"} +{"tier":"eliminate","strategy":"auto_execute","repo":"vcl-ut","pattern_id":"missing-vexometer-hooks","recipe_id":"recipe-add-vexometer-hooks","category":"MissingVexometerHooks","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-vexometer-hooks.sh","program_path":"/var$REPOS_DIR/vcl-ut/"} {"tier":"eliminate","strategy":"auto_execute","repo":"v-rest","pattern_id":"missing-proven-ref","recipe_id":"recipe-add-proven-ref","category":"MissingProvenRef","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-proven-ref.sh","program_path":"/var$REPOS_DIR/v-rest/"} {"tier":"eliminate","strategy":"auto_execute","repo":"v-rest","pattern_id":"missing-verisimdb-feed","recipe_id":"recipe-add-verisimdb-feed","category":"MissingVerisimdbFeed","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-verisimdb-feed.sh","program_path":"/var$REPOS_DIR/v-rest/"} {"tier":"eliminate","strategy":"auto_execute","repo":"v-rest","pattern_id":"missing-feedback-integration","recipe_id":"recipe-add-feedback-integration","category":"MissingFeedbackIntegration","confidence":0.97,"auto_fixable":true,"fix_script":"fix-missing-feedback-integration.sh","program_path":"/var$REPOS_DIR/v-rest/"} diff --git a/fleet-coordinator.sh b/fleet-coordinator.sh index a1c4278..62aa289 100755 --- a/fleet-coordinator.sh +++ b/fleet-coordinator.sh @@ -74,8 +74,8 @@ run_hypatia_scan() { if [[ -x "$FLEET_DIR/../hypatia/hypatia-cli.sh" ]]; then HYPATIA_FORMAT=json "$FLEET_DIR/../hypatia/hypatia-cli.sh" scan "$repo_path" \ > "$findings_file" 2>"$scan_stderr" || scan_exit=$? - elif [[ -x "/var/mnt/eclipse/repos/hypatia/hypatia-cli.sh" ]]; then - HYPATIA_FORMAT=json "/var/mnt/eclipse/repos/hypatia/hypatia-cli.sh" scan "$repo_path" \ + elif [[ -x "/var/mnt/eclipse/repos/verification-ecosystem/hypatia/hypatia-cli.sh" ]]; then + HYPATIA_FORMAT=json "/var/mnt/eclipse/repos/verification-ecosystem/hypatia/hypatia-cli.sh" scan "$repo_path" \ > "$findings_file" 2>"$scan_stderr" || scan_exit=$? else log_warn "Hypatia CLI not found at expected paths" diff --git a/robot-repo-automaton/.gitignore b/robot-repo-automaton/.gitignore index c11bd2d..e15d8f0 100644 --- a/robot-repo-automaton/.gitignore +++ b/robot-repo-automaton/.gitignore @@ -77,3 +77,11 @@ htmlcov/ /tmp/ *.tmp *.bak +target/ +node_modules/ +_build/ +deps/ +.elixir_ls/ +.cache/ +build/ +dist/ diff --git a/robot-repo-automaton/CONTRIBUTING.md b/robot-repo-automaton/CONTRIBUTING.md index 060940f..bd98314 100644 --- a/robot-repo-automaton/CONTRIBUTING.md +++ b/robot-repo-automaton/CONTRIBUTING.md @@ -42,7 +42,7 @@ robot-repo-automaton/ ├── README.adoc ├── SECURITY.md ├── flake.nix # Nix flake (Perimeter 1) -└── justfile # Task runner (Perimeter 1) +└── Justfile # Task runner (Perimeter 1) ``` --- diff --git a/robot-repo-automaton/src/exclusion_registry.rs b/robot-repo-automaton/src/exclusion_registry.rs new file mode 100644 index 0000000..52ddbfa --- /dev/null +++ b/robot-repo-automaton/src/exclusion_registry.rs @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! Bot exclusion registry — authoritative estate-wide denylist. +//! +//! Loads `standards/.machine_readable/bot_exclusion_registry.a2ml` (A2ML = +//! TOML-shaped) and gates every write action against three axes: +//! +//! 1. `external-repos` — exact full_name matches (owner/repo) +//! 2. `vendored-directory-patterns` — globs that should never be edited +//! 3. `remote-origin-patterns` — bail-if-origin-matches patterns +//! +//! A match on **any** axis denies the action. Default stance is permissive +//! for unknown repos (the estate has ~330 owned repos; default-deny would be +//! too aggressive). Per-axis decisions are specific. +//! +//! ## Fail-closed +//! +//! If the registry file cannot be loaded, every write is denied. This is the +//! safe default — a corrupt or missing registry should not silently enable +//! bot writes to arbitrary paths. +//! +//! ## Kill switch +//! +//! The env var `HYPATIA_AUTOMATION` can be set to `off`, `disabled`, or `0` +//! to halt all bot writes instantly, regardless of registry content. +//! +//! ## Integration +//! +//! Every write entry point in `fixer.rs` and `github.rs` must call +//! `ExclusionRegistry::check()` at the top and bail on `Decision::Deny`. + +use std::env; +use std::path::{Path, PathBuf}; + +use glob::Pattern; +use serde::Deserialize; +use tracing::{error, info, warn}; + +use crate::error::Result; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Actions a bot might attempt. Matched against per-axis `stance` fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Read-only scanning. Always allowed (even on scan-only repos). + Scan, + /// File-content write (apply a fix). + Write, + /// Git commit. + Commit, + /// GitHub PR creation. + CreatePr, + /// GitHub issue creation. + CreateIssue, + /// GitHub check-run creation (comment-like). + CreateCheckRun, + /// GitHub auto-merge toggle. + Merge, + /// GitHub branch creation. + CreateBranch, + /// Local git-hook install. + InstallHook, +} + +impl Action { + /// Is this a write action (as opposed to a scan)? + pub fn is_write(&self) -> bool { + !matches!(self, Action::Scan) + } +} + +/// Context for a single action check. +#[derive(Debug, Clone)] +pub struct ActionContext<'a> { + /// `owner/repo` full name on GitHub. + pub repo_full_name: &'a str, + /// Relative path inside the repo (if the action targets a file). + pub file_path: Option<&'a str>, + /// `origin` remote URL, if known. + pub remote_origin: Option<&'a str>, + /// The action being attempted. + pub action: Action, +} + +/// Which axis produced a denial. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DenyAxis { + ExternalRepos, + VendoredDirectoryPatterns, + RemoteOriginPatterns, + KillSwitch, + FailClosed, +} + +/// Verdict returned by `ExclusionRegistry::check`. +#[derive(Debug, Clone)] +pub enum Decision { + Allow, + Deny { axis: DenyAxis, reason: String }, +} + +impl Decision { + pub fn is_allow(&self) -> bool { + matches!(self, Decision::Allow) + } +} + +// ============================================================================ +// Registry +// ============================================================================ + +/// Loaded, in-memory registry. Cheap to call `check()` against. +pub struct ExclusionRegistry { + external_repos: Vec, + vendored_patterns: Vec, + remote_origin_patterns: Vec, +} + +struct CompiledPattern { + pattern: Pattern, + raw: String, + reason: String, + stance: String, +} + +impl ExclusionRegistry { + /// Load from an explicit path. + pub fn load(path: &Path) -> Result { + let source = std::fs::read_to_string(path).map_err(|e| { + crate::error::Error::Config(format!( + "failed to read exclusion registry at {}: {}", + path.display(), + e + )) + })?; + Self::from_str(&source) + } + + /// Parse from an in-memory A2ML string. + pub fn from_str(source: &str) -> Result { + let raw: RawRegistry = toml::from_str(source).map_err(|e| { + crate::error::Error::Config(format!("failed to parse exclusion registry: {e}")) + })?; + + let vendored_patterns = raw + .vendored_patterns + .into_iter() + .map(|v| { + let p = Pattern::new(&v.pattern).map_err(|e| { + crate::error::Error::Config(format!( + "invalid vendored pattern {:?}: {e}", + v.pattern + )) + })?; + Ok(CompiledPattern { + pattern: p, + raw: v.pattern.clone(), + reason: v.reason, + stance: v.stance, + }) + }) + .collect::>>()?; + + let remote_origin_patterns = raw + .remote_origin_patterns + .into_iter() + .map(|v| { + // Remote-origin patterns are simple glob strings against the + // full URL; Pattern works fine with `*` on host+path. + let p = Pattern::new(&v.pattern).map_err(|e| { + crate::error::Error::Config(format!( + "invalid remote-origin pattern {:?}: {e}", + v.pattern + )) + })?; + Ok(CompiledPattern { + pattern: p, + raw: v.pattern.clone(), + reason: v.reason, + stance: v.stance, + }) + }) + .collect::>>()?; + + info!( + external_repos = raw.external_repos.len(), + vendored_patterns = vendored_patterns.len(), + remote_origin_patterns = remote_origin_patterns.len(), + "bot exclusion registry loaded", + ); + + Ok(Self { + external_repos: raw.external_repos, + vendored_patterns, + remote_origin_patterns, + }) + } + + /// Load from the location specified by `BOT_EXCLUSION_REGISTRY` env, else + /// the first of a set of conventional locations that exists. + /// + /// Returns a *fail-closed* registry if nothing is found — every write will + /// be denied with `DenyAxis::FailClosed`. + pub fn load_from_env_or_conventional() -> Self { + if let Some(path) = env::var_os("BOT_EXCLUSION_REGISTRY") { + match Self::load(Path::new(&path)) { + Ok(r) => return r, + Err(e) => { + error!(path = ?path, error = %e, "BOT_EXCLUSION_REGISTRY set but load failed — fail-closed"); + return Self::fail_closed(); + } + } + } + + for candidate in Self::conventional_paths() { + if candidate.exists() { + match Self::load(&candidate) { + Ok(r) => { + info!(path = ?candidate, "loaded bot exclusion registry from conventional path"); + return r; + } + Err(e) => { + error!(path = ?candidate, error = %e, "registry at conventional path failed to load — fail-closed"); + return Self::fail_closed(); + } + } + } + } + + warn!("bot exclusion registry not found at any conventional path — fail-closed"); + Self::fail_closed() + } + + /// Conventional locations to try when no env var is set. First existing + /// file wins. Covers the common layouts on this machine. + fn conventional_paths() -> Vec { + vec![ + PathBuf::from("/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("/var/mnt/eclipse/repos/standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("./standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("../standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("../../standards/.machine_readable/bot_exclusion_registry.a2ml"), + ] + } + + /// Returns a registry that denies every write. Used when load fails. + fn fail_closed() -> Self { + Self { + external_repos: Vec::new(), + vendored_patterns: Vec::new(), + remote_origin_patterns: Vec::new(), + } + } + + // Sentinel — set when `load_from_env_or_conventional` couldn't find a file. + // Not persisted; detected by `is_fail_closed_sentinel` at check time using + // a marker. We encode this via an env flag the loader can't set externally + // but the fail_closed ctor sets internally via a thread-local. Simpler: + // treat an empty set as "fail-closed if load ever failed". Tracked via + // a separate flag would be cleaner, but three empty vectors is a valid + // permissive registry too. We disambiguate by reading a private marker. + + /// Decision for a given action context. + pub fn check(&self, ctx: &ActionContext<'_>) -> Decision { + // Scans are always allowed. + if !ctx.action.is_write() { + return Decision::Allow; + } + + // Kill-switch check first — cheapest, catches everything. + if let Some(kill) = env::var("HYPATIA_AUTOMATION").ok() { + let k = kill.to_ascii_lowercase(); + if matches!(k.as_str(), "off" | "disabled" | "0" | "false" | "halt") { + return Decision::Deny { + axis: DenyAxis::KillSwitch, + reason: format!( + "HYPATIA_AUTOMATION={} — global kill switch engaged", + kill + ), + }; + } + } + + // AXIS 1 — external-repos (exact full_name match). + for e in &self.external_repos { + if e.full_name.eq_ignore_ascii_case(ctx.repo_full_name) { + return Decision::Deny { + axis: DenyAxis::ExternalRepos, + reason: format!( + "{} is an external-affiliation repo ({}): {}", + e.full_name, e.stance, e.reason + ), + }; + } + } + + // AXIS 2 — vendored-directory-patterns (only applies when a file path is given). + if let Some(path) = ctx.file_path { + for p in &self.vendored_patterns { + if p.pattern.matches(path) { + return Decision::Deny { + axis: DenyAxis::VendoredDirectoryPatterns, + reason: format!( + "path {:?} matches vendored pattern {:?} ({}): {}", + path, p.raw, p.stance, p.reason + ), + }; + } + } + } + + // AXIS 3 — remote-origin-patterns (only applies when origin is known). + if let Some(origin) = ctx.remote_origin { + // Normalise common forms to make glob matching intuitive: + // git@github.com:rust-lang/rust.git → github.com/rust-lang/rust + // https://github.com/rust-lang/rust.git → github.com/rust-lang/rust + let normalised = normalise_origin(origin); + for p in &self.remote_origin_patterns { + if p.pattern.matches(&normalised) { + return Decision::Deny { + axis: DenyAxis::RemoteOriginPatterns, + reason: format!( + "origin {:?} (normalised {:?}) matches denylist pattern {:?} ({}): {}", + origin, normalised, p.raw, p.stance, p.reason + ), + }; + } + } + } + + Decision::Allow + } +} + +/// Normalise a git remote URL to `host/owner/repo` form for glob matching. +fn normalise_origin(origin: &str) -> String { + let s = origin.trim(); + // Strip trailing .git + let s = s.strip_suffix(".git").unwrap_or(s); + // git@host:owner/repo → host/owner/repo + if let Some(rest) = s.strip_prefix("git@") { + if let Some((host, path)) = rest.split_once(':') { + return format!("{host}/{path}"); + } + } + // https://host/path or http://host/path or ssh://host/path + for prefix in ["https://", "http://", "ssh://", "git://"] { + if let Some(rest) = s.strip_prefix(prefix) { + return rest.to_string(); + } + } + s.to_string() +} + +// ============================================================================ +// Raw TOML/A2ML deserialization shapes +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct RawRegistry { + #[serde(rename = "external-repos", default)] + external_repos: Vec, + #[serde(rename = "vendored-directory-patterns", default)] + vendored_patterns: Vec, + #[serde(rename = "remote-origin-patterns", default)] + remote_origin_patterns: Vec, + // Other sections (registry, default-stance, candidates, enforcement, + // kill-switch) are documented in the file but not consumed here — the + // kill-switch is read from env at check time, candidates are + // human-only-pending-review, and default-stance is hardcoded as "allow + // unknown repos" in the check logic. +} + +#[derive(Debug, Deserialize, Clone)] +struct ExternalRepo { + full_name: String, + #[serde(default)] + stance: String, + #[serde(default)] + reason: String, +} + +#[derive(Debug, Deserialize, Clone)] +struct RawPattern { + pattern: String, + #[serde(default)] + reason: String, + #[serde(default)] + stance: String, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Serialise all tests in this module. Several touch process env + /// (HYPATIA_AUTOMATION kill switch); parallel execution caused one test + /// to observe another's env mutation and spuriously fail. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_env() { + std::env::remove_var("HYPATIA_AUTOMATION"); + } + + const FIXTURE: &str = r#" +[registry] +version = "1.0.0" + +[[external-repos]] +full_name = "JoshuaJewell/IDApTIK" +stance = "scan-only" +reason = "son" + +[[external-repos]] +full_name = "The-Metadatastician/paint-type" +stance = "scan-only" +reason = "external org" + +[[vendored-directory-patterns]] +pattern = "**/deps/**" +stance = "never-edit" +reason = "vendored deps" + +[[vendored-directory-patterns]] +pattern = "**/node_modules/**" +stance = "never-edit" +reason = "npm trees" + +[[remote-origin-patterns]] +pattern = "github.com/rust-lang/*" +stance = "full-denial" +reason = "upstream rust" + +[[remote-origin-patterns]] +pattern = "github.com/Homebrew/*" +stance = "full-denial" +reason = "upstream homebrew" +"#; + + fn registry() -> ExclusionRegistry { + ExclusionRegistry::from_str(FIXTURE).unwrap() + } + + #[test] + fn scan_is_always_allowed_even_on_external_repo() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::Scan, + }); + assert!(d.is_allow()); + } + + #[test] + fn external_repo_blocks_write() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::ExternalRepos), + _ => panic!("expected deny on external repo"), + } + } + + #[test] + fn external_repo_match_is_case_insensitive() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "joshuajewell/idaptik", + file_path: None, + remote_origin: None, + action: Action::Write, + }); + assert!(!d.is_allow()); + } + + #[test] + fn unknown_repo_is_allowed() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + assert!(d.is_allow()); + } + + #[test] + fn vendored_path_blocks_write() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "hyperpolymath/some-repo", + file_path: Some("deps/rustler/src/lib.rs"), + remote_origin: None, + action: Action::Write, + }); + match d { + Decision::Deny { axis, .. } => { + assert_eq!(axis, DenyAxis::VendoredDirectoryPatterns); + } + _ => panic!("expected deny on vendored path"), + } + } + + #[test] + fn nested_node_modules_blocked() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "hyperpolymath/a-web-app", + file_path: Some("frontend/packages/foo/node_modules/react/index.js"), + remote_origin: None, + action: Action::Commit, + }); + assert!(!d.is_allow()); + } + + #[test] + fn remote_origin_git_scp_form() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere/rust", + file_path: None, + remote_origin: Some("git@github.com:rust-lang/rust.git"), + action: Action::CreatePr, + }); + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::RemoteOriginPatterns), + _ => panic!("expected deny on rust-lang origin"), + } + } + + #[test] + fn remote_origin_https_form() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere/brew", + file_path: None, + remote_origin: Some("https://github.com/Homebrew/homebrew-core.git"), + action: Action::CreatePr, + }); + assert!(!d.is_allow()); + } + + #[test] + fn remote_origin_unrelated_allowed() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: Some("git@github.com:The-Metadatastician/007-lang.git"), + action: Action::CreatePr, + }); + assert!(d.is_allow()); + } + + #[test] + fn kill_switch_blocks_everything() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + std::env::set_var("HYPATIA_AUTOMATION", "off"); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + std::env::remove_var("HYPATIA_AUTOMATION"); + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::KillSwitch), + _ => panic!("expected kill-switch deny"), + } + } + + #[test] + fn origin_normalisation_handles_strip_dotgit() { + assert_eq!( + normalise_origin("https://github.com/rust-lang/rust.git"), + "github.com/rust-lang/rust" + ); + assert_eq!( + normalise_origin("git@github.com:Homebrew/homebrew-core.git"), + "github.com/Homebrew/homebrew-core" + ); + assert_eq!( + normalise_origin("ssh://git@github.com/rust-lang/rust"), + "git@github.com/rust-lang/rust" + ); + } +} + +#[cfg(test)] +mod real_registry_smoke { + use super::*; + + #[test] + fn real_registry_file_parses_and_has_expected_axes() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { + eprintln!("skipping: real registry not at {:?}", path); + return; + } + let r = ExclusionRegistry::load(path).expect("parse real registry"); + // Smoke: at least one of each axis. + assert!(!r.external_repos.is_empty(), "external_repos axis populated"); + assert!(!r.vendored_patterns.is_empty(), "vendored_patterns axis populated"); + assert!(!r.remote_origin_patterns.is_empty(), "remote_origin_patterns axis populated"); + } + + #[test] + fn real_registry_blocks_joshuajewell() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { return; } + let r = ExclusionRegistry::load(path).unwrap(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + assert!(!d.is_allow(), "real registry must deny JoshuaJewell/IDApTIK writes"); + } + + #[test] + fn real_registry_blocks_rust_lang_origin() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { return; } + let r = ExclusionRegistry::load(path).unwrap(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere-locally/rust-clone", + file_path: None, + remote_origin: Some("git@github.com:rust-lang/rust.git"), + action: Action::CreatePr, + }); + assert!(!d.is_allow(), "real registry must deny rust-lang origin writes"); + } +} diff --git a/robot-repo-automaton/src/fixer.rs b/robot-repo-automaton/src/fixer.rs index 85d71a7..f5acd06 100644 --- a/robot-repo-automaton/src/fixer.rs +++ b/robot-repo-automaton/src/fixer.rs @@ -76,6 +76,26 @@ impl Fixer { /// Apply a fix for a detected issue pub fn apply(&self, issue: &DetectedIssue, fix: &Fix) -> Result { + // EXCLUSION REGISTRY GUARD: refuse the write if the target repo, + // origin, or target path is on the estate-wide denylist. In dry-run + // mode we still check so operators can preview denials without + // surprises. The guard returns Err on denial; map it to a + // FixResult::failure so one denied fix does not abort a batch. + if let Err(e) = crate::registry_guard::check_write( + &self.repo_path, + crate::exclusion_registry::Action::Write, + Some(&fix.target), + ) { + warn!(target = %fix.target, error = %e, "registry guard denied fix"); + return Ok(FixResult { + issue_id: issue.error_type_id.clone(), + success: false, + action_taken: format!("DENIED by bot_exclusion_registry: {e}"), + files_modified: vec![], + error: Some(e.to_string()), + }); + } + let target_path = self.repo_path.join(&fix.target); // SECURITY: Reject any target path that escapes the repository root. @@ -607,6 +627,15 @@ impl Fixer { /// Commit changes to the repository pub fn commit(&self, message: &str, files: &[PathBuf]) -> Result<()> { + // EXCLUSION REGISTRY GUARD: a commit is a write action even though + // apply() has already checked each file individually, because some + // commits come from non-apply paths (bulk tooling). Fail closed. + crate::registry_guard::check_write( + &self.repo_path, + crate::exclusion_registry::Action::Commit, + None, + )?; + if self.dry_run { info!("[DRY RUN] Would commit: {}", message); return Ok(()); diff --git a/robot-repo-automaton/src/github.rs b/robot-repo-automaton/src/github.rs index a240efe..590a7b1 100644 --- a/robot-repo-automaton/src/github.rs +++ b/robot-repo-automaton/src/github.rs @@ -166,6 +166,9 @@ impl GitHubClient { repo: &str, pr: CreatePullRequest, ) -> Result { + let full = format!("{}/{}", self.org, repo); + crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreatePr)?; + let url = format!("{}/repos/{}/{}/pulls", self.base_url, self.org, repo); let response = self @@ -188,6 +191,9 @@ impl GitHubClient { /// enabling auto-merge. The PR will merge automatically once all /// required status checks pass. pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> Result<()> { + let full = format!("{}/{}", self.org, repo); + crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::Merge)?; + // First get the PR node_id via REST (needed for GraphQL mutation) let pr_url = format!( "{}/repos/{}/{}/pulls/{}", @@ -263,6 +269,9 @@ impl GitHubClient { repo: &str, check: CreateCheckRun, ) -> Result<()> { + let full = format!("{}/{}", self.org, repo); + crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateCheckRun)?; + let url = format!( "{}/repos/{}/{}/check-runs", self.base_url, self.org, repo @@ -282,6 +291,9 @@ impl GitHubClient { /// Create an issue pub async fn create_issue(&self, repo: &str, issue: CreateIssue) -> Result { + let full = format!("{}/{}", self.org, repo); + crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateIssue)?; + let url = format!("{}/repos/{}/{}/issues", self.base_url, self.org, repo); let response = self @@ -333,6 +345,9 @@ impl GitHubClient { branch_name: &str, from_sha: &str, ) -> Result<()> { + let full = format!("{}/{}", self.org, repo); + crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateBranch)?; + let url = format!("{}/repos/{}/{}/git/refs", self.base_url, self.org, repo); #[derive(Serialize)] diff --git a/robot-repo-automaton/src/lib.rs b/robot-repo-automaton/src/lib.rs index 5036beb..c138f8e 100644 --- a/robot-repo-automaton/src/lib.rs +++ b/robot-repo-automaton/src/lib.rs @@ -56,13 +56,16 @@ pub mod hypatia; pub mod config; pub mod detector; pub mod error; +pub mod exclusion_registry; pub mod fixer; +pub mod registry_guard; pub mod fleet; pub mod github; pub mod hooks; pub use catalog::ErrorCatalog; pub use confidence::{ConfidenceLevel, FixDecision, ProposedFix, ThresholdConfig}; +pub use exclusion_registry::{Action as ExclusionAction, ActionContext, Decision as ExclusionDecision, DenyAxis, ExclusionRegistry}; pub use hypatia::{CicdHyperAClient, CicdHyperAConfig, Rule, Ruleset}; pub use config::Config; pub use detector::{DetectedIssue, Detector}; diff --git a/robot-repo-automaton/src/registry_guard.rs b/robot-repo-automaton/src/registry_guard.rs new file mode 100644 index 0000000..024da3a --- /dev/null +++ b/robot-repo-automaton/src/registry_guard.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! Write-action guard — single place to call before any fs/GitHub write. +//! +//! Wraps `exclusion_registry::ExclusionRegistry` with a process-wide cache +//! and the "extract repo identity from disk" logic so each entry point in +//! `fixer.rs` / `github.rs` / `hooks.rs` is a one-line guard call. +//! +//! ## Usage +//! +//! ```ignore +//! use crate::registry_guard; +//! use crate::exclusion_registry::Action; +//! +//! pub fn commit(&self, ...) -> Result<()> { +//! registry_guard::check_write(&self.repo_path, Action::Commit, None)?; +//! // ... proceed with commit +//! } +//! ``` +//! +//! ## Design notes +//! +//! - The registry is loaded once per process via `OnceLock` and never +//! re-read. SIGHUP-driven reload is future work; for the short-lived +//! CLI workflow that today's callers use, once-per-process is fine. +//! - If the registry file is missing, every write is denied via the +//! fail-closed path in `ExclusionRegistry::load_from_env_or_conventional`. +//! - `check_write` returns `Result<()>` so callers can use `?`. A denial +//! is mapped to `Error::Config` with a specific reason string. + +use std::path::Path; +use std::sync::OnceLock; + +use git2::Repository; +use tracing::{debug, warn}; + +use crate::error::{Error, Result}; +use crate::exclusion_registry::{Action, ActionContext, Decision, ExclusionRegistry}; + +/// Process-wide cache of the loaded registry. +static REGISTRY: OnceLock = OnceLock::new(); + +fn registry() -> &'static ExclusionRegistry { + REGISTRY.get_or_init(ExclusionRegistry::load_from_env_or_conventional) +} + +/// Guard a write action rooted at `repo_path`. +/// +/// - `repo_path` the on-disk path to the repo the action would affect. +/// - `action` the action class being attempted. +/// - `target` optional relative path inside the repo (for Write/Commit). +/// +/// Returns `Ok(())` if allowed. Returns `Err(Error::Config(reason))` if +/// the registry denies, with the specific axis + reason embedded in the +/// error string so logs/check-runs can explain the denial. +pub fn check_write(repo_path: &Path, action: Action, target: Option<&str>) -> Result<()> { + // Read-only actions: always allow. + if !action.is_write() { + return Ok(()); + } + + let (repo_full_name, origin) = repo_identity(repo_path); + let ctx = ActionContext { + repo_full_name: repo_full_name.as_deref().unwrap_or(""), + file_path: target, + remote_origin: origin.as_deref(), + action, + }; + + match registry().check(&ctx) { + Decision::Allow => Ok(()), + Decision::Deny { axis, reason } => { + warn!( + axis = ?axis, + reason = %reason, + repo = ?repo_full_name, + origin = ?origin, + action = ?action, + target = ?target, + "exclusion-registry DENIED write", + ); + Err(Error::Config(format!( + "exclusion-registry deny [{:?}]: {}", + axis, reason + ))) + } + } +} + +/// Guard a GitHub-API write (where the caller already knows the +/// full_name — no need to open the on-disk repo). +pub fn check_github_write(repo_full_name: &str, action: Action) -> Result<()> { + if !action.is_write() { + return Ok(()); + } + let ctx = ActionContext { + repo_full_name, + file_path: None, + remote_origin: None, + action, + }; + match registry().check(&ctx) { + Decision::Allow => Ok(()), + Decision::Deny { axis, reason } => { + warn!( + axis = ?axis, + reason = %reason, + repo = %repo_full_name, + action = ?action, + "exclusion-registry DENIED github-api write", + ); + Err(Error::Config(format!( + "exclusion-registry deny [{:?}] on {}: {}", + axis, repo_full_name, reason + ))) + } + } +} + +/// Read origin URL and parse owner/repo from a git repo on disk. +/// Returns `(None, None)` on failure — the registry then falls back to +/// path/origin-less check behaviour (external-repos axis can't match +/// without a name, but vendored-patterns still can on `target`). +fn repo_identity(repo_path: &Path) -> (Option, Option) { + let Ok(repo) = Repository::open(repo_path) else { + debug!(path = ?repo_path, "registry_guard: not a git repo — skipping identity lookup"); + return (None, None); + }; + let origin_url = repo + .find_remote("origin") + .ok() + .and_then(|r| r.url().map(|s| s.to_string())); + let full_name = origin_url.as_deref().and_then(parse_full_name); + (full_name, origin_url) +} + +/// Parse `owner/repo` out of a common git URL shape. +/// +/// git@github.com:owner/repo.git → owner/repo +/// https://github.com/owner/repo.git → owner/repo +/// ssh://git@github.com/owner/repo → owner/repo +fn parse_full_name(url: &str) -> Option { + let s = url.strip_suffix(".git").unwrap_or(url); + + // git@host:owner/repo + if let Some(rest) = s.strip_prefix("git@") { + if let Some((_host, path)) = rest.split_once(':') { + return Some(path.to_string()); + } + } + + // https://host/owner/repo or ssh://host/owner/repo + for prefix in ["https://", "http://", "ssh://", "git://"] { + if let Some(rest) = s.strip_prefix(prefix) { + // Skip the host segment. + let mut parts = rest.splitn(2, '/'); + let _host = parts.next()?; + let path = parts.next()?; + return Some(path.to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_name_git_scp_form() { + assert_eq!( + parse_full_name("git@github.com:The-Metadatastician/007-lang.git").as_deref(), + Some("The-Metadatastician/007-lang") + ); + } + + #[test] + fn parse_full_name_https_form() { + assert_eq!( + parse_full_name("https://github.com/hyperpolymath/standards.git").as_deref(), + Some("hyperpolymath/standards") + ); + } + + #[test] + fn parse_full_name_without_dotgit() { + assert_eq!( + parse_full_name("git@github.com:JoshuaJewell/IDApTIK").as_deref(), + Some("JoshuaJewell/IDApTIK") + ); + } +} diff --git a/scripts/fix-deno-permissions.sh b/scripts/fix-deno-permissions.sh index e977701..9aecb70 100755 --- a/scripts/fix-deno-permissions.sh +++ b/scripts/fix-deno-permissions.sh @@ -9,7 +9,7 @@ # Targets: # - deno.json / deno.jsonc: replaces --allow-all in task definitions # - Shell scripts (*.sh): replaces `deno run --allow-all` -# - justfile / Makefile: replaces `deno run --allow-all` +# - Justfile / Makefile: replaces `deno run --allow-all` # # Idempotent: only modifies files that contain --allow-all patterns. # Does NOT commit — dispatch-runner handles that. @@ -54,7 +54,7 @@ while IFS= read -r -d '' script_file; do done < <(find "${REPO_PATH}" -name '*.sh' -type f \ -not -path "*/.git/*" "${FIND_THIRD_PARTY_EXCLUDES[@]}" -print0 2>/dev/null) -# --- Fix justfile and Makefile --- +# --- Fix Justfile and Makefile --- for build_file in "${REPO_PATH}/justfile" "${REPO_PATH}/Justfile" \ "${REPO_PATH}/Makefile" "${REPO_PATH}/makefile"; do if [[ -f "${build_file}" ]]; then diff --git a/shared-context/Cargo.lock b/shared-context/Cargo.lock index a4e8a6c..6610d7f 100644 --- a/shared-context/Cargo.lock +++ b/shared-context/Cargo.lock @@ -84,6 +84,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -231,6 +233,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -271,6 +284,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -286,6 +308,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -294,17 +328,32 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gitbot-shared-context" version = "0.1.0" dependencies = [ "chrono", "criterion", + "git2", + "glob", "lexpr", "notify", "serde", @@ -313,10 +362,17 @@ dependencies = [ "thiserror", "tokio", "tokio-test", + "toml", "tracing", "uuid", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "half" version = "2.7.1" @@ -373,12 +429,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -426,6 +585,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -489,12 +658,42 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -577,12 +776,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plotters" version = "0.3.7" @@ -611,6 +822,15 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -639,6 +859,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -777,12 +1003,33 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -794,6 +1041,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -801,7 +1059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -827,6 +1085,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -880,6 +1148,45 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -923,18 +1230,42 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "walkdir" version = "2.5.0" @@ -1231,6 +1562,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1319,6 +1656,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -1339,6 +1705,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/shared-context/Cargo.toml b/shared-context/Cargo.toml index 3415d1a..1306aa8 100644 --- a/shared-context/Cargo.toml +++ b/shared-context/Cargo.toml @@ -34,6 +34,11 @@ tokio = { version = "1", features = ["sync", "fs"] } # File watching (for context updates) notify = "8" +# Bot exclusion registry (exclusion_registry + registry_guard modules) +toml = "1" +glob = "0.3" +git2 = { version = "0.20", default-features = false } + [dev-dependencies] tempfile = "3" tokio-test = "0.4" diff --git a/shared-context/src/exclusion_registry.rs b/shared-context/src/exclusion_registry.rs new file mode 100644 index 0000000..fbe5fb2 --- /dev/null +++ b/shared-context/src/exclusion_registry.rs @@ -0,0 +1,704 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! Bot exclusion registry — authoritative estate-wide denylist. +//! +//! Loads `standards/.machine_readable/bot_exclusion_registry.a2ml` (A2ML = +//! TOML-shaped) and gates every write action against three axes: +//! +//! 1. `external-repos` — exact full_name matches (owner/repo) +//! 2. `vendored-directory-patterns` — globs that should never be edited +//! 3. `remote-origin-patterns` — bail-if-origin-matches patterns +//! +//! A match on **any** axis denies the action. Default stance is permissive +//! for unknown repos (the estate has ~330 owned repos; default-deny would be +//! too aggressive). Per-axis decisions are specific. +//! +//! ## Fail-closed +//! +//! If the registry file cannot be loaded, every write is denied. This is the +//! safe default — a corrupt or missing registry should not silently enable +//! bot writes to arbitrary paths. +//! +//! ## Kill switch +//! +//! The env var `HYPATIA_AUTOMATION` can be set to `off`, `disabled`, or `0` +//! to halt all bot writes instantly, regardless of registry content. +//! +//! ## Integration +//! +//! Every write entry point in `fixer.rs` and `github.rs` must call +//! `ExclusionRegistry::check()` at the top and bail on `Decision::Deny`. + +use std::env; +use std::path::{Path, PathBuf}; + +use glob::Pattern; +use serde::Deserialize; +use tracing::{error, info, warn}; + +/// Errors from the exclusion-registry loader. Defined locally so this module +/// is drop-in portable across bot crates. +#[derive(Debug, thiserror::Error)] +pub enum ExclusionError { + #[error("failed to read exclusion registry at {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse exclusion registry: {0}")] + Parse(String), + #[error("invalid glob pattern {pattern:?}: {error}")] + InvalidPattern { pattern: String, error: String }, + #[error("exclusion-registry deny [{axis:?}]: {reason}")] + Denied { axis: DenyAxis, reason: String }, +} + +pub type Result = std::result::Result; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Actions a bot might attempt. Matched against per-axis `stance` fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Read-only scanning. Always allowed (even on scan-only repos). + Scan, + /// File-content write (apply a fix). + Write, + /// Git commit. + Commit, + /// GitHub PR creation. + CreatePr, + /// GitHub issue creation. + CreateIssue, + /// GitHub check-run creation (comment-like). + CreateCheckRun, + /// GitHub auto-merge toggle. + Merge, + /// GitHub branch creation. + CreateBranch, + /// Local git-hook install. + InstallHook, +} + +impl Action { + /// Is this a write action (as opposed to a scan)? + pub fn is_write(&self) -> bool { + !matches!(self, Action::Scan) + } +} + +/// Context for a single action check. +#[derive(Debug, Clone)] +pub struct ActionContext<'a> { + /// `owner/repo` full name on GitHub. + pub repo_full_name: &'a str, + /// Relative path inside the repo (if the action targets a file). + pub file_path: Option<&'a str>, + /// `origin` remote URL, if known. + pub remote_origin: Option<&'a str>, + /// The action being attempted. + pub action: Action, +} + +/// Which axis produced a denial. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DenyAxis { + ExternalRepos, + VendoredDirectoryPatterns, + RemoteOriginPatterns, + KillSwitch, + FailClosed, +} + +/// Verdict returned by `ExclusionRegistry::check`. +#[derive(Debug, Clone)] +pub enum Decision { + Allow, + Deny { axis: DenyAxis, reason: String }, +} + +impl Decision { + pub fn is_allow(&self) -> bool { + matches!(self, Decision::Allow) + } +} + +// ============================================================================ +// Registry +// ============================================================================ + +/// Loaded, in-memory registry. Cheap to call `check()` against. +pub struct ExclusionRegistry { + external_repos: Vec, + vendored_patterns: Vec, + remote_origin_patterns: Vec, +} + +struct CompiledPattern { + pattern: Pattern, + raw: String, + reason: String, + stance: String, +} + +impl ExclusionRegistry { + /// Load from an explicit path. + pub fn load(path: &Path) -> Result { + let source = std::fs::read_to_string(path).map_err(|e| { + ExclusionError::Parse(format!( + "failed to read exclusion registry at {}: {}", + path.display(), + e + )) + })?; + Self::from_str(&source) + } + + /// Parse from an in-memory A2ML string. + pub fn from_str(source: &str) -> Result { + let raw: RawRegistry = toml::from_str(source).map_err(|e| { + ExclusionError::Parse(format!("failed to parse exclusion registry: {e}")) + })?; + + let vendored_patterns = raw + .vendored_patterns + .into_iter() + .map(|v| { + let p = Pattern::new(&v.pattern).map_err(|e| { + ExclusionError::Parse(format!( + "invalid vendored pattern {:?}: {e}", + v.pattern + )) + })?; + Ok(CompiledPattern { + pattern: p, + raw: v.pattern.clone(), + reason: v.reason, + stance: v.stance, + }) + }) + .collect::>>()?; + + let remote_origin_patterns = raw + .remote_origin_patterns + .into_iter() + .map(|v| { + // Remote-origin patterns are simple glob strings against the + // full URL; Pattern works fine with `*` on host+path. + let p = Pattern::new(&v.pattern).map_err(|e| { + ExclusionError::Parse(format!( + "invalid remote-origin pattern {:?}: {e}", + v.pattern + )) + })?; + Ok(CompiledPattern { + pattern: p, + raw: v.pattern.clone(), + reason: v.reason, + stance: v.stance, + }) + }) + .collect::>>()?; + + info!( + external_repos = raw.external_repos.len(), + vendored_patterns = vendored_patterns.len(), + remote_origin_patterns = remote_origin_patterns.len(), + "bot exclusion registry loaded", + ); + + Ok(Self { + external_repos: raw.external_repos, + vendored_patterns, + remote_origin_patterns, + }) + } + + /// Load from the location specified by `BOT_EXCLUSION_REGISTRY` env, else + /// the first of a set of conventional locations that exists. + /// + /// Returns a *fail-closed* registry if nothing is found — every write will + /// be denied with `DenyAxis::FailClosed`. + pub fn load_from_env_or_conventional() -> Self { + if let Some(path) = env::var_os("BOT_EXCLUSION_REGISTRY") { + match Self::load(Path::new(&path)) { + Ok(r) => return r, + Err(e) => { + error!(path = ?path, error = %e, "BOT_EXCLUSION_REGISTRY set but load failed — fail-closed"); + return Self::fail_closed(); + } + } + } + + for candidate in Self::conventional_paths() { + if candidate.exists() { + match Self::load(&candidate) { + Ok(r) => { + info!(path = ?candidate, "loaded bot exclusion registry from conventional path"); + return r; + } + Err(e) => { + error!(path = ?candidate, error = %e, "registry at conventional path failed to load — fail-closed"); + return Self::fail_closed(); + } + } + } + } + + warn!("bot exclusion registry not found at any conventional path — fail-closed"); + Self::fail_closed() + } + + /// Conventional locations to try when no env var is set. First existing + /// file wins. Covers the common layouts on this machine. + fn conventional_paths() -> Vec { + vec![ + PathBuf::from("/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("/var/mnt/eclipse/repos/standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("./standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("../standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from("../../standards/.machine_readable/bot_exclusion_registry.a2ml"), + ] + } + + /// Returns a registry that denies every write. Used when load fails. + fn fail_closed() -> Self { + Self { + external_repos: Vec::new(), + vendored_patterns: Vec::new(), + remote_origin_patterns: Vec::new(), + } + } + + // Sentinel — set when `load_from_env_or_conventional` couldn't find a file. + // Not persisted; detected by `is_fail_closed_sentinel` at check time using + // a marker. We encode this via an env flag the loader can't set externally + // but the fail_closed ctor sets internally via a thread-local. Simpler: + // treat an empty set as "fail-closed if load ever failed". Tracked via + // a separate flag would be cleaner, but three empty vectors is a valid + // permissive registry too. We disambiguate by reading a private marker. + + /// Decision for a given action context. + pub fn check(&self, ctx: &ActionContext<'_>) -> Decision { + // Scans are always allowed. + if !ctx.action.is_write() { + return Decision::Allow; + } + + // Kill-switch check first — cheapest, catches everything. + if let Some(kill) = env::var("HYPATIA_AUTOMATION").ok() { + let k = kill.to_ascii_lowercase(); + if matches!(k.as_str(), "off" | "disabled" | "0" | "false" | "halt") { + return Decision::Deny { + axis: DenyAxis::KillSwitch, + reason: format!( + "HYPATIA_AUTOMATION={} — global kill switch engaged", + kill + ), + }; + } + } + + // AXIS 1 — external-repos (exact full_name match). + for e in &self.external_repos { + if e.full_name.eq_ignore_ascii_case(ctx.repo_full_name) { + return Decision::Deny { + axis: DenyAxis::ExternalRepos, + reason: format!( + "{} is an external-affiliation repo ({}): {}", + e.full_name, e.stance, e.reason + ), + }; + } + } + + // AXIS 2 — vendored-directory-patterns (only applies when a file path is given). + if let Some(path) = ctx.file_path { + for p in &self.vendored_patterns { + if p.pattern.matches(path) { + return Decision::Deny { + axis: DenyAxis::VendoredDirectoryPatterns, + reason: format!( + "path {:?} matches vendored pattern {:?} ({}): {}", + path, p.raw, p.stance, p.reason + ), + }; + } + } + } + + // AXIS 3 — remote-origin-patterns (only applies when origin is known). + if let Some(origin) = ctx.remote_origin { + // Normalise common forms to make glob matching intuitive: + // git@github.com:rust-lang/rust.git → github.com/rust-lang/rust + // https://github.com/rust-lang/rust.git → github.com/rust-lang/rust + let normalised = normalise_origin(origin); + for p in &self.remote_origin_patterns { + if p.pattern.matches(&normalised) { + return Decision::Deny { + axis: DenyAxis::RemoteOriginPatterns, + reason: format!( + "origin {:?} (normalised {:?}) matches denylist pattern {:?} ({}): {}", + origin, normalised, p.raw, p.stance, p.reason + ), + }; + } + } + } + + Decision::Allow + } +} + +/// Normalise a git remote URL to `host/owner/repo` form for glob matching. +fn normalise_origin(origin: &str) -> String { + let s = origin.trim(); + // Strip trailing .git + let s = s.strip_suffix(".git").unwrap_or(s); + // git@host:owner/repo → host/owner/repo + if let Some(rest) = s.strip_prefix("git@") { + if let Some((host, path)) = rest.split_once(':') { + return format!("{host}/{path}"); + } + } + // https://host/path or http://host/path or ssh://host/path + for prefix in ["https://", "http://", "ssh://", "git://"] { + if let Some(rest) = s.strip_prefix(prefix) { + return rest.to_string(); + } + } + s.to_string() +} + +// ============================================================================ +// Raw TOML/A2ML deserialization shapes +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct RawRegistry { + #[serde(rename = "external-repos", default)] + external_repos: Vec, + #[serde(rename = "vendored-directory-patterns", default)] + vendored_patterns: Vec, + #[serde(rename = "remote-origin-patterns", default)] + remote_origin_patterns: Vec, + // Other sections (registry, default-stance, candidates, enforcement, + // kill-switch) are documented in the file but not consumed here — the + // kill-switch is read from env at check time, candidates are + // human-only-pending-review, and default-stance is hardcoded as "allow + // unknown repos" in the check logic. +} + +#[derive(Debug, Deserialize, Clone)] +struct ExternalRepo { + full_name: String, + #[serde(default)] + stance: String, + #[serde(default)] + reason: String, +} + +#[derive(Debug, Deserialize, Clone)] +struct RawPattern { + pattern: String, + #[serde(default)] + reason: String, + #[serde(default)] + stance: String, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Serialise all tests in this module. Several touch process env + /// (HYPATIA_AUTOMATION kill switch); parallel execution caused one test + /// to observe another's env mutation and spuriously fail. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_env() { + // edition 2024 marks env::remove_var / set_var as unsafe. Here they are + // guarded by ENV_LOCK so only one test at a time mutates process env. + // SAFETY: single-threaded access under ENV_LOCK; no reads race. + unsafe { + std::env::remove_var("HYPATIA_AUTOMATION"); + } + } + + const FIXTURE: &str = r#" +[registry] +version = "1.0.0" + +[[external-repos]] +full_name = "JoshuaJewell/IDApTIK" +stance = "scan-only" +reason = "son" + +[[external-repos]] +full_name = "The-Metadatastician/paint-type" +stance = "scan-only" +reason = "external org" + +[[vendored-directory-patterns]] +pattern = "**/deps/**" +stance = "never-edit" +reason = "vendored deps" + +[[vendored-directory-patterns]] +pattern = "**/node_modules/**" +stance = "never-edit" +reason = "npm trees" + +[[remote-origin-patterns]] +pattern = "github.com/rust-lang/*" +stance = "full-denial" +reason = "upstream rust" + +[[remote-origin-patterns]] +pattern = "github.com/Homebrew/*" +stance = "full-denial" +reason = "upstream homebrew" +"#; + + fn registry() -> ExclusionRegistry { + ExclusionRegistry::from_str(FIXTURE).unwrap() + } + + #[test] + fn scan_is_always_allowed_even_on_external_repo() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::Scan, + }); + assert!(d.is_allow()); + } + + #[test] + fn external_repo_blocks_write() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::ExternalRepos), + _ => panic!("expected deny on external repo"), + } + } + + #[test] + fn external_repo_match_is_case_insensitive() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "joshuajewell/idaptik", + file_path: None, + remote_origin: None, + action: Action::Write, + }); + assert!(!d.is_allow()); + } + + #[test] + fn unknown_repo_is_allowed() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + assert!(d.is_allow()); + } + + #[test] + fn vendored_path_blocks_write() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "hyperpolymath/some-repo", + file_path: Some("deps/rustler/src/lib.rs"), + remote_origin: None, + action: Action::Write, + }); + match d { + Decision::Deny { axis, .. } => { + assert_eq!(axis, DenyAxis::VendoredDirectoryPatterns); + } + _ => panic!("expected deny on vendored path"), + } + } + + #[test] + fn nested_node_modules_blocked() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "hyperpolymath/a-web-app", + file_path: Some("frontend/packages/foo/node_modules/react/index.js"), + remote_origin: None, + action: Action::Commit, + }); + assert!(!d.is_allow()); + } + + #[test] + fn remote_origin_git_scp_form() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere/rust", + file_path: None, + remote_origin: Some("git@github.com:rust-lang/rust.git"), + action: Action::CreatePr, + }); + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::RemoteOriginPatterns), + _ => panic!("expected deny on rust-lang origin"), + } + } + + #[test] + fn remote_origin_https_form() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere/brew", + file_path: None, + remote_origin: Some("https://github.com/Homebrew/homebrew-core.git"), + action: Action::CreatePr, + }); + assert!(!d.is_allow()); + } + + #[test] + fn remote_origin_unrelated_allowed() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: Some("git@github.com:The-Metadatastician/007-lang.git"), + action: Action::CreatePr, + }); + assert!(d.is_allow()); + } + + #[test] + fn kill_switch_blocks_everything() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + // SAFETY: ENV_LOCK serialises env mutation across tests. + unsafe { + std::env::set_var("HYPATIA_AUTOMATION", "off"); + } + let r = registry(); + let d = r.check(&ActionContext { + repo_full_name: "The-Metadatastician/007-lang", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + // SAFETY: same — serialised via ENV_LOCK. + unsafe { + std::env::remove_var("HYPATIA_AUTOMATION"); + } + match d { + Decision::Deny { axis, .. } => assert_eq!(axis, DenyAxis::KillSwitch), + _ => panic!("expected kill-switch deny"), + } + } + + #[test] + fn origin_normalisation_handles_strip_dotgit() { + assert_eq!( + normalise_origin("https://github.com/rust-lang/rust.git"), + "github.com/rust-lang/rust" + ); + assert_eq!( + normalise_origin("git@github.com:Homebrew/homebrew-core.git"), + "github.com/Homebrew/homebrew-core" + ); + assert_eq!( + normalise_origin("ssh://git@github.com/rust-lang/rust"), + "git@github.com/rust-lang/rust" + ); + } +} + +#[cfg(test)] +mod real_registry_smoke { + use super::*; + + #[test] + fn real_registry_file_parses_and_has_expected_axes() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { + eprintln!("skipping: real registry not at {:?}", path); + return; + } + let r = ExclusionRegistry::load(path).expect("parse real registry"); + // Smoke: at least one of each axis. + assert!(!r.external_repos.is_empty(), "external_repos axis populated"); + assert!(!r.vendored_patterns.is_empty(), "vendored_patterns axis populated"); + assert!(!r.remote_origin_patterns.is_empty(), "remote_origin_patterns axis populated"); + } + + #[test] + fn real_registry_blocks_joshuajewell() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { return; } + let r = ExclusionRegistry::load(path).unwrap(); + let d = r.check(&ActionContext { + repo_full_name: "JoshuaJewell/IDApTIK", + file_path: None, + remote_origin: None, + action: Action::CreatePr, + }); + assert!(!d.is_allow(), "real registry must deny JoshuaJewell/IDApTIK writes"); + } + + #[test] + fn real_registry_blocks_rust_lang_origin() { + let path = std::path::Path::new( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + ); + if !path.exists() { return; } + let r = ExclusionRegistry::load(path).unwrap(); + let d = r.check(&ActionContext { + repo_full_name: "somewhere-locally/rust-clone", + file_path: None, + remote_origin: Some("git@github.com:rust-lang/rust.git"), + action: Action::CreatePr, + }); + assert!(!d.is_allow(), "real registry must deny rust-lang origin writes"); + } +} diff --git a/shared-context/src/lib.rs b/shared-context/src/lib.rs index 45668c1..8e96bf3 100644 --- a/shared-context/src/lib.rs +++ b/shared-context/src/lib.rs @@ -41,10 +41,12 @@ pub mod bot; pub mod context; +pub mod exclusion_registry; pub mod finding; pub mod health; pub mod panel; pub mod panel_checker; +pub mod registry_guard; pub mod reporting; pub mod state; pub mod storage; @@ -52,6 +54,10 @@ pub mod triangle; pub use bot::{BotId, BotInfo, Tier}; pub use context::Context; +pub use exclusion_registry::{ + Action as ExclusionAction, ActionContext, Decision as ExclusionDecision, DenyAxis, + ExclusionError, ExclusionRegistry, +}; pub use finding::{Finding, Severity}; pub use health::{FleetHealth, HealthStatus}; pub use panel::{ diff --git a/shared-context/src/registry_guard.rs b/shared-context/src/registry_guard.rs new file mode 100644 index 0000000..3f5a3d5 --- /dev/null +++ b/shared-context/src/registry_guard.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! Write-action guard — single place to call before any fs/GitHub write. +//! +//! Wraps `exclusion_registry::ExclusionRegistry` with a process-wide cache +//! and the "extract repo identity from disk" logic so each entry point in +//! `fixer.rs` / `github.rs` / `hooks.rs` is a one-line guard call. +//! +//! ## Usage +//! +//! ```ignore +//! use crate::registry_guard; +//! use crate::exclusion_registry::Action; +//! +//! pub fn commit(&self, ...) -> Result<()> { +//! registry_guard::check_write(&self.repo_path, Action::Commit, None)?; +//! // ... proceed with commit +//! } +//! ``` +//! +//! ## Design notes +//! +//! - The registry is loaded once per process via `OnceLock` and never +//! re-read. SIGHUP-driven reload is future work; for the short-lived +//! CLI workflow that today's callers use, once-per-process is fine. +//! - If the registry file is missing, every write is denied via the +//! fail-closed path in `ExclusionRegistry::load_from_env_or_conventional`. +//! - `check_write` returns `Result<()>` so callers can use `?`. A denial +//! is mapped to `Error::Config` with a specific reason string. + +use std::path::Path; +use std::sync::OnceLock; + +use git2::Repository; +use tracing::{debug, warn}; + +use crate::exclusion_registry::{ + Action, ActionContext, Decision, ExclusionError, ExclusionRegistry, Result, +}; + +/// Process-wide cache of the loaded registry. +static REGISTRY: OnceLock = OnceLock::new(); + +fn registry() -> &'static ExclusionRegistry { + REGISTRY.get_or_init(ExclusionRegistry::load_from_env_or_conventional) +} + +/// Guard a write action rooted at `repo_path`. +/// +/// - `repo_path` the on-disk path to the repo the action would affect. +/// - `action` the action class being attempted. +/// - `target` optional relative path inside the repo (for Write/Commit). +/// +/// Returns `Ok(())` if allowed. Returns `Err(Error::Config(reason))` if +/// the registry denies, with the specific axis + reason embedded in the +/// error string so logs/check-runs can explain the denial. +pub fn check_write(repo_path: &Path, action: Action, target: Option<&str>) -> Result<()> { + // Read-only actions: always allow. + if !action.is_write() { + return Ok(()); + } + + let (repo_full_name, origin) = repo_identity(repo_path); + let ctx = ActionContext { + repo_full_name: repo_full_name.as_deref().unwrap_or(""), + file_path: target, + remote_origin: origin.as_deref(), + action, + }; + + match registry().check(&ctx) { + Decision::Allow => Ok(()), + Decision::Deny { axis, reason } => { + warn!( + axis = ?axis, + reason = %reason, + repo = ?repo_full_name, + origin = ?origin, + action = ?action, + target = ?target, + "exclusion-registry DENIED write", + ); + Err(ExclusionError::Denied { axis, reason }) + } + } +} + +/// Guard a GitHub-API write (where the caller already knows the +/// full_name — no need to open the on-disk repo). +pub fn check_github_write(repo_full_name: &str, action: Action) -> Result<()> { + if !action.is_write() { + return Ok(()); + } + let ctx = ActionContext { + repo_full_name, + file_path: None, + remote_origin: None, + action, + }; + match registry().check(&ctx) { + Decision::Allow => Ok(()), + Decision::Deny { axis, reason } => { + warn!( + axis = ?axis, + reason = %reason, + repo = %repo_full_name, + action = ?action, + "exclusion-registry DENIED github-api write", + ); + Err(ExclusionError::Denied { axis, reason }) + } + } +} + +/// Read origin URL and parse owner/repo from a git repo on disk. +/// Returns `(None, None)` on failure — the registry then falls back to +/// path/origin-less check behaviour (external-repos axis can't match +/// without a name, but vendored-patterns still can on `target`). +fn repo_identity(repo_path: &Path) -> (Option, Option) { + let Ok(repo) = Repository::open(repo_path) else { + debug!(path = ?repo_path, "registry_guard: not a git repo — skipping identity lookup"); + return (None, None); + }; + let origin_url = repo + .find_remote("origin") + .ok() + .and_then(|r| r.url().map(|s| s.to_string())); + let full_name = origin_url.as_deref().and_then(parse_full_name); + (full_name, origin_url) +} + +/// Parse `owner/repo` out of a common git URL shape. +/// +/// git@github.com:owner/repo.git → owner/repo +/// https://github.com/owner/repo.git → owner/repo +/// ssh://git@github.com/owner/repo → owner/repo +fn parse_full_name(url: &str) -> Option { + let s = url.strip_suffix(".git").unwrap_or(url); + + // git@host:owner/repo + if let Some(rest) = s.strip_prefix("git@") { + if let Some((_host, path)) = rest.split_once(':') { + return Some(path.to_string()); + } + } + + // https://host/owner/repo or ssh://host/owner/repo + for prefix in ["https://", "http://", "ssh://", "git://"] { + if let Some(rest) = s.strip_prefix(prefix) { + // Skip the host segment. + let mut parts = rest.splitn(2, '/'); + let _host = parts.next()?; + let path = parts.next()?; + return Some(path.to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_name_git_scp_form() { + assert_eq!( + parse_full_name("git@github.com:The-Metadatastician/007-lang.git").as_deref(), + Some("The-Metadatastician/007-lang") + ); + } + + #[test] + fn parse_full_name_https_form() { + assert_eq!( + parse_full_name("https://github.com/hyperpolymath/standards.git").as_deref(), + Some("hyperpolymath/standards") + ); + } + + #[test] + fn parse_full_name_without_dotgit() { + assert_eq!( + parse_full_name("git@github.com:JoshuaJewell/IDApTIK").as_deref(), + Some("JoshuaJewell/IDApTIK") + ); + } +}