From 968e10a7b906030dadddeca28374af57425cfa4f Mon Sep 17 00:00:00 2001 From: Ranveer Soni Date: Mon, 11 May 2026 20:07:51 +0530 Subject: [PATCH 1/3] feat(rework): reworked major parts of octo for - adding support for gitlabs - new events that are supported are issues, piplines, push events issues etc - fixed some known issues ie with vuls message - reworked a bit of the logic and reworked the code --- README.md | 6 +- bot/Cargo.lock | 784 +++++++++++++++--- bot/Cargo.toml | 2 + bot/src/commands/delhook.rs | 29 + bot/src/commands/delrepo.rs | 20 + bot/src/commands/edithook.rs | 127 +++ bot/src/commands/editrepo.rs | 51 ++ bot/src/commands/list.rs | 78 ++ bot/src/commands/mod.rs | 85 ++ bot/src/commands/newhook.rs | 144 ++++ bot/src/commands/newrepo.rs | 136 +++ bot/src/commands/resetsecret.rs | 79 ++ bot/src/commands/setrepochannel.rs | 32 + bot/src/core.rs | 527 ------------ bot/src/main.rs | 18 +- schema.sql | 1 + webserver/logos/events/code_scanning_alert.go | 175 ++++ webserver/logos/events/gitlab_common.go | 77 ++ webserver/logos/events/gitlab_issue.go | 76 ++ webserver/logos/events/gitlab_misc.go | 205 +++++ webserver/logos/events/gitlab_mr.go | 87 ++ webserver/logos/events/gitlab_note.go | 99 +++ webserver/logos/events/gitlab_pipeline.go | 197 +++++ webserver/logos/events/gitlab_push.go | 147 ++++ webserver/logos/events/internal_common__.go | 2 + .../logos/events/secret_scanning_alert.go | 107 +++ webserver/logos/logos.go | 2 +- webserver/ontos/api.go | 22 +- webserver/ontos/ontos.go | 141 +++- webserver/pneuma/pneuma.go | 98 ++- webserver/server.go | 57 +- webserver/state/setup.go | 6 +- 32 files changed, 2941 insertions(+), 676 deletions(-) create mode 100644 bot/src/commands/delhook.rs create mode 100644 bot/src/commands/delrepo.rs create mode 100644 bot/src/commands/edithook.rs create mode 100644 bot/src/commands/editrepo.rs create mode 100644 bot/src/commands/list.rs create mode 100644 bot/src/commands/mod.rs create mode 100644 bot/src/commands/newhook.rs create mode 100644 bot/src/commands/newrepo.rs create mode 100644 bot/src/commands/resetsecret.rs create mode 100644 bot/src/commands/setrepochannel.rs delete mode 100644 bot/src/core.rs create mode 100644 webserver/logos/events/code_scanning_alert.go create mode 100644 webserver/logos/events/gitlab_common.go create mode 100644 webserver/logos/events/gitlab_issue.go create mode 100644 webserver/logos/events/gitlab_misc.go create mode 100644 webserver/logos/events/gitlab_mr.go create mode 100644 webserver/logos/events/gitlab_note.go create mode 100644 webserver/logos/events/gitlab_pipeline.go create mode 100644 webserver/logos/events/gitlab_push.go create mode 100644 webserver/logos/events/secret_scanning_alert.go diff --git a/README.md b/README.md index ca6aff4..3e9de7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Git Logs +# OctoFlow -Rewrite of the original Github webhook logger (Git Logs). +Rewrite of the original Github webhook logger (formally Git Logs). --- @@ -53,4 +53,4 @@ You should ideally make this 2 systemd services in production. ## License -This project is licensed under the MIT License +This project is licensed under the GNU Afferno License. diff --git a/bot/Cargo.lock b/bot/Cargo.lock index 5383d11..b456402 100644 --- a/bot/Cargo.lock +++ b/bot/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -107,6 +107,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.4" @@ -124,7 +133,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -157,6 +166,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -196,11 +211,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -221,7 +236,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -235,6 +250,7 @@ dependencies = [ "futures-util", "indexmap", "log", + "octocrab", "once_cell", "poise", "rand", @@ -247,6 +263,7 @@ dependencies = [ "serenity", "sqlx", "tokio", + "urlencoding", ] [[package]] @@ -284,17 +301,17 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" -version = "1.1.6" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -306,6 +323,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.14.2" @@ -313,10 +340,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.8", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -377,11 +418,21 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" @@ -440,6 +491,18 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -450,6 +513,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.20.8" @@ -471,7 +561,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -482,7 +572,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -518,12 +608,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -555,6 +645,44 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.11.0" @@ -564,6 +692,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "env_filter" version = "0.1.0" @@ -650,6 +799,22 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "finl_unicode" version = "1.2.0" @@ -759,7 +924,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -815,17 +980,20 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] name = "getrandom" -version = "0.2.14" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -840,6 +1008,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -946,9 +1125,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "humantime" @@ -958,9 +1137,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "1.3.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -988,26 +1167,57 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls 0.23.31", + "rustls-native-certs 0.8.3", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.3" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", - "tower", "tower-service", "tracing", ] @@ -1092,6 +1302,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.0", + "ed25519-dalek", + "getrandom", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1103,9 +1336,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1268,9 +1501,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1321,6 +1554,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "octocrab" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce7ace5d83b077dd50ff01214a81feea17e258b8f677590c2286add76dc8238e" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.22.0", + "bytes", + "cargo_metadata 0.23.1", + "cfg-if", + "chrono", + "futures", + "futures-util", + "getrandom", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls 0.27.9", + "hyper-timeout", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy 0.10.3", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1333,6 +1607,36 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.0" @@ -1368,6 +1672,16 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.0", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1400,7 +1714,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1466,7 +1780,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1481,11 +1795,20 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" -version = "1.0.81" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1496,7 +1819,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -1592,7 +1915,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", + "hyper-rustls 0.26.0", "hyper-util", "ipnet", "js-sys", @@ -1603,15 +1926,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.22.3", - "rustls-native-certs", + "rustls-native-certs 0.7.1", "rustls-pemfile 2.1.2", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tokio-util", "tower-service", "url", @@ -1623,6 +1946,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.8" @@ -1664,13 +1997,22 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1702,17 +2044,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88d6d420651b496bdd98684116959239430022a115c1240e6c3993be0b15fba" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.5", "rustls-pemfile 2.1.2", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.10.0", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -1736,9 +2105,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.4.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] [[package]] name = "rustls-webpki" @@ -1761,6 +2133,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.15" @@ -1807,6 +2190,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.8.0" @@ -1817,6 +2214,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.10.0" @@ -1824,7 +2230,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1832,9 +2251,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.10.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -1842,18 +2261,29 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", + "serde_core", ] [[package]] name = "serde" -version = "1.0.198" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] @@ -1869,24 +2299,37 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", ] [[package]] @@ -1897,7 +2340,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1933,7 +2376,7 @@ dependencies = [ "arrayvec", "async-trait", "base64 0.21.7", - "bitflags 2.5.0", + "bitflags 2.11.1", "bool_to_bitflags", "bytes", "chrono", @@ -1947,7 +2390,7 @@ dependencies = [ "parking_lot", "percent-encoding", "reqwest", - "secrecy", + "secrecy 0.8.0", "serde", "serde_cow", "serde_json", @@ -2004,6 +2447,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "skeptic" version = "0.13.7" @@ -2011,7 +2466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" dependencies = [ "bytecount", - "cargo_metadata", + "cargo_metadata 0.14.2", "error-chain", "glob", "pulldown-cmark", @@ -2046,6 +2501,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.5.6" @@ -2056,6 +2532,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.5.2" @@ -2141,7 +2627,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.58", "tokio", "tokio-stream", "tracing", @@ -2160,7 +2646,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2183,7 +2669,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.59", + "syn 2.0.117", "tempfile", "tokio", "url", @@ -2198,7 +2684,7 @@ dependencies = [ "atoi", "base64 0.22.0", "bigdecimal", - "bitflags 2.5.0", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -2228,7 +2714,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.58", "tracing", "uuid", "whoami", @@ -2243,7 +2729,7 @@ dependencies = [ "atoi", "base64 0.22.0", "bigdecimal", - "bitflags 2.5.0", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -2270,7 +2756,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.58", "tracing", "uuid", "whoami", @@ -2337,7 +2823,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2359,9 +2845,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.59" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6531ffc7b071655e4ce2e04bd464c4830bb585a61cabb96cf808f05172615a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2374,6 +2860,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "tagptr" version = "0.2.0" @@ -2398,7 +2890,16 @@ version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.58", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -2409,35 +2910,46 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2483,7 +2995,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.6", "tokio-macros", "windows-sys 0.48.0", ] @@ -2496,7 +3008,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2510,6 +3022,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -2532,7 +3054,7 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tungstenite", "webpki-roots 0.26.1", ] @@ -2553,31 +3075,51 @@ dependencies = [ [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "pin-project", "pin-project-lite", + "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -2599,7 +3141,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2639,7 +3181,7 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.58", "url", "utf-8", ] @@ -2663,7 +3205,7 @@ dependencies = [ "mini-moka", "nonmax", "parking_lot", - "secrecy", + "secrecy 0.8.0", "serde_json", "time", "typesize-derive", @@ -2678,7 +3220,7 @@ checksum = "905e88c2a4cc27686bd57e495121d451f027e441388a67f773be729ad4be1ea8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2741,6 +3283,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -2823,7 +3371,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2857,7 +3405,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2891,6 +3439,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -2956,6 +3515,12 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -2974,6 +3539,15 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3122,7 +3696,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -3130,3 +3704,9 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/bot/Cargo.toml b/bot/Cargo.toml index e48b52f..992bf84 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -22,6 +22,8 @@ data-encoding = "2.3" indexmap = { version = "2", features = ["serde"] } serde_yaml = "0.9" once_cell = "1.17" +octocrab = "0.50.0" +urlencoding = "2.1.3" [dependencies.tokio] version = "1" diff --git a/bot/src/commands/delhook.rs b/bot/src/commands/delhook.rs new file mode 100644 index 0000000..44aae65 --- /dev/null +++ b/bot/src/commands/delhook.rs @@ -0,0 +1,29 @@ +use crate::{Context, Error}; + +/// Deletes a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn delhook( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if guild.count.unwrap_or_default() == 0 { + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + sqlx::query!( + "DELETE FROM webhooks WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Webhook deleted if it exists!").await?; + Ok(()) +} diff --git a/bot/src/commands/delrepo.rs b/bot/src/commands/delrepo.rs new file mode 100644 index 0000000..41f842c --- /dev/null +++ b/bot/src/commands/delrepo.rs @@ -0,0 +1,20 @@ +use crate::{Context, Error}; + +/// Deletes a repository +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn delrepo( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + sqlx::query!( + "DELETE FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Repo deleted!").await?; + Ok(()) +} diff --git a/bot/src/commands/edithook.rs b/bot/src/commands/edithook.rs new file mode 100644 index 0000000..e530ebc --- /dev/null +++ b/bot/src/commands/edithook.rs @@ -0,0 +1,127 @@ +use crate::{Context, Error}; + +/// Edits a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn edithook( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, + #[description = "The comment for the webhook"] comment: Option, + #[description = "Is the webhook broken?"] broken: Option, + #[description = "The new secret for the webhook"] webhook_secret: Option, + #[description = "Provider: github or gitlab"] provider: Option, +) -> Result<(), Error> { + let data = ctx.data(); + + // Validate provider if provided + if let Some(ref p) = provider { + let p_lower = p.to_lowercase(); + if p_lower != "github" && p_lower != "gitlab" { + ctx.say("Invalid provider! Use `github` or `gitlab`").await?; + return Ok(()); + } + } + + // Validate secret isn't too short if provided + if let Some(ref s) = webhook_secret { + if s.len() < 16 { + ctx.say("Webhook secret must be at least 16 characters long for security!").await?; + return Ok(()); + } + } + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + } + + // Check webhook for existence + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1 AND id = $2", + &ctx.guild_id().unwrap().to_string(), + &id + ) + .fetch_one(&data.pool) + .await?; + + if webhook_count.count.unwrap_or_default() == 0 { + ctx.say("This webhook does not exist!").await?; + return Ok(()); + } + + let mut tx = data.pool.begin().await?; + + if let Some(comment) = comment { + sqlx::query!( + "UPDATE webhooks SET comment = $1 WHERE id = $2 AND guild_id = $3", + comment, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(broken) = broken { + sqlx::query!( + "UPDATE webhooks SET broken = $1 WHERE id = $2 AND guild_id = $3", + broken, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(webhook_secret) = webhook_secret { + sqlx::query!( + "UPDATE webhooks SET secret = $1 WHERE id = $2 AND guild_id = $3", + webhook_secret, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(provider) = provider { + sqlx::query!( + "UPDATE webhooks SET provider = $1 WHERE id = $2 AND guild_id = $3", + provider.to_lowercase(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + // Update last_updated_at and last_updated_by regardless + sqlx::query!( + "UPDATE webhooks SET last_updated_at = NOW(), last_updated_by = $1 WHERE id = $2 AND guild_id = $3", + ctx.author().id.to_string(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + ctx.say("Webhook updated successfully!").await?; + + Ok(()) +} diff --git a/bot/src/commands/editrepo.rs b/bot/src/commands/editrepo.rs new file mode 100644 index 0000000..47abea7 --- /dev/null +++ b/bot/src/commands/editrepo.rs @@ -0,0 +1,51 @@ +use crate::{Context, Error}; + +/// Edits a repository's name +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn editrepo( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, + #[description = "The new repo owner or organization"] owner: String, + #[description = "The new repo name"] name: String, +) -> Result<(), Error> { + let data = ctx.data(); + let repo_name = (owner+"/"+&name).to_lowercase(); + + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if repo.count.unwrap_or_default() == 0 { + return Err("That repo doesn't exist!".into()); + } + + let provider_query = sqlx::query!( + "SELECT webhooks.provider FROM repos JOIN webhooks ON repos.webhook_id = webhooks.id WHERE repos.id = $1 AND repos.guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); + let client = reqwest::Client::new(); + let exists = if provider == "gitlab" { + let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); + client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false) + } else { + let url = format!("https://api.github.com/repos/{}", repo_name); + client.get(&url).header("User-Agent", "OctoFlow-Discord-Bot").send().await.map(|r| r.status().is_success()).unwrap_or(false) + }; + + if !exists { + return Err("That repository could not be found! Make sure it exists and is public.".into()); + } + + sqlx::query!( + "UPDATE repos SET repo_name = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + &repo_name, ctx.author().id.to_string(), &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Repo name updated successfully!").await?; + Ok(()) +} diff --git a/bot/src/commands/list.rs b/bot/src/commands/list.rs new file mode 100644 index 0000000..3106752 --- /dev/null +++ b/bot/src/commands/list.rs @@ -0,0 +1,78 @@ +use log::error; +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{Context, Error, config}; + +/// Lists all webhooks in a guild with their respective repos and channel IDs +#[poise::command(slash_command, prefix_command, guild_only, required_permissions = "MANAGE_GUILD")] +pub async fn list( + ctx: Context<'_>, +) -> Result<(), Error> { + let data = ctx.data(); + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + } else { + // Get all webhooks + let webhooks = sqlx::query!( + "SELECT id, broken, comment, created_at, COALESCE(provider, 'github') as provider FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_all(&data.pool) + .await; + + match webhooks { + Ok(webhooks) => { + if webhooks.is_empty() { + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + return Ok(()); + } + + let mut cr = CreateReply::default() + .content("Here are all the webhooks in this guild:"); + + let api_url = config::CONFIG.api_url[0].clone(); + + for webhook in webhooks { + let webhook_id = webhook.id; + let provider = webhook.provider.unwrap_or_else(|| "github".to_string()); + let provider_label = if provider == "gitlab" { "GitLab" } else { "GitHub" }; + + cr = cr.embed( + CreateEmbed::new() + .title(format!("Webhook \"{}\"", webhook.comment)) + .field("Webhook ID", webhook_id.clone(), false) + .field("Hook URL", format!("`{}/kittycat?id={}`", api_url, webhook_id), false) + .field("Provider", provider_label.to_string(), true) + .field("Marked as Broken", format!("{}", webhook.broken), true) + .field("Created at", webhook.created_at.to_string(), true) + ); + }; + + ctx.send(cr).await?; + }, + Err(e) => { + error!("Error fetching webhooks: {:?}", e); + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + } + } + } + + Ok(()) +} diff --git a/bot/src/commands/mod.rs b/bot/src/commands/mod.rs new file mode 100644 index 0000000..71f3f5e --- /dev/null +++ b/bot/src/commands/mod.rs @@ -0,0 +1,85 @@ +pub mod list; +pub mod newhook; +pub mod edithook; +pub mod newrepo; +pub mod editrepo; +pub mod delhook; +pub mod delrepo; +pub mod setrepochannel; +pub mod resetsecret; + +use crate::{Context, Error}; + +pub(crate) async fn autocomplete_webhooks<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl Iterator + 'a { + let data = ctx.data(); + let guild_id = match ctx.guild_id() { + Some(id) => id.to_string(), + None => return vec![].into_iter(), + }; + + struct WebhookChoice { + id: String, + comment: String, + } + + let webhooks = match sqlx::query_as!( + WebhookChoice, + "SELECT id, comment FROM webhooks WHERE guild_id = $1", + guild_id + ) + .fetch_all(&data.pool) + .await { + Ok(v) => v, + Err(_) => return vec![].into_iter(), + }; + + webhooks + .into_iter() + .filter(move |w| { + w.comment.to_lowercase().contains(&partial.to_lowercase()) + || w.id.to_lowercase().contains(&partial.to_lowercase()) + }) + .map(|w| w.id) + .collect::>() + .into_iter() +} + +pub(crate) async fn autocomplete_repos<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl Iterator + 'a { + let data = ctx.data(); + let guild_id = match ctx.guild_id() { + Some(id) => id.to_string(), + None => return vec![].into_iter(), + }; + + struct RepoChoice { + id: String, + repo_name: String, + } + + let repos = match sqlx::query_as!( + RepoChoice, + "SELECT id, repo_name FROM repos WHERE guild_id = $1", + guild_id + ) + .fetch_all(&data.pool) + .await { + Ok(v) => v, + Err(_) => return vec![].into_iter(), + }; + + repos + .into_iter() + .filter(move |r| { + r.repo_name.to_lowercase().contains(&partial.to_lowercase()) + || r.id.to_lowercase().contains(&partial.to_lowercase()) + }) + .map(|r| r.id) + .collect::>() + .into_iter() +} diff --git a/bot/src/commands/newhook.rs b/bot/src/commands/newhook.rs new file mode 100644 index 0000000..559852a --- /dev/null +++ b/bot/src/commands/newhook.rs @@ -0,0 +1,144 @@ +use poise::serenity_prelude::CreateMessage; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error, config}; + +/// Creates a new webhook in a guild for sending GitHub/GitLab notifications +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn newhook( + ctx: Context<'_>, + #[description = "The comment for the webhook"] comment: String, + #[description = "Provider: github or gitlab"] provider: Option, + #[description = "Is the webhook broken?"] broken: Option, +) -> Result<(), Error> { + let data = ctx.data(); + + let provider = provider.unwrap_or_else(|| "github".to_string()).to_lowercase(); + + if provider != "github" && provider != "gitlab" { + ctx.say("Invalid provider! Use `github` or `gitlab`").await?; + return Ok(()); + } + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + } + + // Check webhook count + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook_count.count.unwrap_or_default() >= 5 { + ctx.say("You can't have more than 5 webhooks per guild").await?; + return Ok(()); + } + + // Create the webhook + let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + + let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); + + // Create a new dm channel with the user if not slash command + let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; + + let dm = match dm_channel { + Ok(dm) => dm, + Err(_) => { + ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; + return Ok(()); + } + }; + + sqlx::query!( + "INSERT INTO webhooks (id, guild_id, comment, secret, broken, provider, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + id, + &ctx.guild_id().unwrap().to_string(), + comment, + webh_secret, + broken.unwrap_or(false), + provider, + ctx.author().id.to_string(), + ctx.author().id.to_string(), + ) + .execute(&data.pool) + .await?; + + ctx.say("Webhook created! Trying to DM you the credentials...").await?; + + let backup_domains = if config::CONFIG.api_url.len() > 1 { + format!("\n**Backup domains:** {}", config::CONFIG.api_url[1..].join(", ")) + } else { + String::new() + }; + + let dm_content = if provider == "gitlab" { + format!( + "\ +**GitLab Webhook Setup** 🦊 + +1. Go to your GitLab project → Settings → Webhooks +2. Set the **URL** to: `{api_url}/kittycat?id={id}` +3. Set the **Secret token** to: `{webh_secret}` +4. Select the events you want to receive +5. Click **Add webhook** + +When creating repositories with the bot, use `{id}` as the webhook ID. +{backup_domains} +⚠️ **The above URL and secret is unique — do not share it with others** +🗑️ **Delete this message after you're done!**", + api_url=config::CONFIG.api_url[0], + backup_domains=backup_domains, + id=id, + webh_secret=webh_secret + ) + } else { + format!( + "\ +**GitHub Webhook Setup** 🐙 + +1. Go to your repo/org → Settings → Webhooks → Add webhook +2. Set the **Payload URL** to: `{api_url}/kittycat?id={id}` +3. Set the **Content type** to `application/json` +4. Set the **Secret** to: `{webh_secret}` +5. Select the events you want to receive +6. Click **Add webhook** + +When creating repositories with the bot, use `{id}` as the webhook ID. +{backup_domains} +⚠️ **The above URL and secret is unique — do not share it with others** +🗑️ **Delete this message after you're done!**", + api_url=config::CONFIG.api_url[0], + backup_domains=backup_domains, + id=id, + webh_secret=webh_secret + ) + }; + + dm.id.send_message( + &ctx, + CreateMessage::new() + .content(dm_content) + ).await?; + + ctx.say("Webhook created! Check your DMs for the webhook information.").await?; + + Ok(()) +} diff --git a/bot/src/commands/newrepo.rs b/bot/src/commands/newrepo.rs new file mode 100644 index 0000000..328dbdb --- /dev/null +++ b/bot/src/commands/newrepo.rs @@ -0,0 +1,136 @@ +use poise::serenity_prelude::ChannelId; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error}; + +/// Creates a new repository for a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn newrepo( + ctx: Context<'_>, + #[description = "The webhook ID to use"] + #[autocomplete = "super::autocomplete_webhooks"] + webhook_id: String, + #[description = "The repo owner or organization"] owner: String, + #[description = "The repo name"] name: String, + #[description = "The channel to send to"] channel: ChannelId, +) -> Result<(), Error> { + let data = ctx.data(); + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, return a error + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + // Check webhook count + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + let count = webhook_count.count.unwrap_or_default(); + + if count == 0 { + Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()) + } else { + // Check if the webhook exists + let webhook = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", + &webhook_id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook.count.unwrap_or_default() == 0 { + return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let repo_name = (owner+"/"+&name).to_lowercase(); + + // Get provider to validate repo + let provider_query = sqlx::query!( + "SELECT provider FROM webhooks WHERE id = $1 AND guild_id = $2", + &webhook_id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); + + // Validate repository exists + let client = reqwest::Client::new(); + let exists = if provider == "gitlab" { + // GitLab API check + let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); + let res = client.get(&url).send().await; + + if let Ok(response) = res { + response.status().is_success() + } else { + false + } + } else { + // GitHub API check + let url = format!("https://api.github.com/repos/{}", repo_name); + let res = client.get(&url) + .header("User-Agent", "OctoFlow-Discord-Bot") + .send().await; + + if let Ok(response) = res { + response.status().is_success() + } else { + false + } + }; + + if !exists { + return Err("That repository could not be found! Make sure it exists and is public (or use your custom GitLab URL if self-hosted, though validation only works for public repos on github.com/gitlab.com currently).".into()); + } + + // Check if the repo exists + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE lower(repo_name) = $1 AND webhook_id = $2", + &repo_name, + &webhook_id + ) + .fetch_one(&data.pool) + .await?; + + if repo.count.unwrap_or_default() == 0 { + // If it doesn't, create it + let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + + sqlx::query!( + "INSERT INTO repos (id, webhook_id, repo_name, channel_id, guild_id, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", + id, + &webhook_id, + &repo_name, + channel.to_string(), + &ctx.guild_id().unwrap().to_string(), + ctx.author().id.to_string(), + ctx.author().id.to_string(), + ) + .execute(&data.pool) + .await?; + + ctx.say( + format!("Repository created with ID of ``{id}``!", id=id) + ).await?; + + Ok(()) + } else { + Err("That repo already exists! Use ``/delrepo`` (or ``git!delrepo``) to delete it".into()) + } + } +} diff --git a/bot/src/commands/resetsecret.rs b/bot/src/commands/resetsecret.rs new file mode 100644 index 0000000..06ee691 --- /dev/null +++ b/bot/src/commands/resetsecret.rs @@ -0,0 +1,79 @@ +use poise::serenity_prelude::CreateMessage; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error}; + +/// Resets a webhook secret. DMs must be open +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn resetsecret( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let webhook = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", + &id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook.count.unwrap_or_default() == 0 { + return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); + + let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; + + let dm = match dm_channel { + Ok(dm) => dm, + Err(_) => { + ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; + return Ok(()); + } + }; + + sqlx::query!( + "UPDATE webhooks SET secret = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + webh_secret, + ctx.author().id.to_string(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + + dm.id.send_message( + &ctx, + CreateMessage::new() + .content( + format!( + "Your new webhook secret is `{webh_secret}`. + +Update this webhooks information in your GitHub/GitLab settings now. Your webhook will not accept messages unless you do so! + +**Delete this message after you're done!**", + webh_secret=webh_secret + ) + ) + ).await?; + + ctx.say("Webhook secret updated! Check your DMs for the webhook information.").await?; + + Ok(()) +} diff --git a/bot/src/commands/setrepochannel.rs b/bot/src/commands/setrepochannel.rs new file mode 100644 index 0000000..930e89d --- /dev/null +++ b/bot/src/commands/setrepochannel.rs @@ -0,0 +1,32 @@ +use poise::serenity_prelude::ChannelId; + +use crate::{Context, Error}; + +/// Updates the channel for a repository +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn setrepochannel( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, + #[description = "The new channel ID"] channel: ChannelId, +) -> Result<(), Error> { + let data = ctx.data(); + + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if repo.count.unwrap_or_default() == 0 { + return Err("That repo doesn't exist! Use ``/newrepo`` (or ``git!newrepo``) to create one".into()); + } + + sqlx::query!( + "UPDATE repos SET channel_id = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + channel.to_string(), ctx.author().id.to_string(), &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Channel updated!").await?; + Ok(()) +} diff --git a/bot/src/core.rs b/bot/src/core.rs deleted file mode 100644 index 8ac1c0c..0000000 --- a/bot/src/core.rs +++ /dev/null @@ -1,527 +0,0 @@ -use log::error; -use poise::{serenity_prelude::{CreateMessage, ChannelId, CreateEmbed}, CreateReply}; -use rand::distributions::{Alphanumeric, DistString}; - -use crate::{Context, Error, config}; - -/// Lsts all webhooks in a guild with their respective repos and channel IDs -#[poise::command(slash_command, prefix_command, guild_only, required_permissions = "MANAGE_GUILD")] -pub async fn list( - ctx: Context<'_>, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return an error - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - } else { - // Get all webhooks - let webhooks = sqlx::query!( - "SELECT id, broken, comment, created_at FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_all(&data.pool) - .await; - - match webhooks { - Ok(webhooks) => { - let mut cr = CreateReply::default() - .content("Here are all the webhooks in this guild:"); - - let api_url = config::CONFIG.api_url[0].clone(); - - for webhook in webhooks { - let webhook_id = webhook.id; - cr = cr.embed( - CreateEmbed::new() - .title(format!("Webhook \"{}\"", webhook.comment)) - .field("Webhook ID", webhook_id.clone(), false) - .field("Hook URL (visit for hook info, add to Github to recieve events)", api_url.clone()+"/kittycat?id="+&webhook_id, false) - .field("Marked as Broken", format!("{}", webhook.broken), false) - .field("Created at", webhook.created_at.to_string(), false) - ); - }; - - ctx.send(cr).await?; - }, - Err(e) => { - error!("Error fetching webhooks: {:?}", e); - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - } - } - } - - Ok(()) -} - -/// Creates a new webhook in a guild for sending Github notifications -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn newhook( - ctx: Context<'_>, - #[description = "The comment for the webhook"] comment: String, - #[description = "Is the webhook broken?"] broken: Option, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, create it - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - } - - // Check webhook count - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook_count.count.unwrap_or_default() >= 5 { - ctx.say("You can't have more than 5 webhooks per guild").await?; - return Ok(()); - } - - // Create the webhook - let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); - - let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); - - // Create a new dm channel with the user if not slash command - let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; - - let dm = match dm_channel { - Ok(dm) => dm, - Err(_) => { - ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; - return Ok(()); - } - }; - - sqlx::query!( - "INSERT INTO webhooks (id, guild_id, comment, secret, broken, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", - id, - ctx.guild_id().unwrap().to_string(), - comment, - webh_secret, - broken.unwrap_or(false), - ctx.author().id.to_string(), - ctx.author().id.to_string(), - ) - .execute(&data.pool) - .await?; - - ctx.say("Webhook created! Trying to DM you the credentials...").await?; - - dm.id.send_message( - &ctx, - CreateMessage::new() - .content( - format!( - " -Next, add the following webhook to your Github repositories (or organizations): `{api_url}/kittycat?id={id}` - -Set the `Secret` field to `{webh_secret}` and ensure that Content Type is set to `application/json`. - -When creating repositories, use `{id}` as the ID. - -**Backup domains (replace {api_url} with these if gitlogs fails):** {api_domains} - -**Note that the above URL and secret is unique and should not be shared with others** - -**Delete this message after you're done!** - ", - api_url=config::CONFIG.api_url[0], - api_domains=config::CONFIG.api_url[1..].join(", "), - id=id, - webh_secret=webh_secret - ) - ) - ).await?; - - ctx.say("Webhook created! Check your DMs for the webhook information.").await?; - - Ok(()) -} - -/// Edits a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn edithook( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, - #[description = "The comment for the webhook"] comment: Option, - #[description = "Is the webhook broken?"] broken: Option, - #[description = "The new secret for the webhook"] webhook_secret: Option, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, create it - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - } - - // Check webhook for existence - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1 AND id = $2", - ctx.guild_id().unwrap().to_string(), - id - ) - .fetch_one(&data.pool) - .await?; - - if webhook_count.count.unwrap_or_default() == 0 { - ctx.say("This webhook does not exist!").await?; - return Ok(()); - } - - let mut tx = data.pool.begin().await?; - - if let Some(comment) = comment { - sqlx::query!( - "UPDATE webhooks SET comment = $1 WHERE id = $2 AND guild_id = $3", - comment, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - if let Some(broken) = broken { - sqlx::query!( - "UPDATE webhooks SET broken = $1 WHERE id = $2 AND guild_id = $3", - broken, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - if let Some(webhook_secret) = webhook_secret { - sqlx::query!( - "UPDATE webhooks SET secret = $1 WHERE id = $2 AND guild_id = $3", - webhook_secret, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - // Update last_updated_at and last_updated_by regardless - sqlx::query!( - "UPDATE webhooks SET last_updated_at = NOW(), last_updated_by = $1 WHERE id = $2 AND guild_id = $3", - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - ctx.say("Webhook updated successfully!").await?; - - Ok(()) -} - -/// Creates a new repository for a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn newrepo( - ctx: Context<'_>, - #[description = "The webhook ID to use"] webhook_id: String, - #[description = "The repo owner or organization"] owner: String, - #[description = "The repo name"] name: String, - #[description = "The channel to send to"] channel: ChannelId, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - // Check webhook count - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - let count = webhook_count.count.unwrap_or_default(); - - if count == 0 { - Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()) - } else { - // Check if the webhook exists - let webhook = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", - webhook_id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook.count.unwrap_or_default() == 0 { - return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - let repo_name = (owner+"/"+&name).to_lowercase(); - - // Check if the repo exists - let repo = sqlx::query!( - "SELECT COUNT(1) FROM repos WHERE lower(repo_name) = $1 AND webhook_id = $2", - &repo_name, - webhook_id - ) - .fetch_one(&data.pool) - .await?; - - if repo.count.unwrap_or_default() == 0 { - // If it doesn't, create it - let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); - - sqlx::query!( - "INSERT INTO repos (id, webhook_id, repo_name, channel_id, guild_id, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", - id, - webhook_id, - &repo_name, - channel.to_string(), - ctx.guild_id().unwrap().to_string(), - ctx.author().id.to_string(), - ctx.author().id.to_string(), - ) - .execute(&data.pool) - .await?; - - ctx.say( - format!("Repository created with ID of ``{id}``!", id=id) - ).await?; - - Ok(()) - } else { - Err("That repo already exists! Use ``/delrepo`` (or ``git!delrepo``) to delete it".into()) - } - } -} - -/// Deletes a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn delhook( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - sqlx::query!( - "DELETE FROM webhooks WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Webhook deleted if it exists!").await?; - - Ok(()) -} - -/// Deletes a repository -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn delrepo( - ctx: Context<'_>, - #[description = "The repo ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - sqlx::query!( - "DELETE FROM repos WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Repo deleted!").await?; - - Ok(()) -} - -/// Updates the channel for a repository -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn setrepochannel( - ctx: Context<'_>, - #[description = "The repo ID"] id: String, - #[description = "The new channel ID"] channel: ChannelId, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the repo exists - let repo = sqlx::query!( - "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if repo.count.unwrap_or_default() == 0 { - return Err("That repo doesn't exist! Use ``/newrepo`` (or ``git!newrepo``) to create one".into()); - } - - sqlx::query!( - "UPDATE repos SET channel_id = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", - channel.to_string(), - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Channel updated!").await?; - - Ok(()) -} - -/// Resets a webhook secret. DMs must be open -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn resetsecret( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - // Check if the webhook exists - let webhook = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook.count.unwrap_or_default() == 0 { - return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); - - // Try to DM the user - // Create a new dm channel with the user if not slash command - let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; - - let dm = match dm_channel { - Ok(dm) => dm, - Err(_) => { - ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; - return Ok(()); - } - }; - - sqlx::query!( - "UPDATE webhooks SET secret = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", - webh_secret, - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - dm.id.send_message( - &ctx, - CreateMessage::new() - .content( - format!( - "Your new webhook secret is `{webh_secret}`. - -Update this webhooks information in GitHub settings now. Your webhook will not accept messages from GitHub unless you do so! - -**Delete this message after you're done!** - ", - webh_secret=webh_secret - ) - ) - ).await?; - - ctx.say("Webhook secret updated! Check your DMs for the webhook information.").await?; - - Ok(()) -} diff --git a/bot/src/main.rs b/bot/src/main.rs index 3b9e539..8e7506d 100644 --- a/bot/src/main.rs +++ b/bot/src/main.rs @@ -9,7 +9,7 @@ use serenity::gateway::ActivityData; use std::sync::Arc; mod help; -mod core; +mod commands; mod backups; mod config; mod eventmods; @@ -143,13 +143,15 @@ async fn main() { register(), help::simplehelp(), help::help(), - core::list(), - core::newhook(), - core::newrepo(), - core::delhook(), - core::delrepo(), - core::setrepochannel(), - core::resetsecret(), + commands::list(), + commands::newhook(), + commands::edithook(), + commands::newrepo(), + commands::editrepo(), + commands::delhook(), + commands::delrepo(), + commands::setrepochannel(), + commands::resetsecret(), backups::backup(), backups::restore(), eventmods::eventmod(), diff --git a/schema.sql b/schema.sql index 6652bd9..f8ba669 100644 --- a/schema.sql +++ b/schema.sql @@ -9,6 +9,7 @@ CREATE TABLE webhooks ( comment TEXT NOT NULL, -- A comment to help identify the webhook broken BOOLEAN NOT NULL DEFAULT FALSE, secret TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'github', -- 'github' or 'gitlab' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL, last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/webserver/logos/events/code_scanning_alert.go b/webserver/logos/events/code_scanning_alert.go new file mode 100644 index 0000000..0adf7b0 --- /dev/null +++ b/webserver/logos/events/code_scanning_alert.go @@ -0,0 +1,175 @@ +package events + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" +) + +type CodeScanningAlertEvent struct { + Action string `json:"action"` + Repo Repository `json:"repository"` + Sender User `json:"sender"` + Alert struct { + Number int `json:"number"` + State string `json:"state"` + FixedAt string `json:"fixed_at"` + DismissedAt string `json:"dismissed_at"` + DismissedReason string `json:"dismissed_reason"` + DismissedComment string `json:"dismissed_comment"` + CreatedAt string `json:"created_at"` + HTMLURL string `json:"html_url"` + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + SecuritySeverityLevel string `json:"security_severity_level"` + Description string `json:"description"` + FullDescription string `json:"full_description"` + Name string `json:"name"` + } `json:"rule"` + Tool struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"tool"` + MostRecentInstance struct { + Ref string `json:"ref"` + State string `json:"state"` + CommitSHA string `json:"commit_sha"` + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + StartColumn int `json:"start_column"` + EndColumn int `json:"end_column"` + } `json:"location"` + } `json:"most_recent_instance"` + DismissedBy User `json:"dismissed_by"` + } `json:"alert"` +} + +func codeScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { + var gh CodeScanningAlertEvent + + err := json.Unmarshal(bytes, &gh) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gh.Action { + case "fixed": + color = colorGreen + case "appeared_in_branch", "created", "reopened": + color = colorRed + case "closed_by_user": + color = colorDarkRed + } + + // Build severity info + severity := gh.Alert.Rule.Severity + if gh.Alert.Rule.SecuritySeverityLevel != "" { + severity = gh.Alert.Rule.SecuritySeverityLevel + } + if severity == "" { + severity = "unknown" + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gh.Action, + Inline: true, + }, + { + Name: "State", + Value: gh.Alert.State, + Inline: true, + }, + { + Name: "Severity", + Value: severity, + Inline: true, + }, + } + + if gh.Alert.Rule.Name != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Rule", + Value: gh.Alert.Rule.Name + " (`" + gh.Alert.Rule.ID + "`)", + Inline: false, + }) + } + + if gh.Alert.Rule.Description != "" { + desc := gh.Alert.Rule.Description + if len(desc) > 200 { + desc = desc[:200] + "..." + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: desc, + }) + } + + if gh.Alert.Tool.Name != "" { + toolInfo := gh.Alert.Tool.Name + if gh.Alert.Tool.Version != "" { + toolInfo += " v" + gh.Alert.Tool.Version + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Tool", + Value: toolInfo, + Inline: true, + }) + } + + loc := gh.Alert.MostRecentInstance.Location + if loc.Path != "" { + locStr := fmt.Sprintf("`%s` L%d", loc.Path, loc.StartLine) + if loc.EndLine > loc.StartLine { + locStr = fmt.Sprintf("`%s` L%d-L%d", loc.Path, loc.StartLine, loc.EndLine) + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Location", + Value: locStr, + Inline: true, + }) + } + + if gh.Alert.MostRecentInstance.Ref != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Ref", + Value: gh.Alert.MostRecentInstance.Ref, + Inline: true, + }) + } + + if gh.Alert.DismissedBy.Login != "" { + dismissInfo := "By: " + gh.Alert.DismissedBy.Link() + if gh.Alert.DismissedReason != "" { + dismissInfo += "\nReason: " + gh.Alert.DismissedReason + } + if gh.Alert.DismissedAt != "" { + if t, err := time.Parse(time.RFC3339, gh.Alert.DismissedAt); err == nil { + dismissInfo += "\nAt: " + t.Format("2006-01-02 15:04 UTC") + } + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Dismissal", + Value: dismissInfo, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gh.Alert.HTMLURL, + Author: gh.Sender.AuthorEmbed(), + Title: fmt.Sprintf("Code Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Fields: fields, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_common.go b/webserver/logos/events/gitlab_common.go new file mode 100644 index 0000000..17a8e9e --- /dev/null +++ b/webserver/logos/events/gitlab_common.go @@ -0,0 +1,77 @@ +package events + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +// GitLab color palette +var ( + glColorOrange = 0xFC6D26 + glColorPurple = 0x6B4FBB +) + +// GitLabSupportedEvents maps GitLab internal event names to handler functions +var GitLabSupportedEvents = map[string]func(bytes []byte) (*discordgo.MessageSend, error){ + "gl_push": glPushFn, + "gl_tag_push": glTagPushFn, + "gl_issue": glIssueFn, + "gl_note": glNoteFn, + "gl_merge_request": glMergeRequestFn, + "gl_pipeline": glPipelineFn, + "gl_release": glReleaseFn, + "gl_wiki": glWikiFn, + "gl_deployment": glDeploymentFn, + "gl_job": glJobFn, +} + +// GLUser represents a GitLab user in webhook payloads +type GLUser struct { + ID int `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func (u GLUser) AuthorEmbed() *discordgo.MessageEmbedAuthor { + return &discordgo.MessageEmbedAuthor{ + Name: u.Name + " (@" + u.Username + ")", + IconURL: u.AvatarURL, + } +} + +func (u GLUser) Link(baseURL string) string { + if baseURL == "" { + baseURL = "https://gitlab.com" + } + return "[" + strings.ReplaceAll(u.Username, " ", "%20") + "](" + baseURL + "/" + u.Username + ")" +} + +// GLProject represents a GitLab project in webhook payloads +type GLProject struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + WebURL string `json:"web_url"` + AvatarURL string `json:"avatar_url"` + GitSSHURL string `json:"git_ssh_url"` + GitHTTPURL string `json:"git_http_url"` + Namespace string `json:"namespace"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Homepage string `json:"homepage"` + URL string `json:"url"` + SSHURL string `json:"ssh_url"` + HTTPURL string `json:"http_url"` + Visibility string `json:"visibility_level,omitempty"` +} + +// GLRepository represents a GitLab repository section in webhook payloads +type GLRepository struct { + Name string `json:"name"` + URL string `json:"url"` + Description string `json:"description"` + Homepage string `json:"homepage"` +} diff --git a/webserver/logos/events/gitlab_issue.go b/webserver/logos/events/gitlab_issue.go new file mode 100644 index 0000000..aa19ea7 --- /dev/null +++ b/webserver/logos/events/gitlab_issue.go @@ -0,0 +1,76 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLIssueEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + } `json:"object_attributes"` +} + +func glIssueFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLIssueEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + body := gl.ObjectAttributes.Description + if len(body) > 996 { + body = body[:996] + "..." + } + if body == "" { + body = "No description available" + } + + color := colorGreen + if gl.ObjectAttributes.Action == "close" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: body, + Title: fmt.Sprintf("Issue %s on %s (#%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gl.ObjectAttributes.Action, + Inline: true, + }, + { + Name: "State", + Value: gl.ObjectAttributes.State, + Inline: true, + }, + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_misc.go b/webserver/logos/events/gitlab_misc.go new file mode 100644 index 0000000..a3858a1 --- /dev/null +++ b/webserver/logos/events/gitlab_misc.go @@ -0,0 +1,205 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLReleaseEvent struct { + ObjectKind string `json:"object_kind"` + Project GLProject `json:"project"` + Tag string `json:"tag"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Action string `json:"action"` + Assets struct { + Count int `json:"count"` + Links []struct { + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"links"` + } `json:"assets"` +} + +func glReleaseFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLReleaseEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + desc := gl.Description + if len(desc) > 996 { + desc = desc[:996] + "..." + } + if desc == "" { + desc = "No description" + } + + color := colorGreen + if gl.Action == "delete" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.URL, + Description: desc, + Title: fmt.Sprintf("Release %s on %s (%s)", gl.Action, gl.Project.PathWithNamespace, gl.Tag), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Name", + Value: gl.Name, + Inline: true, + }, + { + Name: "Tag", + Value: gl.Tag, + Inline: true, + }, + { + Name: "Assets", + Value: fmt.Sprintf("%d", gl.Assets.Count), + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLWikiEvent struct { + ObjectKind string `json:"object_kind"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + Wiki struct { + WebURL string `json:"web_url"` + } `json:"wiki"` + ObjectAttributes struct { + Title string `json:"title"` + Content string `json:"content"` + Format string `json:"format"` + Slug string `json:"slug"` + URL string `json:"url"` + Action string `json:"action"` + } `json:"object_attributes"` +} + +func glWikiFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLWikiEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorGreen + if gl.ObjectAttributes.Action == "delete" { + color = colorRed + } else if gl.ObjectAttributes.Action == "update" { + color = colorYellow + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Wiki Page %s on %s", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: true, + }, + { + Name: "Action", + Value: gl.ObjectAttributes.Action, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLDeploymentEvent struct { + ObjectKind string `json:"object_kind"` + Status string `json:"status"` + StatusChangedAt string `json:"status_changed_at"` + DeployableID int `json:"deployable_id"` + DeployableURL string `json:"deployable_url"` + Environment string `json:"environment"` + EnvironmentExternalURL string `json:"environment_external_url"` + Project GLProject `json:"project"` + User GLUser `json:"user"` + ShortSHA string `json:"short_sha"` + CommitURL string `json:"commit_url"` + CommitTitle string `json:"commit_title"` +} + +func glDeploymentFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLDeploymentEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.Status { + case "success": + color = colorGreen + case "failed": + color = colorRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Environment", + Value: gl.Environment, + Inline: true, + }, + { + Name: "Status", + Value: gl.Status, + Inline: true, + }, + { + Name: "Commit", + Value: fmt.Sprintf("[%s](%s)", gl.ShortSHA, gl.CommitURL), + Inline: true, + }, + } + + if gl.CommitTitle != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Commit Message", + Value: gl.CommitTitle, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Deployment %s on %s", gl.Status, gl.Project.PathWithNamespace), + Fields: fields, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_mr.go b/webserver/logos/events/gitlab_mr.go new file mode 100644 index 0000000..15db252 --- /dev/null +++ b/webserver/logos/events/gitlab_mr.go @@ -0,0 +1,87 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLMergeRequestEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + MergeStatus string `json:"merge_status"` + MergeWhenPipelineSucceeds bool `json:"merge_when_pipeline_succeeds"` + } `json:"object_attributes"` +} + +func glMergeRequestFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLMergeRequestEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + body := gl.ObjectAttributes.Description + if len(body) > 996 { + body = body[:996] + "..." + } + if body == "" { + body = "No description available" + } + + color := glColorPurple + if gl.ObjectAttributes.Action == "merge" { + color = colorGreen + } else if gl.ObjectAttributes.Action == "close" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: body, + Title: fmt.Sprintf("Merge Request %s on %s (!%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: false, + }, + { + Name: "Source → Target", + Value: gl.ObjectAttributes.SourceBranch + " → " + gl.ObjectAttributes.TargetBranch, + Inline: true, + }, + { + Name: "Merge Status", + Value: gl.ObjectAttributes.MergeStatus, + Inline: true, + }, + { + Name: "State", + Value: gl.ObjectAttributes.State, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_note.go b/webserver/logos/events/gitlab_note.go new file mode 100644 index 0000000..18003a6 --- /dev/null +++ b/webserver/logos/events/gitlab_note.go @@ -0,0 +1,99 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLNoteEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + Note string `json:"note"` + NoteableType string `json:"noteable_type"` + URL string `json:"url"` + } `json:"object_attributes"` + // One of these will be populated depending on what was commented on + Issue *struct { + IID int `json:"iid"` + Title string `json:"title"` + } `json:"issue,omitempty"` + MergeRequest *struct { + IID int `json:"iid"` + Title string `json:"title"` + } `json:"merge_request,omitempty"` + Commit *struct { + ID string `json:"id"` + Message string `json:"message"` + } `json:"commit,omitempty"` + Snippet *struct { + ID int `json:"id"` + Title string `json:"title"` + } `json:"snippet,omitempty"` +} + +func glNoteFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLNoteEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + note := gl.ObjectAttributes.Note + if len(note) > 996 { + note = note[:996] + "..." + } + + var target string + switch gl.ObjectAttributes.NoteableType { + case "Issue": + if gl.Issue != nil { + target = fmt.Sprintf("Issue #%d (%s)", gl.Issue.IID, gl.Issue.Title) + } else { + target = "Issue" + } + case "MergeRequest": + if gl.MergeRequest != nil { + target = fmt.Sprintf("MR !%d (%s)", gl.MergeRequest.IID, gl.MergeRequest.Title) + } else { + target = "Merge Request" + } + case "Commit": + if gl.Commit != nil { + shortID := gl.Commit.ID + if len(shortID) > 7 { + shortID = shortID[:7] + } + target = "Commit " + shortID + } else { + target = "Commit" + } + case "Snippet": + if gl.Snippet != nil { + target = fmt.Sprintf("Snippet #%d (%s)", gl.Snippet.ID, gl.Snippet.Title) + } else { + target = "Snippet" + } + default: + target = gl.ObjectAttributes.NoteableType + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: note, + Title: "Comment on " + target + " in " + gl.Project.PathWithNamespace, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_pipeline.go b/webserver/logos/events/gitlab_pipeline.go new file mode 100644 index 0000000..8689df1 --- /dev/null +++ b/webserver/logos/events/gitlab_pipeline.go @@ -0,0 +1,197 @@ +package events + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +type GLPipelineEvent struct { + ObjectKind string `json:"object_kind"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Ref string `json:"ref"` + Status string `json:"status"` + Source string `json:"source"` + Stages []string `json:"stages"` + Duration int `json:"duration"` + CreatedAt string `json:"created_at"` + FinishedAt string `json:"finished_at"` + } `json:"object_attributes"` + Builds []struct { + ID int `json:"id"` + Stage string `json:"stage"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } `json:"builds"` +} + +func glPipelineFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLPipelineEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.ObjectAttributes.Status { + case "success": + color = colorGreen + case "failed": + color = colorRed + case "canceled", "skipped": + color = colorDarkRed + } + + stages := strings.Join(gl.ObjectAttributes.Stages, " → ") + if stages == "" { + stages = "N/A" + } + + var buildSummary string + for _, b := range gl.Builds { + icon := "⏳" + switch b.Status { + case "success": + icon = "✅" + case "failed": + icon = "❌" + case "skipped": + icon = "⏭️" + case "canceled": + icon = "🚫" + case "running": + icon = "🔄" + } + buildSummary += fmt.Sprintf("%s %s (%s)\n", icon, b.Name, b.Stage) + } + + if len(buildSummary) > 1020 { + buildSummary = buildSummary[:1020] + "..." + } + if buildSummary == "" { + buildSummary = "No builds" + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.Project.WebURL + "/-/pipelines/" + fmt.Sprintf("%d", gl.ObjectAttributes.ID), + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Pipeline #%d %s on %s", gl.ObjectAttributes.ID, gl.ObjectAttributes.Status, gl.Project.PathWithNamespace), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Ref", + Value: gl.ObjectAttributes.Ref, + Inline: true, + }, + { + Name: "Status", + Value: gl.ObjectAttributes.Status, + Inline: true, + }, + { + Name: "Source", + Value: gl.ObjectAttributes.Source, + Inline: true, + }, + { + Name: "Stages", + Value: stages, + }, + { + Name: "Jobs", + Value: buildSummary, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLJobEvent struct { + ObjectKind string `json:"object_kind"` + Ref string `json:"ref"` + BuildID int `json:"build_id"` + BuildName string `json:"build_name"` + BuildStage string `json:"build_stage"` + BuildStatus string `json:"build_status"` + BuildDuration float64 `json:"build_duration"` + BuildFailureReason string `json:"build_failure_reason"` + PipelineID int `json:"pipeline_id"` + User GLUser `json:"user"` + Repository GLRepository `json:"repository"` + ProjectName string `json:"project_name"` +} + +func glJobFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLJobEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.BuildStatus { + case "success": + color = colorGreen + case "failed": + color = colorRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Job", + Value: gl.BuildName, + Inline: true, + }, + { + Name: "Stage", + Value: gl.BuildStage, + Inline: true, + }, + { + Name: "Status", + Value: gl.BuildStatus, + Inline: true, + }, + } + + if gl.BuildDuration > 0 { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Duration", + Value: fmt.Sprintf("%.1fs", gl.BuildDuration), + Inline: true, + }) + } + + if gl.BuildFailureReason != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Failure Reason", + Value: gl.BuildFailureReason, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Job #%d %s in %s", gl.BuildID, gl.BuildStatus, gl.ProjectName), + Fields: fields, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_push.go b/webserver/logos/events/gitlab_push.go new file mode 100644 index 0000000..e04e8e3 --- /dev/null +++ b/webserver/logos/events/gitlab_push.go @@ -0,0 +1,147 @@ +package events + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +type GLPushEvent struct { + ObjectKind string `json:"object_kind"` + Before string `json:"before"` + After string `json:"after"` + Ref string `json:"ref"` + CheckoutSHA string `json:"checkout_sha"` + UserID int `json:"user_id"` + UserName string `json:"user_name"` + UserUsername string `json:"user_username"` + UserEmail string `json:"user_email"` + UserAvatar string `json:"user_avatar"` + Project GLProject `json:"project"` + Repository GLRepository `json:"repository"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + Title string `json:"title"` + Timestamp string `json:"timestamp"` + URL string `json:"url"` + Author struct { + Name string `json:"name"` + Email string `json:"email"` + } `json:"author"` + } `json:"commits"` + TotalCommitsCount int `json:"total_commits_count"` +} + +func glPushFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLPushEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + var commitList string + for _, commit := range gl.Commits { + msg := commit.Message + if len(msg) > 100 { + msg = msg[:100] + "..." + } + commitList += fmt.Sprintf("%s [``%s``](%s) | %s\n", msg, commit.ID[:7], commit.URL, commit.Author.Name) + } + + if len(commitList) > 1024 { + commitList = commitList[:1024] + "..." + } + + if commitList == "" { + commitList = "No commits" + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.Project.WebURL, + Author: &discordgo.MessageEmbedAuthor{ + Name: gl.UserName + " (@" + gl.UserUsername + ")", + IconURL: gl.UserAvatar, + }, + Title: "Push on " + gl.Project.PathWithNamespace, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Branch", + Value: "**Ref:** " + gl.Ref, + }, + { + Name: "Commits", + Value: commitList, + }, + { + Name: "Pusher", + Value: fmt.Sprintf("[%s](%s/%s)", gl.UserUsername, strings.TrimSuffix(gl.Project.WebURL, "/"+gl.Project.PathWithNamespace), gl.UserUsername), + Inline: true, + }, + { + Name: "Total Commits", + Value: fmt.Sprintf("%d", gl.TotalCommitsCount), + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLTagPushEvent struct { + ObjectKind string `json:"object_kind"` + Before string `json:"before"` + After string `json:"after"` + Ref string `json:"ref"` + CheckoutSHA string `json:"checkout_sha"` + UserID int `json:"user_id"` + UserName string `json:"user_name"` + UserUsername string `json:"user_username"` + UserAvatar string `json:"user_avatar"` + Project GLProject `json:"project"` + Repository GLRepository `json:"repository"` +} + +func glTagPushFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLTagPushEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.Project.WebURL, + Author: &discordgo.MessageEmbedAuthor{ + Name: gl.UserName + " (@" + gl.UserUsername + ")", + IconURL: gl.UserAvatar, + }, + Title: "Tag Push on " + gl.Project.PathWithNamespace, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Ref", + Value: gl.Ref, + }, + { + Name: "Checkout SHA", + Value: gl.CheckoutSHA, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/internal_common__.go b/webserver/logos/events/internal_common__.go index b762079..b344134 100644 --- a/webserver/logos/events/internal_common__.go +++ b/webserver/logos/events/internal_common__.go @@ -45,6 +45,8 @@ var SupportedEvents = map[string]func(bytes []byte) (*discordgo.MessageSend, err "team": teamFn, "fork": forkFn, "page_build": pageBuildFn, + "code_scanning_alert": codeScanningAlertFn, + "secret_scanning_alert": secretScanningAlertFn, } type User struct { diff --git a/webserver/logos/events/secret_scanning_alert.go b/webserver/logos/events/secret_scanning_alert.go new file mode 100644 index 0000000..8cd9133 --- /dev/null +++ b/webserver/logos/events/secret_scanning_alert.go @@ -0,0 +1,107 @@ +package events + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" +) + +type SecretScanningAlertEvent struct { + Action string `json:"action"` + Repo Repository `json:"repository"` + Sender User `json:"sender"` + Alert struct { + Number int `json:"number"` + State string `json:"state"` + SecretType string `json:"secret_type"` + SecretTypeDisplayName string `json:"secret_type_display_name"` + Secret string `json:"secret"` // redacted by GitHub + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + Resolution string `json:"resolution"` + ResolvedAt string `json:"resolved_at"` + ResolvedBy User `json:"resolved_by"` + PushProtectionBypassed bool `json:"push_protection_bypassed"` + PushProtectionBypassedBy User `json:"push_protection_bypassed_by"` + } `json:"alert"` +} + +func secretScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { + var gh SecretScanningAlertEvent + + err := json.Unmarshal(bytes, &gh) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorRed + switch gh.Action { + case "resolved": + color = colorGreen + case "revoked": + color = colorDarkRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gh.Action, + Inline: true, + }, + { + Name: "State", + Value: gh.Alert.State, + Inline: true, + }, + { + Name: "Secret Type", + Value: gh.Alert.SecretTypeDisplayName, + Inline: true, + }, + } + + if gh.Alert.Resolution != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Resolution", + Value: gh.Alert.Resolution, + Inline: true, + }) + } + + if gh.Alert.ResolvedBy.Login != "" { + resolveInfo := gh.Alert.ResolvedBy.Link() + if gh.Alert.ResolvedAt != "" { + if t, err := time.Parse(time.RFC3339, gh.Alert.ResolvedAt); err == nil { + resolveInfo += " at " + t.Format("2006-01-02 15:04 UTC") + } + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Resolved By", + Value: resolveInfo, + }) + } + + if gh.Alert.PushProtectionBypassed { + bypassInfo := "Yes" + if gh.Alert.PushProtectionBypassedBy.Login != "" { + bypassInfo = "By " + gh.Alert.PushProtectionBypassedBy.Link() + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Push Protection Bypassed", + Value: bypassInfo, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gh.Alert.HTMLURL, + Author: gh.Sender.AuthorEmbed(), + Title: fmt.Sprintf("Secret Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Fields: fields, + }, + }, + }, nil +} diff --git a/webserver/logos/logos.go b/webserver/logos/logos.go index 54413f1..55ffc70 100644 --- a/webserver/logos/logos.go +++ b/webserver/logos/logos.go @@ -1,3 +1,3 @@ -// Logos (Xenoblade Chronicles 2), the core component that provides logic to Git Logs, +// Logos (Xenoblade Chronicles 2), the core component that provides logic to Octoflow, // removing the useless crud package logos diff --git a/webserver/ontos/api.go b/webserver/ontos/api.go index f302fdb..15bda17 100644 --- a/webserver/ontos/api.go +++ b/webserver/ontos/api.go @@ -12,6 +12,7 @@ import ( // Precomputed values var eventList []string +var glEventList []string func init() { eventList = []string{} @@ -19,6 +20,12 @@ func init() { for event := range events.SupportedEvents { eventList = append(eventList, event) } + + glEventList = []string{} + + for event := range events.GitLabSupportedEvents { + glEventList = append(glEventList, event) + } } // This endpoint can only be used if the discordgo websocket is open @@ -42,15 +49,22 @@ func ApiStats(w http.ResponseWriter, r *http.Request) { } func ApiEventsListView(w http.ResponseWriter, r *http.Request) { - events := []string{} + ghEvents := []string{} for _, event := range eventList { - events = append(events, "- "+event) + ghEvents = append(ghEvents, "- "+event) } - w.Write([]byte(strings.Join(events, "\n"))) + glEvents := []string{} + + for _, event := range glEventList { + glEvents = append(glEvents, "- "+event) + } + + w.Write([]byte("GitHub Events:\n" + strings.Join(ghEvents, "\n") + "\n\nGitLab Events:\n" + strings.Join(glEvents, "\n"))) } func ApiEventsCommaSepView(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(strings.Join(eventList, ","))) + w.Write([]byte("github:" + strings.Join(eventList, ",") + "\ngitlab:" + strings.Join(glEventList, ","))) } + diff --git a/webserver/ontos/ontos.go b/webserver/ontos/ontos.go index bcbab05..2f37be3 100644 --- a/webserver/ontos/ontos.go +++ b/webserver/ontos/ontos.go @@ -140,7 +140,8 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { var secret string var broken bool - err := state.Pool.QueryRow(state.Context, "SELECT secret, broken FROM "+state.TableWebhooks+" WHERE id = $1", id).Scan(&secret, &broken) + var provider string + err := state.Pool.QueryRow(state.Context, "SELECT secret, broken, COALESCE(provider, 'github') FROM "+state.TableWebhooks+" WHERE id = $1", id).Scan(&secret, &broken, &provider) if err != nil { w.WriteHeader(404) @@ -150,7 +151,8 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { if broken { w.WriteHeader(500) - w.Write([]byte("This webhook is marked as broken!")) + w.Write([]byte("This webhook is marked as broken!")) + return } var guildId string @@ -169,6 +171,16 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { bodyBytes, _ = io.ReadAll(r.Body) } + // Route to the appropriate provider handler + switch provider { + case "gitlab": + handleGitLabWebhook(w, r, bodyBytes, id, logId, guildId, secret) + default: + handleGitHubWebhook(w, r, bodyBytes, id, logId, guildId, secret) + } +} + +func handleGitHubWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byte, id, logId, guildId, secret string) { var signature = r.Header.Get("X-Hub-Signature-256") mac := hmac.New(sha256.New, []byte(secret)) @@ -189,7 +201,7 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { var rw events.RepoWrapper - err = json.Unmarshal(bodyBytes, &rw) + err := json.Unmarshal(bodyBytes, &rw) if err != nil { state.Logger.Error("JSON unmarshal error", zap.Error(err)) @@ -212,28 +224,128 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { return } + w.WriteHeader(http.StatusAccepted) w.Write([]byte( - "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n", + "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n" + + "Going to process webhook event now: " + header + "\n", )) + go pneuma.HandleEvents( + bodyBytes, + &rw, + repoID, + logId, + header, + id, + guildId, + "github", + ) +} + +func handleGitLabWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byte, id, logId, guildId, secret string) { + // GitLab uses X-Gitlab-Token for verification (simple token comparison) + token := r.Header.Get("X-Gitlab-Token") + + if token != secret { + w.WriteHeader(401) + w.Write([]byte("This request has a bad token, recheck the secret token in your GitLab webhook settings")) + return + } + + gitlabEvent := r.Header.Get("X-Gitlab-Event") + + if gitlabEvent == "" { + w.WriteHeader(400) + w.Write([]byte("Missing X-Gitlab-Event header")) + return + } + + // Parse the GitLab payload to get project info + var glPayload struct { + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + WebURL string `json:"web_url"` + } `json:"project"` + ObjectKind string `json:"object_kind"` + } + + if err := json.Unmarshal(bodyBytes, &glPayload); err != nil { + state.Logger.Error("GitLab JSON unmarshal error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("This request is not valid JSON: " + err.Error())) + return + } + + repoFullName := strings.ToLower(glPayload.Project.PathWithNamespace) + eventName := mapGitLabEventName(gitlabEvent, glPayload.ObjectKind) + + // Get repo_name from database + var repoName string + var repoID string + err := state.Pool.QueryRow(state.Context, "SELECT id, repo_name FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", repoFullName, id).Scan(&repoID, &repoName) + + if err != nil { + state.Logger.Warn("This repository is not configured on git-logs, ignoring", zap.Error(err), zap.String("repoName", repoFullName), zap.String("webhookID", id)) + w.WriteHeader(http.StatusPartialContent) + w.Write([]byte("This repository is not configured on git-logs, ignoring")) + return + } + + // Create a synthetic RepoWrapper for GitLab + var rw events.RepoWrapper + rw.Repo.FullName = repoFullName + rw.Repo.HTMLURL = glPayload.Project.WebURL + rw.Action = glPayload.ObjectKind + w.WriteHeader(http.StatusAccepted) - w.Write([]byte("Going to process webhook event now: " + header)) + w.Write([]byte( + "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n" + + "Going to process GitLab webhook event now: " + eventName + "\n", + )) go pneuma.HandleEvents( bodyBytes, &rw, repoID, logId, - header, + eventName, id, guildId, + "gitlab", ) +} +// mapGitLabEventName maps GitLab event header values to internal event names +func mapGitLabEventName(headerEvent, objectKind string) string { + switch headerEvent { + case "Push Hook": + return "gl_push" + case "Tag Push Hook": + return "gl_tag_push" + case "Issue Hook": + return "gl_issue" + case "Note Hook": + return "gl_note" + case "Merge Request Hook": + return "gl_merge_request" + case "Pipeline Hook": + return "gl_pipeline" + case "Job Hook": + return "gl_job" + case "Deployment Hook": + return "gl_deployment" + case "Release Hook": + return "gl_release" + case "Wiki Page Hook": + return "gl_wiki" + default: + return "gl_" + strings.ReplaceAll(strings.ToLower(headerEvent), " ", "_") + } } func IndexPage(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`This is the API for the Git Logs service. It handles webhooks from GitHub and sends them to Discord. + w.Write([]byte(`This is the API for the OctoFlow service. It handles webhooks from GitHub and as well as GitLab and sends them to Discord. You may also be looking for: @@ -244,7 +356,8 @@ You may also be looking for: - Webhooks: kittycat?id=ID - Get Webhook Info: GET kittycat?id=ID - Handle Github Webhook: POST kittycat?id=ID - + - Handle GitLab Webhook: POST kittycat?id=ID + `)) w.Write([]byte(`[is_embedded]: ` + strconv.FormatBool(state.IsEmbedded) + "\n")) @@ -272,3 +385,15 @@ func AuditEvent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(strings.Join(log, "\n"))) } + +func HealthCheck(w http.ResponseWriter, r *http.Request) { + err := state.Pool.Ping(state.Context) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("unhealthy: " + err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/webserver/pneuma/pneuma.go b/webserver/pneuma/pneuma.go index c3f7c43..5c16c95 100644 --- a/webserver/pneuma/pneuma.go +++ b/webserver/pneuma/pneuma.go @@ -130,12 +130,13 @@ func HandleEvents( header string, webhookId string, guildId string, + provider string, ) { // Ensure one at a time l := state.MapMutex.Lock(webhookId) defer l.Unlock() - updateLogEntries(logId, webhookId, guildId, "Processing event: "+header, "repoName="+rw.Repo.FullName, "webhookID="+webhookId, "event="+header, "logId="+logId) + updateLogEntries(logId, webhookId, guildId, "Processing event: "+header, "provider="+provider, "repoName="+rw.Repo.FullName, "webhookID="+webhookId, "event="+header, "logId="+logId) // Check event modifiers modres, err := eventmodifiers.CheckEventAllowed(webhookId, repoId, header) @@ -169,7 +170,7 @@ func HandleEvents( rows, err := state.Pool.Query(state.Context, "SELECT channel_id FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", strings.ToLower(rw.Repo.FullName), webhookId) if err != nil { - updateLogEntries(logId, "Channel id fetch error: acl="+modres.ACLFail, "error="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Channel id fetch error: acl="+modres.ACLFail, "error="+err.Error()) state.Logger.Error("Channel id fetch error", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("logId", logId)) return } @@ -182,7 +183,7 @@ func HandleEvents( err = rows.Scan(&channelId) if err != nil { - updateLogEntries(logId, "Channel id scan error: acl="+modres.ACLFail, "error="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Channel id scan error: acl="+modres.ACLFail, "error="+err.Error()) state.Logger.Error("Channel id scan error", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("logId", logId)) continue } @@ -196,7 +197,15 @@ func HandleEvents( return } - evtFn, ok := events.SupportedEvents[header] + // Try provider-specific supported events first, then fall back + var evtFn func([]byte) (*discordgo.MessageSend, error) + var ok bool + + if provider == "gitlab" { + evtFn, ok = events.GitLabSupportedEvents[header] + } else { + evtFn, ok = events.SupportedEvents[header] + } var messageSend *discordgo.MessageSend @@ -211,18 +220,81 @@ func HandleEvents( return } + // Build a cleaner fallback embed instead of dumping raw map values + providerLabel := "GitHub" + if provider == "gitlab" { + providerLabel = "GitLab" + } + var embed = discordgo.MessageEmbed{ - Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), - Fields: []*discordgo.MessageEmbedField{}, + Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), + Color: 0x8b949e, // neutral gray for unknown events + Footer: &discordgo.MessageEmbedFooter{ + Text: providerLabel + " · Unhandled Event", + }, } + // Extract meaningful top-level fields, skip complex nested objects + var embedFields []*discordgo.MessageEmbedField for k, v := range fields { - embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ - Name: cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")), - Value: cases.Title(language.English).String(strings.ReplaceAll(fmt.Sprintf("%v", v), "_", " ")), + // Skip large nested objects that render as ugly map[...] dumps + switch v.(type) { + case map[string]any: + // For known important nested objects, extract key info + nested := v.(map[string]any) + if k == "sender" || k == "user" { + if login, ok := nested["login"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "User", + Value: login, + Inline: true, + }) + } else if name, ok := nested["name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "User", + Value: name, + Inline: true, + }) + } + } else if k == "repository" || k == "project" { + if fullName, ok := nested["full_name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "Repository", + Value: fullName, + Inline: true, + }) + } else if name, ok := nested["name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "Repository", + Value: name, + Inline: true, + }) + } + } + // Skip other nested objects entirely + continue + case []any: + // Skip arrays (they render terribly) + continue + } + + val := fmt.Sprintf("%v", v) + if val == "" || val == "" { + continue + } + if len(val) > 200 { + val = val[:200] + "..." + } + + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")), + Value: val, + Inline: true, }) } + embed.Fields = embedFields + messageSend = &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{&embed}, } @@ -238,6 +310,12 @@ func HandleEvents( } } + if messageSend == nil { + updateLogEntries(logId, webhookId, guildId, "Error: event handler returned nil message") + state.Logger.Error("Event handler returned nil messageSend", zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("event", header), zap.String("logId", logId)) + return + } + for i, embed := range messageSend.Embeds { messageSend.Embeds[i] = applyEmbedLimits(embed) } @@ -251,7 +329,7 @@ func HandleEvents( Content: "Could not send event " + header + " to channel: <#" + channelId + ">:" + err.Error(), }) - updateLogEntries(logId, "Could not send event "+header+" to channel: channelId="+channelId, "err="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Could not send event "+header+" to channel: channelId="+channelId, "err="+err.Error()) } } } diff --git a/webserver/server.go b/webserver/server.go index 300a7bd..ec9461d 100644 --- a/webserver/server.go +++ b/webserver/server.go @@ -1,35 +1,72 @@ package main import ( + "context" "net/http" + "os" + "os/signal" + "syscall" "time" - "github.com/git-logs/client/webserver/ontos" - "github.com/git-logs/client/webserver/state" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/infinitybotlist/eureka/zapchi" + "go.uber.org/zap" + + "github.com/git-logs/client/webserver/ontos" + "github.com/git-logs/client/webserver/state" ) func main() { state.Setup() - defer state.Close() - r := chi.NewMux() + r := chi.NewRouter() - r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api"), middleware.Recoverer, middleware.RealIP, middleware.RequestID, middleware.Timeout(60*time.Second)) + r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api")) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(middleware.RequestID) + r.Use(middleware.Timeout(60 * time.Second)) - // Webhook route - r.Get("/kittycat", ontos.GetWebhookRoute) - r.Post("/kittycat", ontos.HandleWebhookRoute) r.HandleFunc("/", ontos.IndexPage) - r.HandleFunc("/audit", ontos.AuditEvent) // API r.HandleFunc("/api/counts", ontos.ApiStats) r.HandleFunc("/api/events/listview", ontos.ApiEventsListView) r.HandleFunc("/api/events/csview", ontos.ApiEventsCommaSepView) + r.HandleFunc("/health", ontos.HealthCheck) + + // KittyCat (webhook route) + r.Get("/kittycat", ontos.GetWebhookRoute) + r.Post("/kittycat", ontos.HandleWebhookRoute) + r.HandleFunc("/audit", ontos.AuditEvent) + + srv := &http.Server{ + Addr: state.Config.Port, + Handler: r, + } + + // Graceful shutdown channel + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + go func() { + state.Logger.Info("Starting webserver on " + state.Config.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + state.Logger.Fatal("Failed to start server", zap.Error(err)) + } + }() + + <-done + state.Logger.Info("Webserver is shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + state.Logger.Fatal("Server shutdown failed", zap.Error(err)) + } - http.ListenAndServe(state.Config.Port, r) + state.Logger.Info("Webserver gracefully stopped") } diff --git a/webserver/state/setup.go b/webserver/state/setup.go index e6775f5..ca8734e 100644 --- a/webserver/state/setup.go +++ b/webserver/state/setup.go @@ -90,9 +90,7 @@ func Setup() { Logger.Info("Connected to all services successfully") - if v := os.Getenv("APPLY_MIGRATIONS"); v == "true" { - ApplyMigrations() - } + ApplyMigrations() } // Must be called when embedding, PrepareForEmbedding creates the table names from config and may do other setup @@ -181,6 +179,8 @@ func ApplyMigrations() { ALTER TABLE `+TableWebhookLogs+` ADD COLUMN IF NOT EXISTS guild_id TEXT NOT NULL REFERENCES `+TableGuilds+` (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE `+TableWebhooks+` ADD COLUMN IF NOT EXISTS broken BOOLEAN NOT NULL DEFAULT false; + ALTER TABLE `+TableWebhooks+` ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'github'; + ALTER TABLE `+TableWebhooks+` ALTER COLUMN provider DROP NOT NULL; `) if err != nil { From d81b6c6c9ff6161e9836d2155b4c288883c485bd Mon Sep 17 00:00:00 2001 From: Ranveer Soni Date: Thu, 14 May 2026 12:12:59 +0530 Subject: [PATCH 2/3] quite a lot of things --- ...6eb0e8848b17e46b244d7ac96c16db50865f4.json | 20 - ...ef5c9af2951d6e72442831816e4872ee62922.json | 40 -- bot/Cargo.lock | 2 +- bot/Cargo.toml | 1 + bot/src/commands/delhook.rs | 3 +- bot/src/commands/delrepo.rs | 3 +- bot/src/commands/edithook.rs | 19 +- bot/src/commands/editrepo.rs | 26 +- bot/src/commands/list.rs | 121 ++++--- bot/src/commands/mod.rs | 77 +--- bot/src/commands/newhook.rs | 62 ++-- bot/src/commands/newrepo.rs | 48 +-- bot/src/commands/resetsecret.rs | 7 +- bot/src/commands/setrepochannel.rs | 3 +- bot/src/config.rs | 2 +- bot/src/help.rs | 1 - bot/src/main.rs | 78 ++-- schema.sql | 2 +- .../logos/events/branch_protection_rule.go | 6 +- webserver/logos/events/check_run.go | 68 ++-- webserver/logos/events/check_suite.go | 64 ++-- webserver/logos/events/code_scanning_alert.go | 2 +- webserver/logos/events/commit_comment.go | 43 +-- webserver/logos/events/create.go | 46 +-- webserver/logos/events/delete.go | 38 +- webserver/logos/events/dependabot_alert.go | 187 +++++----- webserver/logos/events/deployment.go | 75 ++-- webserver/logos/events/deployment_status.go | 2 +- webserver/logos/events/discussion.go | 22 +- webserver/logos/events/discussion_comment.go | 8 +- webserver/logos/events/embed_delivery.go | 215 +++++++++++ webserver/logos/events/fork.go | 38 +- webserver/logos/events/gitlab_common.go | 21 +- webserver/logos/events/gitlab_issue.go | 13 +- webserver/logos/events/gitlab_misc.go | 6 +- webserver/logos/events/gitlab_mr.go | 45 +-- webserver/logos/events/gitlab_note.go | 28 +- webserver/logos/events/gitlab_pipeline.go | 4 +- webserver/logos/events/gitlab_push.go | 36 +- webserver/logos/events/internal_common__.go | 150 ++++++-- webserver/logos/events/issue_comment.go | 68 ++-- webserver/logos/events/issues.go | 50 +-- webserver/logos/events/page_build.go | 80 ++-- webserver/logos/events/public.go | 25 +- webserver/logos/events/pull_request.go | 69 ++-- .../events/pull_request_review_comment.go | 62 ++-- webserver/logos/events/push.go | 64 ++-- webserver/logos/events/release.go | 55 +-- webserver/logos/events/repository.go | 41 ++- .../events/repository_vulnerability_alert.go | 342 ++++++++++++++++++ .../logos/events/secret_scanning_alert.go | 2 +- webserver/logos/events/star.go | 25 +- webserver/logos/events/status.go | 64 ++-- webserver/logos/events/watch.go | 29 +- webserver/logos/events/workflow_job.go | 82 ++--- webserver/logos/events/workflow_run.go | 89 +++-- webserver/ontos/ontos.go | 19 +- webserver/pneuma/fallback_embed.go | 202 +++++++++++ webserver/pneuma/pneuma.go | 86 ++--- webserver/server.go | 60 +-- 60 files changed, 1889 insertions(+), 1257 deletions(-) delete mode 100644 bot/.sqlx/query-8f8092ef058a05cee47e23d2cd66eb0e8848b17e46b244d7ac96c16db50865f4.json delete mode 100644 bot/.sqlx/query-dbbde0e577ef9d9b704c7b42a67ef5c9af2951d6e72442831816e4872ee62922.json create mode 100644 webserver/logos/events/embed_delivery.go create mode 100644 webserver/logos/events/repository_vulnerability_alert.go create mode 100644 webserver/pneuma/fallback_embed.go diff --git a/bot/.sqlx/query-8f8092ef058a05cee47e23d2cd66eb0e8848b17e46b244d7ac96c16db50865f4.json b/bot/.sqlx/query-8f8092ef058a05cee47e23d2cd66eb0e8848b17e46b244d7ac96c16db50865f4.json deleted file mode 100644 index f40f361..0000000 --- a/bot/.sqlx/query-8f8092ef058a05cee47e23d2cd66eb0e8848b17e46b244d7ac96c16db50865f4.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO webhooks (id, guild_id, comment, secret, broken, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Bool", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "8f8092ef058a05cee47e23d2cd66eb0e8848b17e46b244d7ac96c16db50865f4" -} diff --git a/bot/.sqlx/query-dbbde0e577ef9d9b704c7b42a67ef5c9af2951d6e72442831816e4872ee62922.json b/bot/.sqlx/query-dbbde0e577ef9d9b704c7b42a67ef5c9af2951d6e72442831816e4872ee62922.json deleted file mode 100644 index 8ce71c2..0000000 --- a/bot/.sqlx/query-dbbde0e577ef9d9b704c7b42a67ef5c9af2951d6e72442831816e4872ee62922.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, broken, comment, created_at FROM webhooks WHERE guild_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "broken", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "comment", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "dbbde0e577ef9d9b704c7b42a67ef5c9af2951d6e72442831816e4872ee62922" -} diff --git a/bot/Cargo.lock b/bot/Cargo.lock index b456402..8bb26c3 100644 --- a/bot/Cargo.lock +++ b/bot/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "botox" version = "0.1.0" -source = "git+https://github.com/infinitybotlist/botox?branch=main#064fc80fa7065422266b1d562587dac8164c4185" +source = "git+https://github.com/infinitybotlist/botox?branch=main#ebc5dea74571d858f0b097df1a4e5f6a23c63cd1" dependencies = [ "futures", "futures-util", diff --git a/bot/Cargo.toml b/bot/Cargo.toml index 992bf84..fac43b0 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -2,6 +2,7 @@ name = "bot" version = "0.1.0" edition = "2021" +default-run = "bot" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/bot/src/commands/delhook.rs b/bot/src/commands/delhook.rs index 44aae65..6bfe346 100644 --- a/bot/src/commands/delhook.rs +++ b/bot/src/commands/delhook.rs @@ -4,8 +4,7 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn delhook( ctx: Context<'_>, - #[description = "The webhook ID"] - #[autocomplete = "super::autocomplete_webhooks"] + #[description = "Webhook ID from `/list`"] id: String, ) -> Result<(), Error> { let data = ctx.data(); diff --git a/bot/src/commands/delrepo.rs b/bot/src/commands/delrepo.rs index 41f842c..e964f7f 100644 --- a/bot/src/commands/delrepo.rs +++ b/bot/src/commands/delrepo.rs @@ -4,8 +4,7 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn delrepo( ctx: Context<'_>, - #[description = "The repo ID"] - #[autocomplete = "super::autocomplete_repos"] + #[description = "Repo ID from `/list`"] id: String, ) -> Result<(), Error> { let data = ctx.data(); diff --git a/bot/src/commands/edithook.rs b/bot/src/commands/edithook.rs index e530ebc..a53cfef 100644 --- a/bot/src/commands/edithook.rs +++ b/bot/src/commands/edithook.rs @@ -1,27 +1,20 @@ +use super::webhook_provider::WebhookProvider; use crate::{Context, Error}; /// Edits a webhook #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn edithook( ctx: Context<'_>, - #[description = "The webhook ID"] - #[autocomplete = "super::autocomplete_webhooks"] + #[description = "Webhook ID from `/list`"] id: String, #[description = "The comment for the webhook"] comment: Option, #[description = "Is the webhook broken?"] broken: Option, #[description = "The new secret for the webhook"] webhook_secret: Option, - #[description = "Provider: github or gitlab"] provider: Option, + #[description = "Where this webhook receives events from"] provider: Option, ) -> Result<(), Error> { - let data = ctx.data(); + ctx.defer().await?; - // Validate provider if provided - if let Some(ref p) = provider { - let p_lower = p.to_lowercase(); - if p_lower != "github" && p_lower != "gitlab" { - ctx.say("Invalid provider! Use `github` or `gitlab`").await?; - return Ok(()); - } - } + let data = ctx.data(); // Validate secret isn't too short if provided if let Some(ref s) = webhook_secret { @@ -101,7 +94,7 @@ pub async fn edithook( if let Some(provider) = provider { sqlx::query!( "UPDATE webhooks SET provider = $1 WHERE id = $2 AND guild_id = $3", - provider.to_lowercase(), + provider.as_db(), &id, &ctx.guild_id().unwrap().to_string() ) diff --git a/bot/src/commands/editrepo.rs b/bot/src/commands/editrepo.rs index 47abea7..7e37607 100644 --- a/bot/src/commands/editrepo.rs +++ b/bot/src/commands/editrepo.rs @@ -4,12 +4,13 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn editrepo( ctx: Context<'_>, - #[description = "The repo ID"] - #[autocomplete = "super::autocomplete_repos"] + #[description = "Repo ID from `/list`"] id: String, #[description = "The new repo owner or organization"] owner: String, #[description = "The new repo name"] name: String, -) -> Result<(), Error> { +) -> Result<(), Error> { + ctx.defer().await?; + let data = ctx.data(); let repo_name = (owner+"/"+&name).to_lowercase(); @@ -22,25 +23,6 @@ pub async fn editrepo( return Err("That repo doesn't exist!".into()); } - let provider_query = sqlx::query!( - "SELECT webhooks.provider FROM repos JOIN webhooks ON repos.webhook_id = webhooks.id WHERE repos.id = $1 AND repos.guild_id = $2", - &id, &ctx.guild_id().unwrap().to_string() - ).fetch_one(&data.pool).await?; - - let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); - let client = reqwest::Client::new(); - let exists = if provider == "gitlab" { - let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); - client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false) - } else { - let url = format!("https://api.github.com/repos/{}", repo_name); - client.get(&url).header("User-Agent", "OctoFlow-Discord-Bot").send().await.map(|r| r.status().is_success()).unwrap_or(false) - }; - - if !exists { - return Err("That repository could not be found! Make sure it exists and is public.".into()); - } - sqlx::query!( "UPDATE repos SET repo_name = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", &repo_name, ctx.author().id.to_string(), &id, &ctx.guild_id().unwrap().to_string() diff --git a/bot/src/commands/list.rs b/bot/src/commands/list.rs index 3106752..eef5bcf 100644 --- a/bot/src/commands/list.rs +++ b/bot/src/commands/list.rs @@ -1,77 +1,114 @@ use log::error; -use poise::{serenity_prelude::CreateEmbed, CreateReply}; +use poise::serenity_prelude::{Colour, CreateEmbed, CreateEmbedFooter}; +use poise::CreateReply; -use crate::{Context, Error, config}; +use crate::{config, Context, Error}; + +/// Discord allows at most 10 embeds per message. +const MAX_EMBEDS_PER_MESSAGE: usize = 10; /// Lists all webhooks in a guild with their respective repos and channel IDs #[poise::command(slash_command, prefix_command, guild_only, required_permissions = "MANAGE_GUILD")] -pub async fn list( - ctx: Context<'_>, -) -> Result<(), Error> { +pub async fn list(ctx: Context<'_>) -> Result<(), Error> { + ctx.defer().await?; + let data = ctx.data(); + let guild_id = ctx.guild_id().unwrap().to_string(); - // Check if the guild exists on our DB let guild = sqlx::query!( "SELECT COUNT(1) FROM guilds WHERE id = $1", - &ctx.guild_id().unwrap().to_string() + &guild_id ) .fetch_one(&data.pool) .await?; - + if guild.count.unwrap_or_default() == 0 { - // If it doesn't, create it sqlx::query!( "INSERT INTO guilds (id) VALUES ($1)", - &ctx.guild_id().unwrap().to_string() + &guild_id ) .execute(&data.pool) .await?; ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - } else { - // Get all webhooks - let webhooks = sqlx::query!( - "SELECT id, broken, comment, created_at, COALESCE(provider, 'github') as provider FROM webhooks WHERE guild_id = $1", - &ctx.guild_id().unwrap().to_string() - ) - .fetch_all(&data.pool) - .await; - - match webhooks { - Ok(webhooks) => { - if webhooks.is_empty() { - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - return Ok(()); - } + return Ok(()); + } - let mut cr = CreateReply::default() - .content("Here are all the webhooks in this guild:"); + let webhooks = sqlx::query!( + "SELECT id, broken, comment, created_at, COALESCE(provider, 'github') as provider FROM webhooks WHERE guild_id = $1", + &guild_id + ) + .fetch_all(&data.pool) + .await; - let api_url = config::CONFIG.api_url[0].clone(); + match webhooks { + Ok(webhooks) => { + if webhooks.is_empty() { + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + return Ok(()); + } - for webhook in webhooks { - let webhook_id = webhook.id; - let provider = webhook.provider.unwrap_or_else(|| "github".to_string()); + let api_url = config::CONFIG.api_url[0].clone(); + let chunks: Vec<_> = webhooks.chunks(MAX_EMBEDS_PER_MESSAGE).collect(); + let total_parts = chunks.len(); + + for (i, chunk) in chunks.into_iter().enumerate() { + let content = if total_parts == 1 { + "Here are all the webhooks in this guild:".to_string() + } else if i == 0 { + format!( + "Here are all the webhooks in this guild (part 1 of {}):", + total_parts + ) + } else { + format!( + "Webhooks continued (part {} of {}):", + i + 1, + total_parts + ) + }; + + let mut cr = CreateReply::default().content(content); + + for webhook in chunk { + let webhook_id = webhook.id.clone(); + let provider = webhook.provider.as_deref().unwrap_or("github"); let provider_label = if provider == "gitlab" { "GitLab" } else { "GitHub" }; + let status_note = if webhook.broken { + "Marked broken — use `/resetsecret` or recreate the hook if deliveries fail." + } else { + "Receiving events normally." + }; + let created_ts = webhook.created_at.timestamp(); + let broken_label = if webhook.broken { "Yes" } else { "No" }; cr = cr.embed( CreateEmbed::new() - .title(format!("Webhook \"{}\"", webhook.comment)) - .field("Webhook ID", webhook_id.clone(), false) - .field("Hook URL", format!("`{}/kittycat?id={}`", api_url, webhook_id), false) - .field("Provider", provider_label.to_string(), true) - .field("Marked as Broken", format!("{}", webhook.broken), true) - .field("Created at", webhook.created_at.to_string(), true) + .colour(Colour::from_rgb(88, 101, 242)) + .title(webhook.comment.clone()) + .description(format!( + "**{}** webhook · {}\nCreated ", + provider_label, status_note, created_ts + )) + .field("Webhook ID", format!("`{}`", webhook_id), true) + .field("Broken?", broken_label, true) + .field( + "Endpoint", + format!("`{}/kittycat?id={}`", api_url, webhook_id), + false, + ) + .footer(CreateEmbedFooter::new("OctoFlow · manage webhooks with /newhook and /edithook")), ); - }; + } ctx.send(cr).await?; - }, - Err(e) => { - error!("Error fetching webhooks: {:?}", e); - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; } } + Err(e) => { + error!("Error fetching webhooks: {:?}", e); + ctx.say("Could not load webhooks from the database. Try again in a moment.") + .await?; + } } Ok(()) diff --git a/bot/src/commands/mod.rs b/bot/src/commands/mod.rs index 71f3f5e..8359fac 100644 --- a/bot/src/commands/mod.rs +++ b/bot/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod list; +pub mod webhook_provider; pub mod newhook; pub mod edithook; pub mod newrepo; @@ -7,79 +8,3 @@ pub mod delhook; pub mod delrepo; pub mod setrepochannel; pub mod resetsecret; - -use crate::{Context, Error}; - -pub(crate) async fn autocomplete_webhooks<'a>( - ctx: Context<'a>, - partial: &'a str, -) -> impl Iterator + 'a { - let data = ctx.data(); - let guild_id = match ctx.guild_id() { - Some(id) => id.to_string(), - None => return vec![].into_iter(), - }; - - struct WebhookChoice { - id: String, - comment: String, - } - - let webhooks = match sqlx::query_as!( - WebhookChoice, - "SELECT id, comment FROM webhooks WHERE guild_id = $1", - guild_id - ) - .fetch_all(&data.pool) - .await { - Ok(v) => v, - Err(_) => return vec![].into_iter(), - }; - - webhooks - .into_iter() - .filter(move |w| { - w.comment.to_lowercase().contains(&partial.to_lowercase()) - || w.id.to_lowercase().contains(&partial.to_lowercase()) - }) - .map(|w| w.id) - .collect::>() - .into_iter() -} - -pub(crate) async fn autocomplete_repos<'a>( - ctx: Context<'a>, - partial: &'a str, -) -> impl Iterator + 'a { - let data = ctx.data(); - let guild_id = match ctx.guild_id() { - Some(id) => id.to_string(), - None => return vec![].into_iter(), - }; - - struct RepoChoice { - id: String, - repo_name: String, - } - - let repos = match sqlx::query_as!( - RepoChoice, - "SELECT id, repo_name FROM repos WHERE guild_id = $1", - guild_id - ) - .fetch_all(&data.pool) - .await { - Ok(v) => v, - Err(_) => return vec![].into_iter(), - }; - - repos - .into_iter() - .filter(move |r| { - r.repo_name.to_lowercase().contains(&partial.to_lowercase()) - || r.id.to_lowercase().contains(&partial.to_lowercase()) - }) - .map(|r| r.id) - .collect::>() - .into_iter() -} diff --git a/bot/src/commands/newhook.rs b/bot/src/commands/newhook.rs index 559852a..6ad8815 100644 --- a/bot/src/commands/newhook.rs +++ b/bot/src/commands/newhook.rs @@ -1,24 +1,21 @@ use poise::serenity_prelude::CreateMessage; use rand::distributions::{Alphanumeric, DistString}; -use crate::{Context, Error, config}; +use super::webhook_provider::WebhookProvider; +use crate::{config, Context, Error}; /// Creates a new webhook in a guild for sending GitHub/GitLab notifications #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn newhook( ctx: Context<'_>, #[description = "The comment for the webhook"] comment: String, - #[description = "Provider: github or gitlab"] provider: Option, + #[description = "Where this webhook receives events from"] provider: WebhookProvider, #[description = "Is the webhook broken?"] broken: Option, ) -> Result<(), Error> { - let data = ctx.data(); + ctx.defer().await?; - let provider = provider.unwrap_or_else(|| "github".to_string()).to_lowercase(); - - if provider != "github" && provider != "gitlab" { - ctx.say("Invalid provider! Use `github` or `gitlab`").await?; - return Ok(()); - } + let data = ctx.data(); + let provider_str = provider.as_db(); // Check if the guild exists on our DB let guild = sqlx::query!( @@ -27,7 +24,7 @@ pub async fn newhook( ) .fetch_one(&data.pool) .await?; - + if guild.count.unwrap_or_default() == 0 { // If it doesn't, create it sqlx::query!( @@ -62,7 +59,8 @@ pub async fn newhook( let dm = match dm_channel { Ok(dm) => dm, Err(_) => { - ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; + ctx.say("I couldn't create a DM channel with you, please enable DMs from server members") + .await?; return Ok(()); } }; @@ -73,23 +71,24 @@ pub async fn newhook( &ctx.guild_id().unwrap().to_string(), comment, webh_secret, - broken.unwrap_or(false), - provider, + broken.unwrap_or(false), + provider_str, ctx.author().id.to_string(), ctx.author().id.to_string(), ) .execute(&data.pool) .await?; - ctx.say("Webhook created! Trying to DM you the credentials...").await?; - let backup_domains = if config::CONFIG.api_url.len() > 1 { - format!("\n**Backup domains:** {}", config::CONFIG.api_url[1..].join(", ")) + format!( + "\n**Backup domains:** {}", + config::CONFIG.api_url[1..].join(", ") + ) } else { String::new() }; - let dm_content = if provider == "gitlab" { + let dm_content = if provider_str == "gitlab" { format!( "\ **GitLab Webhook Setup** 🦊 @@ -104,10 +103,10 @@ When creating repositories with the bot, use `{id}` as the webhook ID. {backup_domains} ⚠️ **The above URL and secret is unique — do not share it with others** 🗑️ **Delete this message after you're done!**", - api_url=config::CONFIG.api_url[0], - backup_domains=backup_domains, - id=id, - webh_secret=webh_secret + api_url = config::CONFIG.api_url[0], + backup_domains = backup_domains, + id = id, + webh_secret = webh_secret ) } else { format!( @@ -125,20 +124,19 @@ When creating repositories with the bot, use `{id}` as the webhook ID. {backup_domains} ⚠️ **The above URL and secret is unique — do not share it with others** 🗑️ **Delete this message after you're done!**", - api_url=config::CONFIG.api_url[0], - backup_domains=backup_domains, - id=id, - webh_secret=webh_secret + api_url = config::CONFIG.api_url[0], + backup_domains = backup_domains, + id = id, + webh_secret = webh_secret ) }; - dm.id.send_message( - &ctx, - CreateMessage::new() - .content(dm_content) - ).await?; + dm.id + .send_message(ctx.http(), CreateMessage::new().content(dm_content)) + .await?; + + ctx.say("Webhook created! Check your DMs for the webhook information.") + .await?; - ctx.say("Webhook created! Check your DMs for the webhook information.").await?; - Ok(()) } diff --git a/bot/src/commands/newrepo.rs b/bot/src/commands/newrepo.rs index 328dbdb..89d43f4 100644 --- a/bot/src/commands/newrepo.rs +++ b/bot/src/commands/newrepo.rs @@ -7,13 +7,14 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn newrepo( ctx: Context<'_>, - #[description = "The webhook ID to use"] - #[autocomplete = "super::autocomplete_webhooks"] + #[description = "Webhook ID from `/list`"] webhook_id: String, #[description = "The repo owner or organization"] owner: String, #[description = "The repo name"] name: String, #[description = "The channel to send to"] channel: ChannelId, -) -> Result<(), Error> { +) -> Result<(), Error> { + ctx.defer().await?; + let data = ctx.data(); // Check if the guild exists on our DB @@ -57,47 +58,6 @@ pub async fn newrepo( let repo_name = (owner+"/"+&name).to_lowercase(); - // Get provider to validate repo - let provider_query = sqlx::query!( - "SELECT provider FROM webhooks WHERE id = $1 AND guild_id = $2", - &webhook_id, - &ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); - - // Validate repository exists - let client = reqwest::Client::new(); - let exists = if provider == "gitlab" { - // GitLab API check - let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); - let res = client.get(&url).send().await; - - if let Ok(response) = res { - response.status().is_success() - } else { - false - } - } else { - // GitHub API check - let url = format!("https://api.github.com/repos/{}", repo_name); - let res = client.get(&url) - .header("User-Agent", "OctoFlow-Discord-Bot") - .send().await; - - if let Ok(response) = res { - response.status().is_success() - } else { - false - } - }; - - if !exists { - return Err("That repository could not be found! Make sure it exists and is public (or use your custom GitLab URL if self-hosted, though validation only works for public repos on github.com/gitlab.com currently).".into()); - } - // Check if the repo exists let repo = sqlx::query!( "SELECT COUNT(1) FROM repos WHERE lower(repo_name) = $1 AND webhook_id = $2", diff --git a/bot/src/commands/resetsecret.rs b/bot/src/commands/resetsecret.rs index 06ee691..91b38f5 100644 --- a/bot/src/commands/resetsecret.rs +++ b/bot/src/commands/resetsecret.rs @@ -7,10 +7,11 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn resetsecret( ctx: Context<'_>, - #[description = "The webhook ID"] - #[autocomplete = "super::autocomplete_webhooks"] + #[description = "Webhook ID from `/list`"] id: String, ) -> Result<(), Error> { + ctx.defer().await?; + let data = ctx.data(); let guild = sqlx::query!( @@ -59,7 +60,7 @@ pub async fn resetsecret( .await?; dm.id.send_message( - &ctx, + ctx.http(), CreateMessage::new() .content( format!( diff --git a/bot/src/commands/setrepochannel.rs b/bot/src/commands/setrepochannel.rs index 930e89d..2301b85 100644 --- a/bot/src/commands/setrepochannel.rs +++ b/bot/src/commands/setrepochannel.rs @@ -6,8 +6,7 @@ use crate::{Context, Error}; #[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] pub async fn setrepochannel( ctx: Context<'_>, - #[description = "The repo ID"] - #[autocomplete = "super::autocomplete_repos"] + #[description = "Repo ID from `/list`"] id: String, #[description = "The new channel ID"] channel: ChannelId, ) -> Result<(), Error> { diff --git a/bot/src/config.rs b/bot/src/config.rs index e5a9695..804f0e2 100644 --- a/bot/src/config.rs +++ b/bot/src/config.rs @@ -21,7 +21,7 @@ impl Default for Config { database_url: String::from(""), token: String::from(""), api_url: vec![String::from("https://v2.gitlogs.xyz")], - proxy_url: Some(String::from("http://127.0.0.1:3219")), + proxy_url: None, } } } diff --git a/bot/src/help.rs b/bot/src/help.rs index 644ba5c..9f970ed 100644 --- a/bot/src/help.rs +++ b/bot/src/help.rs @@ -10,7 +10,6 @@ pub async fn help(ctx: Context<'_>, command: Option) -> Result<(), Error pub async fn simplehelp( ctx: Context<'_>, #[description = "Specific command to show help about"] - #[autocomplete = "poise::builtins::autocomplete_command"] command: Option, ) -> Result<(), Error> { botox::help::simplehelp(ctx, command).await diff --git a/bot/src/main.rs b/bot/src/main.rs index 8e7506d..7588d14 100644 --- a/bot/src/main.rs +++ b/bot/src/main.rs @@ -2,10 +2,11 @@ use std::time::Duration; use log::{error, info}; use poise::serenity_prelude::{ - self as prelude, FullEvent, + self as prelude, EditInteractionResponse, FullEvent, }; use sqlx::postgres::PgPoolOptions; use serenity::gateway::ActivityData; +use std::sync::atomic::Ordering; use std::sync::Arc; mod help; @@ -23,6 +24,30 @@ pub struct Data { pub pool: sqlx::PgPool, } +/// After `ctx.defer()`, Discord expects the next user-visible text on the *original* interaction +/// response (`edit_response`). `ctx.say` may still use the initial callback in some cases and +/// return Unknown interaction (10062). +async fn send_command_user_message(ctx: Context<'_>, content: String) -> Result<(), prelude::Error> { + match ctx { + poise::Context::Application(app) + if app + .has_sent_initial_response + .load(Ordering::SeqCst) => + { + app.interaction + .edit_response( + ctx.serenity_context().http.as_ref(), + EditInteractionResponse::new().content(content), + ) + .await?; + } + _ => { + ctx.say(content).await?; + } + } + Ok(()) +} + #[poise::command(prefix_command, hide_in_help)] async fn register(ctx: Context<'_>) -> Result<(), Error> { poise::builtins::register_application_commands_buttons(ctx).await?; @@ -36,12 +61,13 @@ async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { match error { poise::FrameworkError::Command { error, ctx, .. } => { error!("Error in command `{}`: {:?}", ctx.command().name, error,); - ctx.say(format!( + let msg = format!( "There was an error running this command: {}", error - )) - .await - .unwrap(); + ); + if let Err(e) = send_command_user_message(ctx, msg).await { + error!("Could not send command error reply: {}", e); + } } poise::FrameworkError::CommandCheckFailed { error, ctx, .. } => { error!( @@ -51,16 +77,22 @@ async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { ); if let Some(error) = error { error!("Error in command `{}`: {:?}", ctx.command().name, error,); - ctx.say(format!( + let msg = format!( "Whoa there, do you have permission to do this?: {}", error - )) + ); + if let Err(e) = send_command_user_message(ctx, msg).await { + error!("Could not send check-failed reply: {}", e); + } + } else if let Err(e) = + send_command_user_message( + ctx, + "You don't have permission to do this but we couldn't figure out why..." + .to_string(), + ) .await - .unwrap(); - } else { - ctx.say("You don't have permission to do this but we couldn't figure out why...") - .await - .unwrap(); + { + error!("Could not send check-failed reply: {}", e); } } error => { @@ -110,10 +142,10 @@ async fn main() { let mut http = prelude::HttpBuilder::new(&config::CONFIG.token); - if let Some(v) = &config::CONFIG.proxy_url { + if let Some(v) = config::CONFIG.proxy_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { info!("Setting proxy url to {}", v); http = http.proxy(v).ratelimiter_disabled(true); - } + } let http = http.build(); @@ -143,15 +175,15 @@ async fn main() { register(), help::simplehelp(), help::help(), - commands::list(), - commands::newhook(), - commands::edithook(), - commands::newrepo(), - commands::editrepo(), - commands::delhook(), - commands::delrepo(), - commands::setrepochannel(), - commands::resetsecret(), + commands::list::list(), + commands::newhook::newhook(), + commands::edithook::edithook(), + commands::newrepo::newrepo(), + commands::editrepo::editrepo(), + commands::delhook::delhook(), + commands::delrepo::delrepo(), + commands::setrepochannel::setrepochannel(), + commands::resetsecret::resetsecret(), backups::backup(), backups::restore(), eventmods::eventmod(), diff --git a/schema.sql b/schema.sql index f8ba669..f144fbf 100644 --- a/schema.sql +++ b/schema.sql @@ -7,7 +7,7 @@ CREATE TABLE webhooks ( id TEXT PRIMARY KEY NOT NULL, guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE ON UPDATE CASCADE, comment TEXT NOT NULL, -- A comment to help identify the webhook - broken BOOLEAN NOT NULL DEFAULT FALSE, + broken BOOLEAN NOT NULL DEFAULT FALSE, secret TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'github', -- 'github' or 'gitlab' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/webserver/logos/events/branch_protection_rule.go b/webserver/logos/events/branch_protection_rule.go index e2dac6b..65ffb03 100644 --- a/webserver/logos/events/branch_protection_rule.go +++ b/webserver/logos/events/branch_protection_rule.go @@ -151,13 +151,13 @@ func branchProtectionRuleFn(bytes []byte) (*discordgo.MessageSend, error) { var title string if gh.Action == "created" { color = colorGreen - title = "New branch protection rule: " + gh.Repo.FullName + title = "New branch protection rule · " + gh.Repo.FullName } else if gh.Action == "edited" { color = colorYellow - title = "Branch protection rule edited: " + gh.Repo.FullName + title = "Branch protection rule edited · " + gh.Repo.FullName } else { color = colorRed - title = "Branch protection rule deleted: " + gh.Repo.FullName + title = "Branch protection rule deleted · " + gh.Repo.FullName } desc := "**Settings:**\n\n" + gh.Rule.settings() diff --git a/webserver/logos/events/check_run.go b/webserver/logos/events/check_run.go index f3c47d3..b0d8173 100644 --- a/webserver/logos/events/check_run.go +++ b/webserver/logos/events/check_run.go @@ -1,6 +1,7 @@ package events import ( + "strings" "time" "github.com/bwmarrin/discordgo" @@ -24,7 +25,6 @@ type CheckRunEvent struct { func checkRunFn(bytes []byte) (*discordgo.MessageSend, error) { var gh CheckRunEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -39,45 +39,41 @@ func checkRunFn(bytes []byte) (*discordgo.MessageSend, error) { gh.CheckRun.Status = "No status yet!" } + page := strings.TrimSpace(gh.CheckRun.HTMLURL) + if page == "" { + page = gh.Repo.HTMLURL + } + + color := CheckConclusionEmbedColor(gh.CheckRun.Conclusion) + if strings.EqualFold(gh.CheckRun.Status, "in_progress") || strings.EqualFold(gh.CheckRun.Status, "queued") { + color = colorYellow + } + + desc := "**" + gh.CheckRun.Name + "** · `" + gh.Action + "` · " + gh.Repo.MarkdownLink() + if d := strings.TrimSpace(gh.CheckRun.DetailsURL); d != "" { + desc += "\n\n[**Open details**](" + d + ")" + } + + ts := "" + if !gh.CheckRun.StartedAt.IsZero() { + ts = gh.CheckRun.StartedAt.UTC().Format(time.RFC3339) + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Check Run " + gh.CheckRun.Name + " " + gh.Action + " on " + gh.Repo.FullName, - Timestamp: gh.CheckRun.StartedAt.Format(time.RFC3339), + Color: color, + URL: page, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Check run · " + gh.Repo.FullName, + Description: desc, + Timestamp: ts, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Status", - Value: gh.CheckRun.Status, - Inline: true, - }, - { - Name: "Name", - Value: gh.CheckRun.Name, - Inline: true, - }, - { - Name: "Conclusion", - Value: gh.CheckRun.Conclusion, - Inline: true, - }, - { - Name: "URL", - Value: gh.CheckRun.HTMLURL, - Inline: true, - }, - { - Name: "Details URL", - Value: gh.CheckRun.DetailsURL, - Inline: true, - }, + {Name: "Status", Value: "`" + gh.CheckRun.Status + "`", Inline: true}, + {Name: "Conclusion", Value: "`" + gh.CheckRun.Conclusion + "`", Inline: true}, + {Name: "SHA", Value: gh.Repo.Commit(gh.CheckRun.HeadSHA), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, diff --git a/webserver/logos/events/check_suite.go b/webserver/logos/events/check_suite.go index c99573f..de7c452 100644 --- a/webserver/logos/events/check_suite.go +++ b/webserver/logos/events/check_suite.go @@ -1,6 +1,8 @@ package events import ( + "strings" + "github.com/bwmarrin/discordgo" ) @@ -16,6 +18,7 @@ type CheckSuiteEvent struct { Status string `json:"status,omitempty"` Conclusion string `json:"conclusion,omitempty"` URL string `json:"url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` Before string `json:"before,omitempty"` HeadCommit struct { ID string `json:"id,omitempty"` @@ -39,7 +42,6 @@ type CheckSuiteEvent struct { func checkSuiteFn(bytes []byte) (*discordgo.MessageSend, error) { var gh CheckSuiteEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -54,38 +56,42 @@ func checkSuiteFn(bytes []byte) (*discordgo.MessageSend, error) { gh.CheckSuite.Status = "No status yet!" } + page := strings.TrimSpace(gh.CheckSuite.HTMLURL) + if page == "" { + page = gh.Repo.HTMLURL + } + + color := CheckConclusionEmbedColor(gh.CheckSuite.Conclusion) + + msg := strings.TrimSpace(gh.CheckSuite.HeadCommit.Message) + if len(msg) > 240 { + msg = msg[:237] + "…" + } + if msg == "" { + msg = "_No commit message._" + } + commitLine := msg + "\n" + gh.Repo.Commit(gh.CheckSuite.HeadCommit.ID) + + desc := "**Check suite** `" + gh.Action + "` on " + gh.Repo.MarkdownLink() + if page != "" && strings.HasPrefix(page, "http") { + desc += "\n\n[**View check suite**](" + page + ")" + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Check Suite " + gh.Action + " on " + gh.Repo.FullName, + Color: color, + URL: page, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Check suite · " + gh.Repo.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Status", - Value: gh.CheckSuite.Status, - Inline: true, - }, - { - Name: "Conclusion", - Value: gh.CheckSuite.Conclusion, - Inline: true, - }, - { - Name: "URL", - Value: gh.CheckSuite.URL, - Inline: true, - }, - { - Name: "Commit", - Value: gh.CheckSuite.HeadCommit.Message + " | " + gh.Repo.Commit(gh.CheckSuite.HeadCommit.ID), - }, + {Name: "Status", Value: "`" + gh.CheckSuite.Status + "`", Inline: true}, + {Name: "Conclusion", Value: "`" + gh.CheckSuite.Conclusion + "`", Inline: true}, + {Name: "Branch", Value: "`" + gh.CheckSuite.HeadBranch + "`", Inline: true}, + {Name: "Head commit", Value: commitLine, Inline: false}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/code_scanning_alert.go b/webserver/logos/events/code_scanning_alert.go index 0adf7b0..2926e1e 100644 --- a/webserver/logos/events/code_scanning_alert.go +++ b/webserver/logos/events/code_scanning_alert.go @@ -167,7 +167,7 @@ func codeScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { Color: color, URL: gh.Alert.HTMLURL, Author: gh.Sender.AuthorEmbed(), - Title: fmt.Sprintf("Code Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Title: fmt.Sprintf("Code Scanning Alert #%d · %s · %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), Fields: fields, }, }, diff --git a/webserver/logos/events/commit_comment.go b/webserver/logos/events/commit_comment.go index 9e4e03a..38cca69 100644 --- a/webserver/logos/events/commit_comment.go +++ b/webserver/logos/events/commit_comment.go @@ -1,6 +1,8 @@ package events import ( + "strings" + "github.com/bwmarrin/discordgo" ) @@ -13,55 +15,50 @@ type CommitCommentEvent struct { HTMLURL string `json:"html_url"` User User `json:"user"` CommitID string `json:"commit_id"` - } + } `json:"comment"` } func commitCommentFn(bytes []byte) (*discordgo.MessageSend, error) { var gh CommitCommentEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var comment string = gh.Comment.Body - - if len(gh.Comment.Body) > 1000 { - comment = gh.Comment.Body[:1000] + "..." + comment := strings.TrimSpace(gh.Comment.Body) + if len(comment) > 1800 { + comment = comment[:1797] + "…" } - if comment == "" { - comment = "No description available" + comment = "_Empty comment._" } - var color int + color := colorGreen if gh.Action == "deleted" { color = colorRed - } else { - color = colorGreen } + short := strings.TrimSpace(gh.Comment.CommitID) + if len(short) > 7 { + short = short[:7] + } + + desc := "**Commit:** " + gh.Repo.Commit(gh.Comment.CommitID) + "\n\n" + comment + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { Color: color, URL: gh.Comment.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), Author: gh.Sender.AuthorEmbed(), - Title: "Comment on commit " + gh.Repo.FullName + " (" + gh.Comment.CommitID[:7] + ")", - Description: comment, + Title: "Commit comment · " + gh.Repo.FullName + " · `" + short + "`", + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Comment.User.Link(), - Inline: true, - }, - { - Name: "Commit", - Value: gh.Repo.Commit(gh.Comment.CommitID), - Inline: true, - }, + {Name: "Comment author", Value: gh.Comment.User.Link(), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/create.go b/webserver/logos/events/create.go index cc2fed5..285345a 100644 --- a/webserver/logos/events/create.go +++ b/webserver/logos/events/create.go @@ -1,6 +1,8 @@ package events import ( + "fmt" + "github.com/bwmarrin/discordgo" ) @@ -16,45 +18,31 @@ type CreateEvent struct { func createFn(bytes []byte) (*discordgo.MessageSend, error) { var gh CreateEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } + desc := fmt.Sprintf("Created **`%s`** (`%s`)", gh.RefType, gh.Ref) + if gh.MasterBranch != "" { + desc += "\n**Default branch:** `" + gh.MasterBranch + "`" + } + if gh.PusherType != "" { + desc += "\n**Pusher type:** `" + gh.PusherType + "`" + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "New " + gh.RefType + " created on " + gh.Repo.FullName, + Color: colorGreen, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Create · " + gh.Repo.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Ref", - Value: gh.Ref, - Inline: true, - }, - { - Name: "Ref Type", - Value: gh.RefType, - Inline: true, - }, - { - Name: "Master Branch", - Value: gh.MasterBranch, - Inline: true, - }, - { - Name: "Pusher Type", - Value: gh.PusherType, - Inline: true, - }, + {Name: "Actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, diff --git a/webserver/logos/events/delete.go b/webserver/logos/events/delete.go index 5a0c833..82e8255 100644 --- a/webserver/logos/events/delete.go +++ b/webserver/logos/events/delete.go @@ -1,6 +1,8 @@ package events import ( + "fmt" + "github.com/bwmarrin/discordgo" ) @@ -15,40 +17,28 @@ type DeleteEvent struct { func deleteFn(bytes []byte) (*discordgo.MessageSend, error) { var gh DeleteEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } + desc := fmt.Sprintf("Deleted **`%s`** (`%s`)", gh.RefType, gh.Ref) + if gh.PusherType != "" { + desc += "\n**Pusher type:** `" + gh.PusherType + "`" + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorRed, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Removed " + gh.RefType + " from " + gh.Repo.FullName, + Color: colorRed, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Delete · " + gh.Repo.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Ref", - Value: gh.Ref, - Inline: true, - }, - { - Name: "Ref Type", - Value: gh.RefType, - Inline: true, - }, - { - Name: "Pusher Type", - Value: gh.PusherType, - Inline: true, - }, + {Name: "Actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, diff --git a/webserver/logos/events/dependabot_alert.go b/webserver/logos/events/dependabot_alert.go index da32cc6..3eddeed 100644 --- a/webserver/logos/events/dependabot_alert.go +++ b/webserver/logos/events/dependabot_alert.go @@ -1,6 +1,11 @@ package events -import "github.com/bwmarrin/discordgo" +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) type DependabotAlertEvent struct { Action string `json:"action"` @@ -39,141 +44,141 @@ type DependabotAlertEvent struct { func dependabotAlertFn(bytes []byte) (*discordgo.MessageSend, error) { var gh DependabotAlertEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) - if err != nil { return &discordgo.MessageSend{}, err } - var color int + color := colorGreen if gh.Action == "closed" { color = colorRed - } else { - color = colorGreen } - var details = gh.Alert.Dependency.Package.Name + " (" + gh.Alert.Dependency.Package.Ecosystem + ")" - + pkg := gh.Alert.Dependency.Package.Name + eco := gh.Alert.Dependency.Package.Ecosystem + depLine := fmt.Sprintf("**`%s`** · _%s_", pkg, eco) + if gh.Alert.Dependency.ManifestPath != "" { + depLine += "\n**Manifest:** `" + gh.Alert.Dependency.ManifestPath + "`" + } if gh.Alert.Dependency.Scope != "" { - details += "\n**Scope:** " + gh.Alert.Dependency.Scope - + depLine += "\n**Scope:** `" + gh.Alert.Dependency.Scope + "`" if gh.Alert.Dependency.Scope == "runtime" { - details += " (this could be a highly critical vulnerability; runtime dependencies may not be checked by Dependabot)" + depLine += " _(runtime deps are often exploitable at execution time)_" } } - if gh.Alert.Dependency.ManifestPath != "" { - details += "\n**Manifest Path:** " + gh.Alert.Dependency.ManifestPath - } - - if gh.Alert.SecurityAdvisory.Severity != "" { - details += "\n**Severity:** " + gh.Alert.SecurityAdvisory.Severity - - if gh.Alert.SecurityAdvisory.Severity == "high" || gh.Alert.SecurityAdvisory.Severity == "critical" { + sev := gh.Alert.SecurityAdvisory.Severity + if sev != "" { + if sev == "high" || sev == "critical" { color = colorDarkRed + } else if sev == "medium" { + color = colorYellow } } + advisoryBits := []string{} if gh.Alert.SecurityAdvisory.GHSAID != "" { - details += "\n**GHSA ID:** " + gh.Alert.SecurityAdvisory.GHSAID + advisoryBits = append(advisoryBits, "**GHSA:** `"+gh.Alert.SecurityAdvisory.GHSAID+"`") } - - if gh.Alert.SecurityAdvisory.CVEID != "" { - details += "\n**CVE:** CVE " + gh.Alert.SecurityAdvisory.CVEID + if cve := strings.TrimSpace(gh.Alert.SecurityAdvisory.CVEID); cve != "" { + if strings.HasPrefix(strings.ToUpper(cve), "CVE-") { + advisoryBits = append(advisoryBits, "**CVE:** `"+cve+"`") + } else { + advisoryBits = append(advisoryBits, "**CVE:** `CVE-"+cve+"`") + } } - - if gh.Alert.State == "fixed" { - details += "\n**Could be fixed by resolving:** " + gh.Alert.Dependency.Package.Name + " " + gh.Alert.SecurityAdvisory.GHSAID + if sev != "" { + advisoryBits = append(advisoryBits, "**Severity:** `"+sev+"`") } - - if len(details) > 1020 { - details = details[:1020] + "..." + advisoryBlock := strings.Join(advisoryBits, "\n") + if advisoryBlock == "" { + advisoryBlock = "—" } - var summaryDet string - - if gh.Alert.SecurityAdvisory.Summary != "" { - summaryDet += "\n**Summary:** " + gh.Alert.SecurityAdvisory.Summary + summary := strings.TrimSpace(gh.Alert.SecurityAdvisory.Summary) + if summary == "" { + summary = "_No short summary from GitHub._" + } else if len(summary) > 500 { + summary = summary[:497] + "…" } - if gh.Alert.SecurityAdvisory.Description != "" { - if len(gh.Alert.SecurityAdvisory.Description) > 996 { - summaryDet += "\n\n" + gh.Alert.SecurityAdvisory.Description[:996] + "..." - } else { - summaryDet += "\n\n" + gh.Alert.SecurityAdvisory.Description - } + body := strings.TrimSpace(gh.Alert.SecurityAdvisory.Description) + if len(body) > 900 { + body = body[:897] + "…" } - - if len(summaryDet) > 1020 { - summaryDet = summaryDet[:1020] + "..." + if body != "" { + summary += "\n\n" + body } - var vulns string - + var vulnLines []string for _, vuln := range gh.Alert.SecurityAdvisory.Vulnerabilities { - vulns += "\n**Severity:** " + vuln.Severity + "\n**Vulnerable Version Range:** " + vuln.VulnerableVersionRange + "\n**First Patched Version:** " + vuln.FirstPatchedVersion.Identifier + "\n" + line := "" + if vuln.Severity != "" { + line += "**" + vuln.Severity + "**" + } + if vuln.VulnerableVersionRange != "" { + if line != "" { + line += " · " + } + line += "`" + vuln.VulnerableVersionRange + "`" + } + if vuln.FirstPatchedVersion.Identifier != "" { + line += "\n_Patched:_ `" + vuln.FirstPatchedVersion.Identifier + "`" + } + if strings.TrimSpace(line) != "" { + vulnLines = append(vulnLines, line) + } } - - if len(vulns) > 1020 { - vulns = vulns[:1020] + "..." + vulns := strings.Join(vulnLines, "\n\n") + if vulns == "" { + vulns = "—" } - var dismissed string - - if gh.Alert.DismissedReason != "" { - dismissed += "\n**Dismissed Reason:** " + gh.Alert.DismissedReason + dismissed := "—" + if gh.Alert.DismissedReason != "" || gh.Alert.DismissedBy.Login != "" { + dismissed = "" + if gh.Alert.DismissedReason != "" { + dismissed += "**Reason:** " + gh.Alert.DismissedReason + } + if gh.Alert.DismissedBy.Login != "" { + if dismissed != "" { + dismissed += "\n" + } + dismissed += "**By:** " + gh.Alert.DismissedBy.Link() + } } - if len(dismissed) > 1020 { - dismissed += dismissed[:1020] + "..." + alertURL := strings.TrimSpace(gh.Alert.HTMLURL) + title := fmt.Sprintf("Dependabot · %s · %s", strings.TrimSpace(gh.Repo.FullName), gh.Alert.State) + if strings.TrimSpace(gh.Repo.FullName) == "" { + title = fmt.Sprintf("Dependabot · %s · %s", strings.TrimSpace(gh.Repo.Name), gh.Alert.State) } - if gh.Alert.DismissedBy.Login != "" { - dismissed += "\n**Dismissed By:** " + gh.Alert.DismissedBy.Link() + desc := fmt.Sprintf("**Package:** `%s` · **Ecosystem:** _%s_\n**State:** `%s` · **Action:** `%s`", pkg, eco, gh.Alert.State, gh.Action) + if alertURL != "" { + desc += "\n[**Open alert in GitHub**](" + alertURL + ")" } - if dismissed == "" { - dismissed = "Not dismissed" + var thumb *discordgo.MessageEmbedThumbnail + if gh.Repo.Owner.AvatarURL != "" { + thumb = &discordgo.MessageEmbedThumbnail{URL: gh.Repo.Owner.AvatarURL} } return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.Alert.HTMLURL, - Title: "Dependabot Alert on " + gh.Repo.FullName + " " + gh.Alert.State, + Color: color, + URL: alertURL, + Title: title, + Description: desc, + Thumbnail: thumb, + Author: gh.Sender.AuthorEmbed(), Fields: []*discordgo.MessageEmbedField{ - { - Name: "URL", - Value: gh.Alert.HTMLURL, - Inline: true, - }, - { - Name: "State", - Value: gh.Alert.State, - Inline: true, - }, - { - Name: "Details", - Value: details, - Inline: true, - }, - { - Name: "Summary", - Value: summaryDet, - Inline: true, - }, - { - Name: "Vulnerabilities", - Value: vulns, - Inline: true, - }, - { - Name: "Dismissal Details", - Value: dismissed, - Inline: true, - }, + {Name: "Dependency", Value: truncateField(depLine, 1024), Inline: false}, + {Name: "Advisory", Value: truncateField(advisoryBlock, 1024), Inline: false}, + {Name: "Summary", Value: truncateField(summary, 1024), Inline: false}, + {Name: "Version ranges", Value: truncateField(vulns, 1024), Inline: false}, + {Name: "Dismissal", Value: truncateField(dismissed, 1024), Inline: false}, }, }, }, diff --git a/webserver/logos/events/deployment.go b/webserver/logos/events/deployment.go index fe4f26e..9c031f7 100644 --- a/webserver/logos/events/deployment.go +++ b/webserver/logos/events/deployment.go @@ -2,9 +2,12 @@ package events import ( "fmt" + "strings" "time" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type DeploymentEvent struct { @@ -27,29 +30,42 @@ type DeploymentEvent struct { func deploymentFn(bytes []byte) (*discordgo.MessageSend, error) { var gh DeploymentEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var color int - var title string = "Deployment " + gh.Action + " on " + gh.Repo.FullName - if gh.Action == "created" || gh.Action == "edited" { - color = colorGreen - } else { + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + color := colorGreen + if gh.Action != "created" && gh.Action != "edited" { color = colorRed } - var env = gh.Deployment.Environment - + env := gh.Deployment.Environment if gh.Deployment.OriginalEnvironment != gh.Deployment.Environment && gh.Deployment.OriginalEnvironment != "" { - env = gh.Deployment.OriginalEnvironment + " => " + gh.Deployment.Environment + env = "`" + gh.Deployment.OriginalEnvironment + "` → `" + gh.Deployment.Environment + "`" + } else { + env = "`" + env + "`" + } + + body := strings.TrimSpace(gh.Deployment.Description) + if len(body) > 1200 { + body = body[:1197] + "…" + } + if body == "" { + body = "_No deployment description._" + } + + desc := "**" + actionLabel + "** · " + gh.Repo.MarkdownLink() + "\n\n" + body + if gh.Deployment.StatusesUrl != "" { + desc += "\n\n[**Statuses**](" + gh.Deployment.StatusesUrl + ")" } - if len(gh.Deployment.Description) > 996 { - gh.Deployment.Description = gh.Deployment.Description[:996] + "..." + ts := "" + if !gh.Deployment.CreatedAt.IsZero() { + ts = gh.Deployment.CreatedAt.UTC().Format(time.RFC3339) } return &discordgo.MessageSend{ @@ -57,36 +73,17 @@ func deploymentFn(bytes []byte) (*discordgo.MessageSend, error) { { Color: color, URL: gh.Repo.HTMLURL, - Title: title, + Thumbnail: gh.Repo.OwnerThumbnail(), Author: gh.Deployment.Creator.AuthorEmbed(), - Description: gh.Deployment.Description, - Timestamp: gh.Deployment.CreatedAt.Format(time.RFC3339), + Title: "Deployment · " + gh.Repo.FullName, + Description: desc, + Timestamp: ts, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Environment", - Value: env, - Inline: true, - }, - { - Name: "Commit", - Value: gh.Repo.Commit(gh.Deployment.SHA), - Inline: true, - }, - { - Name: "Is Production (according to github)", - Value: fmt.Sprintf("%t", gh.Deployment.ProductionEnvironment), - Inline: true, - }, - { - Name: "Is Transient Environment", - Value: fmt.Sprintf("%t", gh.Deployment.TransientEnvironment), - Inline: true, - }, + {Name: "Environment", Value: env, Inline: false}, + {Name: "Commit", Value: gh.Repo.Commit(gh.Deployment.SHA), Inline: true}, + {Name: "Production", Value: fmt.Sprintf("`%t`", gh.Deployment.ProductionEnvironment), Inline: true}, + {Name: "Transient", Value: fmt.Sprintf("`%t`", gh.Deployment.TransientEnvironment), Inline: true}, + {Name: "Webhook actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, diff --git a/webserver/logos/events/deployment_status.go b/webserver/logos/events/deployment_status.go index b547c07..52d96c9 100644 --- a/webserver/logos/events/deployment_status.go +++ b/webserver/logos/events/deployment_status.go @@ -51,7 +51,7 @@ func deploymentStatusFn(bytes []byte) (*discordgo.MessageSend, error) { emoji = "ℹ️" } - var title string = emoji + " Deployment status updated on: " + gh.Repo.FullName + " (" + gh.Action + ")" + title := emoji + " Deployment status updated · " + gh.Repo.FullName + " · (" + gh.Action + ")" var color int if gh.DeploymentStatus.State == "success" { diff --git a/webserver/logos/events/discussion.go b/webserver/logos/events/discussion.go index edf77e9..985ed8a 100644 --- a/webserver/logos/events/discussion.go +++ b/webserver/logos/events/discussion.go @@ -77,7 +77,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { @@ -134,7 +134,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { @@ -186,7 +186,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -228,7 +228,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -274,7 +274,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -299,7 +299,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -330,7 +330,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { @@ -399,7 +399,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -451,7 +451,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -488,7 +488,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -607,7 +607,7 @@ func discussionFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { diff --git a/webserver/logos/events/discussion_comment.go b/webserver/logos/events/discussion_comment.go index 810578d..19e36e9 100644 --- a/webserver/logos/events/discussion_comment.go +++ b/webserver/logos/events/discussion_comment.go @@ -71,7 +71,7 @@ func discussionCommentFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -111,7 +111,7 @@ func discussionCommentFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, }, @@ -151,7 +151,7 @@ func discussionCommentFn(bytes []byte) (*discordgo.MessageSend, error) { }, { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { @@ -182,7 +182,7 @@ func discussionCommentFn(bytes []byte) (*discordgo.MessageSend, error) { Fields: []*discordgo.MessageEmbedField{ { Name: "Repository", - Value: gh.Repo.FullName, + Value: gh.Repo.MarkdownLink(), Inline: true, }, { diff --git a/webserver/logos/events/embed_delivery.go b/webserver/logos/events/embed_delivery.go new file mode 100644 index 0000000..56409ad --- /dev/null +++ b/webserver/logos/events/embed_delivery.go @@ -0,0 +1,215 @@ +package events + +import ( + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +func providerDisplayName(provider string) string { + if strings.TrimSpace(provider) == "gitlab" { + return "GitLab" + } + return "GitHub" +} + +// repoMarkdownSegment is a Discord markdown link for the repo when html_url is present. +func repoMarkdownSegment(rw *RepoWrapper) string { + if rw == nil { + return "" + } + s := strings.TrimSpace(rw.Repo.MarkdownLink()) + if strings.Contains(s, "](") { + return s + } + return "" +} + +// prependRepoMarkdownOnce adds a clickable repo line to the embed description (footers do not support links). +func prependRepoMarkdownOnce(e *discordgo.MessageEmbed, md, fullName string) { + if e == nil || md == "" { + return + } + desc := e.Description + trimDesc := strings.TrimSpace(desc) + if trimDesc == "" { + e.Description = md + return + } + if strings.HasPrefix(trimDesc, md) { + return + } + if idx := strings.Index(md, "]("); idx > 0 { + prefix := md[:idx+len("](")] + if strings.Contains(desc, prefix) { + return + } + } + if fullName != "" && strings.Contains(desc, "["+fullName+"](") { + return + } + e.Description = md + "\n\n" + desc +} + +// footerWithoutLeadingRepo removes a duplicated repo path from footer text so it matches the markdown line in description. +func footerWithoutLeadingRepo(footerText, repo, provider string) string { + t := strings.TrimSpace(footerText) + repo = strings.TrimSpace(repo) + pr := providerDisplayName(provider) + if repo == "" { + if t == "" { + return pr + } + return t + } + parts := strings.Split(t, " · ") + if len(parts) >= 2 { + left := strings.TrimSpace(parts[0]) + if strings.EqualFold(left, repo) { + rest := strings.TrimSpace(strings.Join(parts[1:], " · ")) + if rest == "" { + return pr + } + return rest + } + } + suff := " · " + repo + if len(t) >= len(suff) && strings.EqualFold(t[len(t)-len(suff):], suff) { + out := strings.TrimSpace(t[:len(t)-len(suff)]) + if out == "" { + return pr + } + return out + } + if strings.EqualFold(t, repo) { + return pr + } + if t == "" { + return pr + } + return t +} + +// StripPlainRepoFromTitle removes the repository path from a title so the clickable line in the +// description is not duplicated (Discord titles do not support inline markdown links). +func StripPlainRepoFromTitle(title, repo string) string { + title = strings.TrimSpace(title) + repo = strings.TrimSpace(repo) + if title == "" || repo == "" { + return title + } + if strings.EqualFold(title, repo) { + return "" + } + parts := strings.Split(title, " · ") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if strings.EqualFold(p, repo) { + continue + } + out = append(out, p) + } + res := strings.Join(out, " · ") + suff := ": " + repo + if len(res) >= len(suff) && strings.EqualFold(res[len(res)-len(suff):], suff) { + res = strings.TrimSpace(res[:len(res)-len(suff)]) + } + suff2 := " on " + repo + if len(res) >= len(suff2) && strings.EqualFold(res[len(res)-len(suff2):], suff2) { + res = strings.TrimSpace(res[:len(res)-len(suff2)]) + } + suff3 := " in " + repo + if len(res) >= len(suff3) && strings.EqualFold(res[len(res)-len(suff3):], suff3) { + res = strings.TrimSpace(res[:len(res)-len(suff3)]) + } + return strings.TrimSpace(res) +} + +// EnrichEmbedsBeforeSend fills missing timestamps and normalizes footers so Discord embeds look +// consistent across GitHub and GitLab handlers without editing every event file. +func EnrichEmbedsBeforeSend(embeds []*discordgo.MessageEmbed, rw *RepoWrapper, provider string) { + if len(embeds) == 0 || rw == nil { + return + } + + ts := time.Now().UTC().Format(time.RFC3339) + repo := strings.TrimSpace(rw.Repo.FullName) + md := repoMarkdownSegment(rw) + pr := providerDisplayName(provider) + + for _, e := range embeds { + if e == nil { + continue + } + if e.Timestamp == "" { + e.Timestamp = ts + } + + if e.Thumbnail == nil && rw.Repo.Owner.AvatarURL != "" { + e.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: rw.Repo.Owner.AvatarURL} + } + + if md != "" { + prependRepoMarkdownOnce(e, md, repo) + if e.Footer != nil { + e.Footer.Text = footerWithoutLeadingRepo(e.Footer.Text, repo, provider) + if strings.TrimSpace(e.Footer.Text) == "" { + e.Footer.Text = pr + } + } + if strings.TrimSpace(e.Title) != "" { + if t := StripPlainRepoFromTitle(e.Title, repo); t != "" { + e.Title = t + } else { + e.Title = pr + } + } + linkVal := strings.TrimSpace(rw.Repo.MarkdownLink()) + for _, f := range e.Fields { + if f == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(f.Value), repo) { + f.Value = linkVal + } + } + } + + if e.Footer == nil { + ft := &discordgo.MessageEmbedFooter{} + switch { + case md != "": + ft.Text = pr + if rw.Repo.Owner.AvatarURL != "" { + ft.IconURL = rw.Repo.Owner.AvatarURL + } + case repo != "": + ft.Text = repo + if rw.Repo.Owner.AvatarURL != "" { + ft.IconURL = rw.Repo.Owner.AvatarURL + } + case provider == "gitlab": + ft.Text = "GitLab" + default: + ft.Text = "GitHub" + } + e.Footer = ft + continue + } + + if md != "" { + continue + } + + if repo != "" && !strings.Contains(e.Footer.Text, repo) { + if len(e.Footer.Text)+len(repo)+3 <= 2000 { + e.Footer.Text = e.Footer.Text + " · " + repo + } + } + } +} diff --git a/webserver/logos/events/fork.go b/webserver/logos/events/fork.go index e771570..a7dd399 100644 --- a/webserver/logos/events/fork.go +++ b/webserver/logos/events/fork.go @@ -1,8 +1,6 @@ package events import ( - "fmt" - "github.com/bwmarrin/discordgo" ) @@ -16,40 +14,32 @@ type ForkEvent struct { func forkFn(bytes []byte) (*discordgo.MessageSend, error) { var gh ForkEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } + desc := "**Upstream:** " + gh.Repo.MarkdownLink() + "\n**Fork:** " + gh.Forkee.MarkdownLink() + + thumb := gh.Forkee.OwnerThumbnail() + if thumb == nil { + thumb = gh.Repo.OwnerThumbnail() + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Forkee.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "New fork: " + gh.Forkee.FullName, + Color: colorGreen, + URL: gh.Forkee.HTMLURL, + Thumbnail: thumb, + Author: gh.Sender.AuthorEmbed(), + Title: "Fork · " + gh.Forkee.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Original repo", - Value: gh.Repo.HTMLURL, - }, - { - Name: "Forked repo", - Value: gh.Forkee.HTMLURL, - }, - { - Name: "Visibility", - Value: fmt.Sprintf("%s -> %s", gh.Repo.Visibility(), gh.Forkee.Visibility()), - }, + {Name: "Actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, }, nil - } diff --git a/webserver/logos/events/gitlab_common.go b/webserver/logos/events/gitlab_common.go index 17a8e9e..eefff29 100644 --- a/webserver/logos/events/gitlab_common.go +++ b/webserver/logos/events/gitlab_common.go @@ -33,13 +33,18 @@ type GLUser struct { Username string `json:"username"` Email string `json:"email"` AvatarURL string `json:"avatar_url"` + WebURL string `json:"web_url"` } func (u GLUser) AuthorEmbed() *discordgo.MessageEmbedAuthor { - return &discordgo.MessageEmbedAuthor{ + author := &discordgo.MessageEmbedAuthor{ Name: u.Name + " (@" + u.Username + ")", IconURL: u.AvatarURL, } + if u.WebURL != "" { + author.URL = u.WebURL + } + return author } func (u GLUser) Link(baseURL string) string { @@ -75,3 +80,17 @@ type GLRepository struct { Description string `json:"description"` Homepage string `json:"homepage"` } + +// glProjectThumbnail returns a Discord thumbnail when the project has an avatar URL. +func glProjectThumbnail(p GLProject) *discordgo.MessageEmbedThumbnail { + if strings.TrimSpace(p.AvatarURL) == "" { + return nil + } + return &discordgo.MessageEmbedThumbnail{URL: p.AvatarURL} +} + +// glFooterForProject shows the provider in the embed footer; the project path is a clickable +// markdown line in the description (see EnrichEmbedsBeforeSend). +func glFooterForProject(_ GLProject) *discordgo.MessageEmbedFooter { + return &discordgo.MessageEmbedFooter{Text: "GitLab"} +} diff --git a/webserver/logos/events/gitlab_issue.go b/webserver/logos/events/gitlab_issue.go index aa19ea7..030b695 100644 --- a/webserver/logos/events/gitlab_issue.go +++ b/webserver/logos/events/gitlab_issue.go @@ -47,29 +47,28 @@ func glIssueFn(bytes []byte) (*discordgo.MessageSend, error) { { Color: color, URL: gl.ObjectAttributes.URL, + Thumbnail: glProjectThumbnail(gl.Project), Author: gl.User.AuthorEmbed(), Description: body, - Title: fmt.Sprintf("Issue %s on %s (#%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Title: fmt.Sprintf("Issue · %s · #%d", gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), Fields: []*discordgo.MessageEmbedField{ { Name: "Action", - Value: gl.ObjectAttributes.Action, + Value: "`" + gl.ObjectAttributes.Action + "`", Inline: true, }, { Name: "State", - Value: gl.ObjectAttributes.State, + Value: "`" + gl.ObjectAttributes.State + "`", Inline: true, }, { Name: "Title", Value: gl.ObjectAttributes.Title, - Inline: true, + Inline: false, }, }, - Footer: &discordgo.MessageEmbedFooter{ - Text: "GitLab", - }, + Footer: glFooterForProject(gl.Project), }, }, }, nil diff --git a/webserver/logos/events/gitlab_misc.go b/webserver/logos/events/gitlab_misc.go index a3858a1..53112bf 100644 --- a/webserver/logos/events/gitlab_misc.go +++ b/webserver/logos/events/gitlab_misc.go @@ -50,7 +50,7 @@ func glReleaseFn(bytes []byte) (*discordgo.MessageSend, error) { Color: color, URL: gl.URL, Description: desc, - Title: fmt.Sprintf("Release %s on %s (%s)", gl.Action, gl.Project.PathWithNamespace, gl.Tag), + Title: fmt.Sprintf("Release · %s · %s · (%s)", gl.Action, gl.Project.PathWithNamespace, gl.Tag), Fields: []*discordgo.MessageEmbedField{ { Name: "Name", @@ -113,7 +113,7 @@ func glWikiFn(bytes []byte) (*discordgo.MessageSend, error) { Color: color, URL: gl.ObjectAttributes.URL, Author: gl.User.AuthorEmbed(), - Title: fmt.Sprintf("Wiki Page %s on %s", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace), + Title: fmt.Sprintf("Wiki Page · %s · %s", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace), Fields: []*discordgo.MessageEmbedField{ { Name: "Title", @@ -194,7 +194,7 @@ func glDeploymentFn(bytes []byte) (*discordgo.MessageSend, error) { { Color: color, Author: gl.User.AuthorEmbed(), - Title: fmt.Sprintf("Deployment %s on %s", gl.Status, gl.Project.PathWithNamespace), + Title: fmt.Sprintf("Deployment · %s · %s", gl.Status, gl.Project.PathWithNamespace), Fields: fields, Footer: &discordgo.MessageEmbedFooter{ Text: "GitLab", diff --git a/webserver/logos/events/gitlab_mr.go b/webserver/logos/events/gitlab_mr.go index 15db252..e87a1d6 100644 --- a/webserver/logos/events/gitlab_mr.go +++ b/webserver/logos/events/gitlab_mr.go @@ -12,17 +12,17 @@ type GLMergeRequestEvent struct { User GLUser `json:"user"` Project GLProject `json:"project"` ObjectAttributes struct { - ID int `json:"id"` - IID int `json:"iid"` - Title string `json:"title"` - Description string `json:"description"` - State string `json:"state"` - Action string `json:"action"` - URL string `json:"url"` - SourceBranch string `json:"source_branch"` - TargetBranch string `json:"target_branch"` - MergeStatus string `json:"merge_status"` - MergeWhenPipelineSucceeds bool `json:"merge_when_pipeline_succeeds"` + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + MergeStatus string `json:"merge_status"` + MergeWhenPipelineSucceeds bool `json:"merge_when_pipeline_succeeds"` } `json:"object_attributes"` } @@ -48,14 +48,17 @@ func glMergeRequestFn(bytes []byte) (*discordgo.MessageSend, error) { color = colorRed } + desc := fmt.Sprintf("_Action:_ **`%s`**\n\n%s", gl.ObjectAttributes.Action, body) + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { Color: color, URL: gl.ObjectAttributes.URL, + Thumbnail: glProjectThumbnail(gl.Project), Author: gl.User.AuthorEmbed(), - Description: body, - Title: fmt.Sprintf("Merge Request %s on %s (!%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Description: desc, + Title: fmt.Sprintf("Merge request · %s · !%d", gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), Fields: []*discordgo.MessageEmbedField{ { Name: "Title", @@ -63,24 +66,22 @@ func glMergeRequestFn(bytes []byte) (*discordgo.MessageSend, error) { Inline: false, }, { - Name: "Source → Target", - Value: gl.ObjectAttributes.SourceBranch + " → " + gl.ObjectAttributes.TargetBranch, - Inline: true, + Name: "Branches", + Value: "`" + gl.ObjectAttributes.SourceBranch + "` → `" + gl.ObjectAttributes.TargetBranch + "`", + Inline: false, }, { - Name: "Merge Status", - Value: gl.ObjectAttributes.MergeStatus, + Name: "Merge status", + Value: "`" + gl.ObjectAttributes.MergeStatus + "`", Inline: true, }, { Name: "State", - Value: gl.ObjectAttributes.State, + Value: "`" + gl.ObjectAttributes.State + "`", Inline: true, }, }, - Footer: &discordgo.MessageEmbedFooter{ - Text: "GitLab", - }, + Footer: glFooterForProject(gl.Project), }, }, }, nil diff --git a/webserver/logos/events/gitlab_note.go b/webserver/logos/events/gitlab_note.go index 18003a6..a415f6f 100644 --- a/webserver/logos/events/gitlab_note.go +++ b/webserver/logos/events/gitlab_note.go @@ -17,7 +17,6 @@ type GLNoteEvent struct { NoteableType string `json:"noteable_type"` URL string `json:"url"` } `json:"object_attributes"` - // One of these will be populated depending on what was commented on Issue *struct { IID int `json:"iid"` Title string `json:"title"` @@ -44,23 +43,23 @@ func glNoteFn(bytes []byte) (*discordgo.MessageSend, error) { } note := gl.ObjectAttributes.Note - if len(note) > 996 { - note = note[:996] + "..." + if len(note) > 1800 { + note = note[:1797] + "…" } var target string switch gl.ObjectAttributes.NoteableType { case "Issue": if gl.Issue != nil { - target = fmt.Sprintf("Issue #%d (%s)", gl.Issue.IID, gl.Issue.Title) + target = fmt.Sprintf("Issue #%d · %s", gl.Issue.IID, gl.Issue.Title) } else { target = "Issue" } case "MergeRequest": if gl.MergeRequest != nil { - target = fmt.Sprintf("MR !%d (%s)", gl.MergeRequest.IID, gl.MergeRequest.Title) + target = fmt.Sprintf("Merge request !%d · %s", gl.MergeRequest.IID, gl.MergeRequest.Title) } else { - target = "Merge Request" + target = "Merge request" } case "Commit": if gl.Commit != nil { @@ -68,13 +67,13 @@ func glNoteFn(bytes []byte) (*discordgo.MessageSend, error) { if len(shortID) > 7 { shortID = shortID[:7] } - target = "Commit " + shortID + target = "Commit `" + shortID + "`" } else { target = "Commit" } case "Snippet": if gl.Snippet != nil { - target = fmt.Sprintf("Snippet #%d (%s)", gl.Snippet.ID, gl.Snippet.Title) + target = fmt.Sprintf("Snippet #%d · %s", gl.Snippet.ID, gl.Snippet.Title) } else { target = "Snippet" } @@ -82,17 +81,18 @@ func glNoteFn(bytes []byte) (*discordgo.MessageSend, error) { target = gl.ObjectAttributes.NoteableType } + desc := "_On **" + target + "**_\n\n" + note + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: glColorOrange, + Color: glColorPurple, URL: gl.ObjectAttributes.URL, + Thumbnail: glProjectThumbnail(gl.Project), Author: gl.User.AuthorEmbed(), - Description: note, - Title: "Comment on " + target + " in " + gl.Project.PathWithNamespace, - Footer: &discordgo.MessageEmbedFooter{ - Text: "GitLab", - }, + Title: "Comment · " + gl.Project.PathWithNamespace, + Description: desc, + Footer: glFooterForProject(gl.Project), }, }, }, nil diff --git a/webserver/logos/events/gitlab_pipeline.go b/webserver/logos/events/gitlab_pipeline.go index 8689df1..c84c7d4 100644 --- a/webserver/logos/events/gitlab_pipeline.go +++ b/webserver/logos/events/gitlab_pipeline.go @@ -84,7 +84,7 @@ func glPipelineFn(bytes []byte) (*discordgo.MessageSend, error) { Color: color, URL: gl.Project.WebURL + "/-/pipelines/" + fmt.Sprintf("%d", gl.ObjectAttributes.ID), Author: gl.User.AuthorEmbed(), - Title: fmt.Sprintf("Pipeline #%d %s on %s", gl.ObjectAttributes.ID, gl.ObjectAttributes.Status, gl.Project.PathWithNamespace), + Title: fmt.Sprintf("Pipeline · #%d · %s · %s", gl.ObjectAttributes.ID, gl.ObjectAttributes.Status, gl.Project.PathWithNamespace), Fields: []*discordgo.MessageEmbedField{ { Name: "Ref", @@ -186,7 +186,7 @@ func glJobFn(bytes []byte) (*discordgo.MessageSend, error) { { Color: color, Author: gl.User.AuthorEmbed(), - Title: fmt.Sprintf("Job #%d %s in %s", gl.BuildID, gl.BuildStatus, gl.ProjectName), + Title: fmt.Sprintf("Job · #%d · %s · %s", gl.BuildID, gl.BuildStatus, gl.ProjectName), Fields: fields, Footer: &discordgo.MessageEmbedFooter{ Text: "GitLab", diff --git a/webserver/logos/events/gitlab_push.go b/webserver/logos/events/gitlab_push.go index e04e8e3..348a3b5 100644 --- a/webserver/logos/events/gitlab_push.go +++ b/webserver/logos/events/gitlab_push.go @@ -47,7 +47,7 @@ func glPushFn(bytes []byte) (*discordgo.MessageSend, error) { if len(msg) > 100 { msg = msg[:100] + "..." } - commitList += fmt.Sprintf("%s [``%s``](%s) | %s\n", msg, commit.ID[:7], commit.URL, commit.Author.Name) + commitList += fmt.Sprintf("• **%s** [`%s`](%s) · _%s_\n", msg, commit.ID[:7], commit.URL, commit.Author.Name) } if len(commitList) > 1024 { @@ -61,18 +61,16 @@ func glPushFn(bytes []byte) (*discordgo.MessageSend, error) { return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: glColorOrange, - URL: gl.Project.WebURL, + Color: glColorOrange, + URL: gl.Project.WebURL, + Thumbnail: glProjectThumbnail(gl.Project), Author: &discordgo.MessageEmbedAuthor{ Name: gl.UserName + " (@" + gl.UserUsername + ")", IconURL: gl.UserAvatar, }, - Title: "Push on " + gl.Project.PathWithNamespace, + Title: "Push · " + gl.Project.PathWithNamespace, + Description: fmt.Sprintf("**%d** commit(s) on `%s`", gl.TotalCommitsCount, gl.Ref), Fields: []*discordgo.MessageEmbedField{ - { - Name: "Branch", - Value: "**Ref:** " + gl.Ref, - }, { Name: "Commits", Value: commitList, @@ -83,14 +81,12 @@ func glPushFn(bytes []byte) (*discordgo.MessageSend, error) { Inline: true, }, { - Name: "Total Commits", + Name: "Total", Value: fmt.Sprintf("%d", gl.TotalCommitsCount), Inline: true, }, }, - Footer: &discordgo.MessageEmbedFooter{ - Text: "GitLab", - }, + Footer: glFooterForProject(gl.Project), }, }, }, nil @@ -120,27 +116,27 @@ func glTagPushFn(bytes []byte) (*discordgo.MessageSend, error) { return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: glColorOrange, - URL: gl.Project.WebURL, + Color: glColorOrange, + URL: gl.Project.WebURL, + Thumbnail: glProjectThumbnail(gl.Project), Author: &discordgo.MessageEmbedAuthor{ Name: gl.UserName + " (@" + gl.UserUsername + ")", IconURL: gl.UserAvatar, }, - Title: "Tag Push on " + gl.Project.PathWithNamespace, + Title: "Tag push · " + gl.Project.PathWithNamespace, + Description: "New tag activity on **`" + gl.Ref + "`**.", Fields: []*discordgo.MessageEmbedField{ { Name: "Ref", - Value: gl.Ref, + Value: "`" + gl.Ref + "`", }, { Name: "Checkout SHA", - Value: gl.CheckoutSHA, + Value: "`" + gl.CheckoutSHA + "`", Inline: true, }, }, - Footer: &discordgo.MessageEmbedFooter{ - Text: "GitLab", - }, + Footer: glFooterForProject(gl.Project), }, }, }, nil diff --git a/webserver/logos/events/internal_common__.go b/webserver/logos/events/internal_common__.go index b344134..43275df 100644 --- a/webserver/logos/events/internal_common__.go +++ b/webserver/logos/events/internal_common__.go @@ -10,43 +10,45 @@ import ( var json = jsoniter.ConfigCompatibleWithStandardLibrary +// Semantic embed colors (GitHub-inspired, easier on the eyes than pure RGB primaries). var ( - colorGreen = 0x00ff1a - colorYellow = 0xffff00 - colorRed = 0xff0000 - colorDarkRed = 0x8b0000 + colorGreen = 0x238636 + colorYellow = 0xD29922 + colorRed = 0xDA3633 + colorDarkRed = 0x8B0000 ) var SupportedEvents = map[string]func(bytes []byte) (*discordgo.MessageSend, error){ - "branch_protection_rule": branchProtectionRuleFn, - "check_suite": checkSuiteFn, - "create": createFn, - "issues": issuesFn, - "issue_comment": issueCommentFn, - "pull_request": pullRequestFn, - "pull_request_review_comment": pullRequestReviewCommentFn, - "push": pushFn, - "star": starFn, - "status": statusFn, - "release": releaseFn, - "commit_comment": commitCommentFn, - "deployment": deploymentFn, - "deployment_status": deploymentStatusFn, - "discussion": discussionFn, - "discussion_comment": discussionCommentFn, - "workflow_run": workflowRunFn, - "dependabot_alert": dependabotAlertFn, - "delete": deleteFn, - "workflow_job": workflowJobFn, - "check_run": checkRunFn, - "public": publicFn, - "watch": watchFn, - "repository": repositoryFn, - "team": teamFn, - "fork": forkFn, - "page_build": pageBuildFn, - "code_scanning_alert": codeScanningAlertFn, - "secret_scanning_alert": secretScanningAlertFn, + "branch_protection_rule": branchProtectionRuleFn, + "check_suite": checkSuiteFn, + "create": createFn, + "issues": issuesFn, + "issue_comment": issueCommentFn, + "pull_request": pullRequestFn, + "pull_request_review_comment": pullRequestReviewCommentFn, + "push": pushFn, + "star": starFn, + "status": statusFn, + "release": releaseFn, + "commit_comment": commitCommentFn, + "deployment": deploymentFn, + "deployment_status": deploymentStatusFn, + "discussion": discussionFn, + "discussion_comment": discussionCommentFn, + "workflow_run": workflowRunFn, + "dependabot_alert": dependabotAlertFn, + "delete": deleteFn, + "workflow_job": workflowJobFn, + "check_run": checkRunFn, + "public": publicFn, + "watch": watchFn, + "repository": repositoryFn, + "repository_vulnerability_alert": RepositoryVulnerabilityAlert, + "team": teamFn, + "fork": forkFn, + "page_build": pageBuildFn, + "code_scanning_alert": codeScanningAlertFn, + "secret_scanning_alert": secretScanningAlertFn, } type User struct { @@ -58,15 +60,37 @@ type User struct { OrganizationsURL string `json:"organizations_url"` } +// GitHubWebURL returns a browser URL for this user or org. Webhook payloads often omit html_url +// on nested objects (for example organization), so we fall back to https://github.com/. +func (u User) GitHubWebURL() string { + if s := strings.TrimSpace(u.HTMLURL); s != "" { + return s + } + login := strings.TrimSpace(u.Login) + if login == "" { + return "" + } + return "https://github.com/" + login +} + func (u User) AuthorEmbed() *discordgo.MessageEmbedAuthor { return &discordgo.MessageEmbedAuthor{ Name: u.Login, + URL: u.GitHubWebURL(), IconURL: u.AvatarURL, } } func (u User) Link() string { - return "[" + strings.ReplaceAll(u.Login, " ", "%20") + "](" + u.HTMLURL + ")" + login := strings.TrimSpace(u.Login) + if login == "" { + return "—" + } + url := u.GitHubWebURL() + if url == "" { + return login + } + return "[" + strings.ReplaceAll(login, " ", "%20") + "](" + url + ")" } type Repository struct { @@ -83,7 +107,15 @@ type Repository struct { // Commit returns the commit URL for the given commit ID. func (r Repository) Commit(id string) string { - return "[" + id[:7] + "](" + r.HTMLURL + "/commit/" + id + ")" + id = strings.TrimSpace(id) + if len(id) < 7 { + return "—" + } + base := strings.TrimSpace(r.HTMLURL) + if base == "" { + return "`" + id[:7] + "`" + } + return "[" + id[:7] + "](" + base + "/commit/" + id + ")" } func (r Repository) Visibility() string { @@ -93,6 +125,54 @@ func (r Repository) Visibility() string { return "Public" } +// MarkdownLink renders [full_name](html_url) for embed field text. +func (r Repository) MarkdownLink() string { + name := strings.TrimSpace(r.FullName) + if name == "" { + name = strings.TrimSpace(r.Name) + } + if name == "" { + return "—" + } + u := strings.TrimSpace(r.HTMLURL) + if u == "" { + return name + } + return "[" + name + "](" + u + ")" +} + +// OwnerThumbnail uses the repository owner avatar when present. +func (r Repository) OwnerThumbnail() *discordgo.MessageEmbedThumbnail { + return r.Owner.EmbedThumbnail() +} + +// EmbedThumbnail uses this user's avatar as a Discord embed thumbnail. +func (u User) EmbedThumbnail() *discordgo.MessageEmbedThumbnail { + if strings.TrimSpace(u.AvatarURL) == "" { + return nil + } + return &discordgo.MessageEmbedThumbnail{URL: u.AvatarURL} +} + +// CheckConclusionEmbedColor maps GitHub Actions / Checks conclusions to embed colors. +func CheckConclusionEmbedColor(conclusion string) int { + switch strings.ToLower(strings.TrimSpace(conclusion)) { + case "success", "fixed": + return colorGreen + case "failure", "cancelled", "timed_out", "timedout", "action_required": + return colorRed + case "skipped", "neutral", "stale": + return colorYellow + case "no conclusion yet!": + return colorYellow + default: + if strings.TrimSpace(conclusion) == "" { + return colorYellow + } + return colorGreen + } +} + type Issue struct { ID int `json:"id"` Number int `json:"number"` diff --git a/webserver/logos/events/issue_comment.go b/webserver/logos/events/issue_comment.go index 325f184..e41238c 100644 --- a/webserver/logos/events/issue_comment.go +++ b/webserver/logos/events/issue_comment.go @@ -2,8 +2,11 @@ package events import ( "fmt" + "strings" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type IssueCommentEvent struct { @@ -21,63 +24,56 @@ type IssueCommentEvent struct { func issueCommentFn(bytes []byte) (*discordgo.MessageSend, error) { var gh IssueCommentEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var body string = gh.Issue.Body - if len(gh.Issue.Body) > 1000 { - body = gh.Issue.Body[:1000] + "..." + issueBody := strings.TrimSpace(gh.Issue.Body) + if len(issueBody) > 400 { + issueBody = issueBody[:397] + "…" } - - if body == "" { - body = "No description available" + if issueBody == "" { + issueBody = "_No issue body._" } - var comment string = gh.Comment.Body - - if len(gh.Comment.Body) > 1000 { - comment = gh.Comment.Body[:1000] + "..." + comment := strings.TrimSpace(gh.Comment.Body) + if len(comment) > 1200 { + comment = comment[:1197] + "…" } - if comment == "" { - comment = "No description available" + comment = "_Empty comment._" } - var color int + color := colorGreen if gh.Action == "deleted" { color = colorRed - } else { - color = colorGreen } + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + page := strings.TrimSpace(gh.Comment.HTMLURL) + if page == "" { + page = gh.Issue.HTMLURL + } + + desc := fmt.Sprintf("**%s** on [#%d — %s](%s)\n\n**Comment**\n%s", + actionLabel, gh.Issue.Number, gh.Issue.Title, gh.Issue.HTMLURL, comment) + desc += "\n\n**Issue excerpt**\n" + issueBody + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.Issue.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: fmt.Sprintf("Comment on %s (#%d) %s", gh.Repo.FullName, gh.Issue.Number, gh.Action), + Color: color, + URL: page, + Thumbnail: gh.Sender.EmbedThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Issue comment · " + gh.Repo.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Title", - Value: gh.Issue.Title, - }, - { - Name: "Parent Issue", - Value: body, - }, - { - Name: "Comment", - Value: comment, - }, + {Name: "Comment author", Value: gh.Comment.User.Link(), Inline: true}, + {Name: "Webhook actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/issues.go b/webserver/logos/events/issues.go index c9350a8..73493be 100644 --- a/webserver/logos/events/issues.go +++ b/webserver/logos/events/issues.go @@ -2,8 +2,12 @@ package events import ( "fmt" + "strconv" + "strings" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type IssuesEvent struct { @@ -16,7 +20,6 @@ type IssuesEvent struct { func issuesFn(bytes []byte) (*discordgo.MessageSend, error) { var gh IssuesEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -25,18 +28,28 @@ func issuesFn(bytes []byte) (*discordgo.MessageSend, error) { var body string = gh.Issue.Body if len(gh.Issue.Body) > 996 { - body = gh.Issue.Body[:996] + "..." + body = gh.Issue.Body[:996] + "…" } if body == "" { - body = "No description available" + body = "_No description provided._" } - var color int - if gh.Action == "deleted" || gh.Action == "unpinned" { + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + color := colorGreen + switch gh.Action { + case "deleted", "unpinned", "demilestoned", "closed", "locked": color = colorRed - } else { - color = colorGreen + case "edited", "labeled", "unlabeled", "assigned", "unassigned", "milestoned", "pinned": + color = colorYellow + } + + desc := fmt.Sprintf("**%s** · #%d · **`%s`**\n\n%s", actionLabel, gh.Issue.Number, gh.Issue.State, body) + + thumb := gh.Issue.User.EmbedThumbnail() + if thumb == nil { + thumb = gh.Repo.OwnerThumbnail() } return &discordgo.MessageSend{ @@ -44,25 +57,14 @@ func issuesFn(bytes []byte) (*discordgo.MessageSend, error) { { Color: color, URL: gh.Issue.HTMLURL, + Thumbnail: thumb, Author: gh.Sender.AuthorEmbed(), - Description: body, - Title: fmt.Sprintf("Issue %s on %s (#%d)", gh.Action, gh.Repo.FullName, gh.Issue.Number), + Description: desc, + Title: "Issue · " + gh.Repo.FullName + " · #" + strconv.Itoa(gh.Issue.Number), Fields: []*discordgo.MessageEmbedField{ - { - Name: "Action", - Value: gh.Action, - Inline: true, - }, - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Title", - Value: gh.Issue.Title, - Inline: true, - }, + {Name: "Title", Value: gh.Issue.Title, Inline: false}, + {Name: "Author", Value: gh.Issue.User.Link(), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/page_build.go b/webserver/logos/events/page_build.go index 0110544..0a20c5d 100644 --- a/webserver/logos/events/page_build.go +++ b/webserver/logos/events/page_build.go @@ -2,6 +2,7 @@ package events import ( "fmt" + "strings" "time" "github.com/bwmarrin/discordgo" @@ -24,59 +25,52 @@ type PageBuildEvent struct { func pageBuildFn(bytes []byte) (*discordgo.MessageSend, error) { var gh PageBuildEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } + color := colorGreen + st := strings.ToLower(strings.TrimSpace(gh.Build.Status)) + if st == "errored" || st == "failed" || strings.TrimSpace(gh.Build.Error.Message) != "" { + color = colorRed + } else if st == "building" || st == "pending" { + color = colorYellow + } + + dur := "unknown" + if gh.Build.Duration > 0 { + dur = fmt.Sprintf("%d s", gh.Build.Duration) + } + + errMsg := strings.TrimSpace(gh.Build.Error.Message) + if errMsg == "" { + errMsg = "_None_" + } + + desc := "**Status:** `" + gh.Build.Status + "` · **Duration:** `" + dur + "`\n**Commit:** " + gh.Repo.Commit(gh.Build.Commit) + if errMsg != "_None_" { + desc += "\n\n**Error**\n```\n" + errMsg + "\n```" + } + + ts := "" + if !gh.Build.CreatedAt.IsZero() { + ts = gh.Build.CreatedAt.UTC().Format(time.RFC3339) + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Page build: " + gh.Repo.FullName, - Timestamp: gh.Build.CreatedAt.Format(time.RFC3339), + Color: color, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "GitHub Pages · " + gh.Repo.FullName, + Description: desc, + Timestamp: ts, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Commit", - Value: gh.Repo.Commit(gh.Build.Commit), - }, - { - Name: "Duration", - Value: func() string { - if gh.Build.Duration == 0 { - return "unknown" - } - return fmt.Sprintf("%d seconds", gh.Build.Duration) - }(), - }, - { - Name: "Errors", - Value: func() string { - if gh.Build.Error.Message == "" { - return "No errors yet!" - } - return gh.Build.Error.Message - }(), - Inline: true, - }, - { - Name: "Status", - Value: func() string { - if gh.Build.Status == "" { - return "unknown" - } - return gh.Build.Status - }(), - Inline: true, - }, + {Name: "Actor", Value: gh.Sender.Link(), Inline: false}, }, }, }, diff --git a/webserver/logos/events/public.go b/webserver/logos/events/public.go index 4157df0..4525257 100644 --- a/webserver/logos/events/public.go +++ b/webserver/logos/events/public.go @@ -1,8 +1,6 @@ package events import ( - "fmt" - "github.com/bwmarrin/discordgo" ) @@ -14,30 +12,23 @@ type PublicEvent struct { func publicFn(bytes []byte) (*discordgo.MessageSend, error) { var gh PublicEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } + desc := "**" + gh.Repo.MarkdownLink() + "** is now **public**.\n\n" + gh.Sender.Link() + " flipped visibility from private → public." + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: fmt.Sprintf("Repository update: %s", gh.Repo.FullName), - Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Changes", - Value: "private -> public", - }, - }, + Color: colorGreen, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Title: "Repository is public · " + gh.Repo.FullName, + Author: gh.Sender.AuthorEmbed(), + Description: desc, }, }, }, nil diff --git a/webserver/logos/events/pull_request.go b/webserver/logos/events/pull_request.go index f20d1c5..6a9b8f9 100644 --- a/webserver/logos/events/pull_request.go +++ b/webserver/logos/events/pull_request.go @@ -2,8 +2,12 @@ package events import ( "fmt" + "strconv" + "strings" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type PullRequestEvent struct { @@ -16,57 +20,56 @@ type PullRequestEvent struct { func pullRequestFn(bytes []byte) (*discordgo.MessageSend, error) { var gh PullRequestEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var body string = gh.PullRequest.Body - if len(gh.PullRequest.Body) > 1000 { - body = gh.PullRequest.Body[:1000] + body := strings.TrimSpace(gh.PullRequest.Body) + if len(body) > 1800 { + body = body[:1797] + "…" } - if body == "" { - body = "No description available" + body = "_No description._" } - var color int + color := colorGreen if gh.Action == "closed" { color = colorRed - } else { - color = colorGreen + } else if gh.Action == "edited" || gh.Action == "labeled" || gh.Action == "unlabeled" || gh.Action == "synchronize" { + color = colorYellow + } + + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + desc := fmt.Sprintf("**%s** · #%d · **`%s`**\n\n%s", actionLabel, gh.PullRequest.Number, gh.PullRequest.State, body) + + branches := fmt.Sprintf("`%s` ← `%s`", gh.PullRequest.Base.Ref, gh.PullRequest.Head.Ref) + if gh.PullRequest.Base.Repo.FullName != "" || gh.PullRequest.Head.Repo.FullName != "" { + branches += fmt.Sprintf("\n%s into %s", gh.PullRequest.Head.Repo.MarkdownLink(), gh.PullRequest.Base.Repo.MarkdownLink()) + } + + thumb := gh.PullRequest.User.EmbedThumbnail() + if thumb == nil { + thumb = gh.Repo.OwnerThumbnail() } return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.PullRequest.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: fmt.Sprintf("Pull Request %s on %s (#%d)", gh.Action, gh.Repo.FullName, gh.PullRequest.Number), + Color: color, + URL: gh.PullRequest.HTMLURL, + Thumbnail: thumb, + Author: gh.Sender.AuthorEmbed(), + Title: "Pull request · " + gh.Repo.FullName + " · #" + strconv.Itoa(gh.PullRequest.Number), + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "Action", - Value: gh.Action, - }, - { - Name: "User", - Value: gh.Sender.Link(), - }, - { - Name: "Title", - Value: gh.PullRequest.Title, - }, - { - Name: "Body", - Value: body, - }, - { - Name: "More Information", - Value: fmt.Sprintf("**Base Ref:** %s\n**Base Label:** %s\n**Head Ref:** %s\n**Head Label:** %s", gh.PullRequest.Base.Ref, gh.PullRequest.Base.Label, gh.PullRequest.Head.Ref, gh.PullRequest.Head.Label), - }, + {Name: "Title", Value: gh.PullRequest.Title, Inline: false}, + {Name: "Branches", Value: branches, Inline: false}, + {Name: "PR author", Value: gh.PullRequest.User.Link(), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, + {Name: "Locked", Value: fmt.Sprintf("`%v`", gh.PullRequest.Locked), Inline: true}, }, }, }, diff --git a/webserver/logos/events/pull_request_review_comment.go b/webserver/logos/events/pull_request_review_comment.go index 5b965cf..66cc1d2 100644 --- a/webserver/logos/events/pull_request_review_comment.go +++ b/webserver/logos/events/pull_request_review_comment.go @@ -1,9 +1,13 @@ package events import ( + "fmt" "strconv" + "strings" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type PullRequestReviewCommentEvent struct { @@ -21,60 +25,56 @@ type PullRequestReviewCommentEvent struct { func pullRequestReviewCommentFn(bytes []byte) (*discordgo.MessageSend, error) { var gh PullRequestReviewCommentEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var body string = gh.PullRequest.Body - if len(gh.PullRequest.Body) > 1000 { - body = gh.PullRequest.Body[:1000] + prBody := strings.TrimSpace(gh.PullRequest.Body) + if len(prBody) > 400 { + prBody = prBody[:397] + "…" } - - if body == "" { - body = "No description available" + if prBody == "" { + prBody = "_No PR body._" } - var comment string = gh.Comment.Body - - if len(gh.Comment.Body) > 1000 { - comment = gh.Comment.Body[:1000] + "..." + comment := strings.TrimSpace(gh.Comment.Body) + if len(comment) > 1200 { + comment = comment[:1197] + "…" } - if comment == "" { - comment = "No description available" + comment = "_Empty review comment._" } - var color int + color := colorGreen if gh.Action == "deleted" { color = colorRed - } else { - color = colorGreen } + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + page := strings.TrimSpace(gh.Comment.HTMLURL) + if page == "" { + page = gh.PullRequest.HTMLURL + } + + desc := fmt.Sprintf("**%s** on [#%d — %s](%s)\n\n**Review comment**\n%s", + actionLabel, gh.PullRequest.Number, gh.PullRequest.Title, gh.PullRequest.HTMLURL, comment) + desc += "\n\n**PR excerpt**\n" + prBody + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { Color: color, - URL: gh.PullRequest.HTMLURL, + URL: page, + Thumbnail: gh.Sender.EmbedThumbnail(), Author: gh.Sender.AuthorEmbed(), - Description: comment, - Title: "Pull Request Review Comment on " + gh.Repo.FullName + " (#" + strconv.Itoa(gh.PullRequest.Number) + ")", + Title: "PR review comment · " + gh.Repo.FullName + " · #" + strconv.Itoa(gh.PullRequest.Number), + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Comment.User.Link(), - }, - { - Name: "Title", - Value: gh.PullRequest.Title, - }, - { - Name: "Parent Issue", - Value: body, - }, + {Name: "Comment author", Value: gh.Comment.User.Link(), Inline: true}, + {Name: "Webhook actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/push.go b/webserver/logos/events/push.go index 0cc4f74..5a93615 100644 --- a/webserver/logos/events/push.go +++ b/webserver/logos/events/push.go @@ -32,7 +32,6 @@ type PushEvent struct { func pushFn(bytes []byte) (*discordgo.MessageSend, error) { var gh PushEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -41,58 +40,75 @@ func pushFn(bytes []byte) (*discordgo.MessageSend, error) { var commitList string for _, commit := range gh.Commits { - fmt.Println(commit.Author) - - // If the username is empty, use the name instead if commit.Author.Username == "" { commit.Author.Username = commit.Author.Name } - if len(commit.Message) > 100 { - commit.Message = commit.Message[:100] + "..." + msg := commit.Message + if len(msg) > 100 { + msg = msg[:100] + "…" + } + + hash := strings.TrimSpace(commit.ID) + if len(hash) >= 7 { + hash = hash[:7] + } + if hash == "" { + hash = "???????" } - commitList += fmt.Sprintf("%s [``%s``](%s) | [%s](%s)\n", commit.Message, commit.ID[:7], commit.URL, commit.Author.Username, strings.ReplaceAll("https://github.com/"+commit.Author.Username, " ", "%20")) + authorURL := "https://github.com/" + strings.ReplaceAll(commit.Author.Username, " ", "%20") + commitURL := strings.TrimSpace(commit.URL) + line := fmt.Sprintf("• **%s** · `%s`", msg, hash) + if commitURL != "" { + line = fmt.Sprintf("• **%s** [`%s`](%s)", msg, hash, commitURL) + } + line += fmt.Sprintf(" · [%s](%s)\n", commit.Author.Username, authorURL) + commitList += line } if len(commitList) > 1024 { - commitList = commitList[:1024] + "..." + commitList = commitList[:1024] + "…" } if commitList == "" { - commitList = "No commits?" + commitList = "_No commits in payload._" } - branchInfo := "**Ref:** " + gh.Ref - + n := len(gh.Commits) + desc := fmt.Sprintf("**%d** commit(s) pushed to **`%s`**", n, gh.Ref) if gh.BaseRef != "" { - branchInfo = "\n" + "**Base Ref:** " + gh.BaseRef + desc += "\n**Base ref:** `" + gh.BaseRef + "`" + } + + pusherVal := gh.Sender.Link() + if strings.TrimSpace(gh.Pusher.Name) != "" { + pn := strings.ReplaceAll(gh.Pusher.Name, " ", "%20") + pusherVal = fmt.Sprintf("[%s](https://github.com/%s)", gh.Pusher.Name, pn) } return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Push on " + gh.Repo.FullName, + Color: colorGreen, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Push · " + gh.Repo.FullName, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "Branch", - Value: branchInfo, - }, { Name: "Commits", Value: commitList, }, { - Name: "Commit Sender", - Value: gh.Sender.Link(), + Name: "Pusher (name)", + Value: pusherVal, Inline: true, }, { - Name: "Pusher", - Value: fmt.Sprintf("[%s](%s)", gh.Pusher.Name, "https://github.com/"+gh.Pusher.Name), + Name: "Webhook actor", + Value: gh.Sender.Link(), Inline: true, }, }, diff --git a/webserver/logos/events/release.go b/webserver/logos/events/release.go index 920de21..d761d6f 100644 --- a/webserver/logos/events/release.go +++ b/webserver/logos/events/release.go @@ -1,6 +1,8 @@ package events import ( + "strings" + "github.com/bwmarrin/discordgo" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -20,49 +22,54 @@ type ReleaseEvent struct { func releaseFn(bytes []byte) (*discordgo.MessageSend, error) { var gh ReleaseEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var color int - var title string = cases.Title(language.English).String(gh.Action) + " release on " + gh.Repo.FullName - if gh.Action == "created" || gh.Action == "published" || gh.Action == "edited" || gh.Action == "prereleased" || gh.Action == "released" { - color = colorGreen - } else { + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + color := colorGreen + if gh.Action == "deleted" || gh.Action == "unpublished" { color = colorRed + } else if gh.Action == "edited" || gh.Action == "prereleased" { + color = colorYellow } - var body string = gh.Release.Body - if len(gh.Release.Body) > 996 { - body = gh.Release.Body[:996] + "..." + body := strings.TrimSpace(gh.Release.Body) + if len(body) > 1800 { + body = body[:1797] + "…" } - if body == "" { - body = "No description available" + body = "_No release notes._" + } + + page := strings.TrimSpace(gh.Release.HTMLUrl) + if page == "" { + page = gh.Repo.HTMLURL + } + + tag := strings.TrimSpace(gh.Release.TagName) + if tag == "" { + tag = "Release" } + desc := "**" + actionLabel + "** · [`" + tag + "`](" + page + ")\n\n" + body + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { Color: color, - URL: gh.Repo.HTMLURL, - Title: title, + URL: page, + Thumbnail: gh.Repo.OwnerThumbnail(), + Title: "Release · " + gh.Repo.FullName, Author: gh.Sender.AuthorEmbed(), - Description: body, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Release", - Value: "[" + gh.Release.TagName + "]" + "(" + gh.Release.HTMLUrl + ")", - Inline: true, - }, + {Name: "Repository", Value: gh.Repo.MarkdownLink(), Inline: false}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, + {Name: "Tag", Value: "`" + gh.Release.TagName + "`", Inline: true}, }, }, }, diff --git a/webserver/logos/events/repository.go b/webserver/logos/events/repository.go index 5d16191..a27e5bd 100644 --- a/webserver/logos/events/repository.go +++ b/webserver/logos/events/repository.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type RepositoryEvent struct { @@ -15,35 +17,42 @@ type RepositoryEvent struct { func repositoryFn(bytes []byte) (*discordgo.MessageSend, error) { var gh RepositoryEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var color int - var title string - if gh.Action == "created" { - color = colorGreen - title = "Created: " + gh.Repo.FullName - } else { + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + + color := colorGreen + if gh.Action != "created" { + color = colorYellow + } + if gh.Action == "deleted" { color = colorRed - title = strings.ToUpper(gh.Action) + ": " + gh.Repo.FullName + } + + desc := "**" + actionLabel + "** · " + gh.Repo.MarkdownLink() + if strings.TrimSpace(gh.Repo.Description) != "" { + d := gh.Repo.Description + if len(d) > 400 { + d = d[:397] + "…" + } + desc += "\n\n_" + d + "_" } return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.Repo.HTMLURL, - Title: title, - Author: gh.Sender.AuthorEmbed(), + Color: color, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Title: "Repository · " + gh.Repo.FullName, + Author: gh.Sender.AuthorEmbed(), + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/repository_vulnerability_alert.go b/webserver/logos/events/repository_vulnerability_alert.go new file mode 100644 index 0000000..7a9e033 --- /dev/null +++ b/webserver/logos/events/repository_vulnerability_alert.go @@ -0,0 +1,342 @@ +package events + +import ( + "fmt" + "strconv" + "strings" + + "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// RepositoryVulnerabilityAlertEvent is the legacy GitHub webhook (superseded by dependabot_alert for many orgs). +// Structs include every field GitHub is known to send so nothing is dropped on unmarshal. +type RepositoryVulnerabilityAlertEvent struct { + Action string `json:"action"` + Alert repoVulnAlert `json:"alert"` + Repository repoVulnRepository `json:"repository"` + Sender User `json:"sender"` + Organization *User `json:"organization"` +} + +type repoVulnAlert struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + Name string `json:"name"` + State string `json:"state"` + Severity string `json:"severity"` + AffectedPackageName string `json:"affected_package_name"` + AffectedRange string `json:"affected_range"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ExternalIdentifier string `json:"external_identifier"` + ExternalReference string `json:"external_reference"` + GhsaID string `json:"ghsa_id"` + CVEID string `json:"cve_id"` + FixedIn string `json:"fixed_in"` + FixedAt string `json:"fixed_at"` + FixReason string `json:"fix_reason"` + DismissedAt string `json:"dismissed_at"` + DismissReason string `json:"dismiss_reason"` + DismissComment string `json:"dismiss_comment"` + Dismisser *User `json:"dismisser"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` +} + +// repoVulnRepository captures the repository object as sent on this webhook (superset of Repository for display). +type repoVulnRepository struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + PushedAt string `json:"pushed_at"` + Size int64 `json:"size"` + Archived bool `json:"archived"` + Disabled bool `json:"disabled"` + Visibility string `json:"visibility"` + Topics []string `json:"topics"` + Owner User `json:"owner"` +} + +func repoVulnSeverityEmoji(sev string) string { + switch strings.ToLower(strings.TrimSpace(sev)) { + case "critical": + return "⛔" + case "high": + return "🔥" + case "medium": + return "⚠️" + case "low": + return "🟡" + default: + return "🛡️" + } +} + +func repoVulnRepoMarkdown(r *repoVulnRepository) string { + name := strings.TrimSpace(r.FullName) + if name == "" { + name = strings.TrimSpace(r.Name) + } + if name == "" { + return "—" + } + url := strings.TrimSpace(r.HTMLURL) + if url != "" { + return "[" + name + "](" + url + ")" + } + return name +} + +func repoVulnAdvisoryMarkdown(a *repoVulnAlert) string { + var parts []string + ref := strings.TrimSpace(a.ExternalReference) + if ref != "" && strings.HasPrefix(ref, "http") { + if id := strings.TrimSpace(a.GhsaID); id != "" { + parts = append(parts, fmt.Sprintf("[**%s**](%s)", id, ref)) + } else { + parts = append(parts, "[**Advisory**]("+ref+")") + } + } else if id := strings.TrimSpace(a.GhsaID); id != "" { + parts = append(parts, "**"+id+"**") + } + ext := strings.TrimSpace(a.ExternalIdentifier) + if ext != "" { + if strings.HasPrefix(strings.ToUpper(ext), "CVE-") { + parts = append(parts, "**"+ext+"**") + } else { + parts = append(parts, "**ID:** "+ext) + } + } + cve := strings.TrimSpace(a.CVEID) + if cve != "" && !strings.Contains(strings.Join(parts, " "), cve) { + if strings.HasPrefix(strings.ToUpper(cve), "CVE-") { + parts = append(parts, "**"+cve+"**") + } else { + parts = append(parts, "**CVE-"+cve+"**") + } + } + if len(parts) == 0 { + return "—" + } + return strings.Join(parts, " · ") +} + +func repoVulnRemediation(a *repoVulnAlert) string { + var b strings.Builder + if fi := strings.TrimSpace(a.FixedIn); fi != "" { + b.WriteString("Upgrade to **`") + b.WriteString(fi) + b.WriteString("`** or newer.") + } + if fa := strings.TrimSpace(a.FixedAt); fa != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString("_Resolved at:_ ") + b.WriteString(fa) + } + if fr := strings.TrimSpace(a.FixReason); fr != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(fr) + } + if b.Len() == 0 { + return "—" + } + return b.String() +} + +func repoVulnDismissalCompact(a *repoVulnAlert) string { + if strings.TrimSpace(a.DismissedAt) == "" && strings.TrimSpace(a.DismissReason) == "" && (a.Dismisser == nil || strings.TrimSpace(a.Dismisser.Login) == "") { + return "" + } + var b strings.Builder + if t := strings.TrimSpace(a.DismissedAt); t != "" { + b.WriteString("_Dismissed:_ " + t) + } + if r := strings.TrimSpace(a.DismissReason); r != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString("**Reason:** " + r) + } + if c := strings.TrimSpace(a.DismissComment); c != "" { + if len(c) > 280 { + c = c[:277] + "…" + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(c) + } + if a.Dismisser != nil && strings.TrimSpace(a.Dismisser.Login) != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString("**By:** " + a.Dismisser.Link()) + } + return b.String() +} + +// RepositoryVulnerabilityAlert renders legacy GitHub repository_vulnerability_alert webhooks. +func RepositoryVulnerabilityAlert(bytes []byte) (*discordgo.MessageSend, error) { + var gh RepositoryVulnerabilityAlertEvent + if err := json.Unmarshal(bytes, &gh); err != nil { + return nil, err + } + + color := colorYellow + actionLower := strings.ToLower(strings.TrimSpace(gh.Action)) + switch actionLower { + case "resolve": + color = colorGreen + case "dismiss": + color = colorRed + default: + switch strings.ToLower(strings.TrimSpace(gh.Alert.Severity)) { + case "critical", "high": + color = colorDarkRed + case "medium": + color = colorYellow + case "low": + color = 0x3FB950 + } + } + + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(actionLower, "_", " ")) + if actionLabel == "" { + actionLabel = "Update" + } + + embedURL := strings.TrimSpace(gh.Repository.HTMLURL) + if u := strings.TrimSpace(gh.Alert.HTMLURL); u != "" { + embedURL = u + } else if u := strings.TrimSpace(gh.Alert.ExternalReference); u != "" && strings.HasPrefix(u, "http") { + embedURL = u + } + + repoTitle := strings.TrimSpace(gh.Repository.FullName) + if repoTitle == "" { + repoTitle = strings.TrimSpace(gh.Repository.Name) + } + if repoTitle == "" { + repoTitle = "Repository" + } + + pkg := strings.TrimSpace(gh.Alert.AffectedPackageName) + if pkg == "" { + pkg = "Dependency" + } + rng := strings.TrimSpace(gh.Alert.AffectedRange) + sev := strings.TrimSpace(gh.Alert.Severity) + if sev == "" { + sev = "unspecified" + } + + desc := fmt.Sprintf("%s **%s** — **%s** severity on **`%s`**", + repoVulnSeverityEmoji(gh.Alert.Severity), actionLabel, cases.Title(language.English).String(strings.ToLower(sev)), pkg) + if rng != "" { + desc += "\n```\n" + rng + "\n```" + } + + var thumb *discordgo.MessageEmbedThumbnail + if u := strings.TrimSpace(gh.Repository.Owner.AvatarURL); u != "" { + thumb = &discordgo.MessageEmbedThumbnail{URL: u} + } + + fields := []*discordgo.MessageEmbedField{ + {Name: "Repository", Value: truncateField(repoVulnRepoMarkdown(&gh.Repository), 1024), Inline: false}, + {Name: "Advisory", Value: truncateField(repoVulnAdvisoryMarkdown(&gh.Alert), 1024), Inline: false}, + {Name: "Remediation", Value: truncateField(repoVulnRemediation(&gh.Alert), 1024), Inline: false}, + {Name: "Context", Value: truncateField(metaLine(&gh), 1024), Inline: false}, + } + if d := repoVulnDismissalCompact(&gh.Alert); d != "" { + fields = append(fields, &discordgo.MessageEmbedField{Name: "Dismissal", Value: truncateField(d, 1024), Inline: false}) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: embedURL, + Thumbnail: thumb, + Author: gh.Sender.AuthorEmbed(), + Title: repoTitle + " · Security alert", + Description: desc, + Fields: trimEmbedFields(fields, 25), + }, + }, + }, nil +} + +func metaLine(gh *RepositoryVulnerabilityAlertEvent) string { + var parts []string + if gh.Alert.State != "" { + parts = append(parts, "**State:** "+gh.Alert.State) + } + if gh.Alert.Number != 0 { + parts = append(parts, "**#:** "+strconv.Itoa(gh.Alert.Number)) + } + if t := strings.TrimSpace(gh.Alert.CreatedAt); t != "" { + parts = append(parts, "**Detected:** "+t) + } + if gh.Organization != nil && strings.TrimSpace(gh.Organization.Login) != "" { + parts = append(parts, "**Organization:** "+gh.Organization.Link()) + } + if len(parts) == 0 { + return "—" + } + return strings.Join(parts, "\n") +} + +func truncateField(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-1] + "…" +} + +func trimEmbedFields(fields []*discordgo.MessageEmbedField, max int) []*discordgo.MessageEmbedField { + if len(fields) <= max { + return fields + } + return fields[:max] +} + +// LooksLikeRepositoryVulnerabilityAlert detects the legacy Dependabot security payload that uses +// alert.affected_package_name / alert.affected_range (modern dependabot_alert uses a different shape). +// Used when X-GitHub-Event does not match the map key (proxies, stray whitespace, etc.). +func LooksLikeRepositoryVulnerabilityAlert(body []byte) bool { + var probe struct { + Alert struct { + AffectedPackageName string `json:"affected_package_name"` + AffectedRange string `json:"affected_range"` + Dependency *struct { + Package struct { + Name string `json:"name"` + } `json:"package"` + } `json:"dependency"` + } `json:"alert"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return false + } + if probe.Alert.Dependency != nil && strings.TrimSpace(probe.Alert.Dependency.Package.Name) != "" { + return false + } + return strings.TrimSpace(probe.Alert.AffectedPackageName) != "" +} diff --git a/webserver/logos/events/secret_scanning_alert.go b/webserver/logos/events/secret_scanning_alert.go index 8cd9133..511528d 100644 --- a/webserver/logos/events/secret_scanning_alert.go +++ b/webserver/logos/events/secret_scanning_alert.go @@ -99,7 +99,7 @@ func secretScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { Color: color, URL: gh.Alert.HTMLURL, Author: gh.Sender.AuthorEmbed(), - Title: fmt.Sprintf("Secret Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Title: fmt.Sprintf("Secret Scanning Alert #%d · %s · %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), Fields: fields, }, }, diff --git a/webserver/logos/events/star.go b/webserver/logos/events/star.go index e70583e..c43c41b 100644 --- a/webserver/logos/events/star.go +++ b/webserver/logos/events/star.go @@ -13,7 +13,6 @@ type StarEvent struct { func starFn(bytes []byte) (*discordgo.MessageSend, error) { var gh StarEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -22,26 +21,26 @@ func starFn(bytes []byte) (*discordgo.MessageSend, error) { var color int var title string + var desc string if gh.Action == "created" { color = colorGreen - title = "Starred: " + gh.Repo.FullName + title = "Star · " + gh.Repo.FullName + desc = gh.Sender.Link() + " starred " + gh.Repo.MarkdownLink() + " ✨" } else { color = colorRed - title = "Unstarred: " + gh.Repo.FullName + title = "Unstar · " + gh.Repo.FullName + desc = gh.Sender.Link() + " removed their star from " + gh.Repo.MarkdownLink() } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.Repo.HTMLURL, - Title: title, - Author: gh.Sender.AuthorEmbed(), - Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - }, + Color: color, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Sender.EmbedThumbnail(), + Title: title, + Author: gh.Sender.AuthorEmbed(), + Description: desc, }, }, }, nil diff --git a/webserver/logos/events/status.go b/webserver/logos/events/status.go index 8b83a05..34ae277 100644 --- a/webserver/logos/events/status.go +++ b/webserver/logos/events/status.go @@ -1,7 +1,7 @@ package events import ( - "fmt" + "strings" "github.com/bwmarrin/discordgo" ) @@ -30,45 +30,61 @@ type StatusEvent struct { func statusFn(bytes []byte) (*discordgo.MessageSend, error) { var gh StatusEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var moreInfoMsg string - if gh.TargetURL != "" { - moreInfoMsg = "\n\nFor more information, " + gh.TargetURL + color := colorYellow + switch strings.ToLower(strings.TrimSpace(gh.State)) { + case "success", "successful": + color = colorGreen + case "failure", "error", "failed": + color = colorRed + case "pending": + color = colorYellow } - if gh.Context == "" { - gh.Context = "-" + ctx := strings.TrimSpace(gh.Context) + if ctx == "" { + ctx = "—" } + desc := strings.TrimSpace(gh.Description) + if desc == "" { + desc = "_No status description._" + } + if u := strings.TrimSpace(gh.TargetURL); u != "" { + desc += "\n\n[**Open status target**](" + u + ")" + } + + sha := strings.TrimSpace(gh.Commit.SHA) + commitLine := gh.Repo.Commit(sha) + msg := strings.TrimSpace(gh.Commit.Commit.Message) + if len(msg) > 200 { + msg = msg[:197] + "…" + } + if msg != "" { + commitLine += "\n_" + msg + "_" + } + + author := User{Login: gh.Commit.Author.Login, HTMLURL: gh.Commit.Author.HTMLURL} + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, + Color: color, URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), Author: gh.Sender.AuthorEmbed(), - Title: "Status " + gh.State + " on " + gh.Repo.FullName, - Description: gh.Description + moreInfoMsg, + Title: "Commit status · " + gh.Repo.FullName + " · `" + gh.State + "`", + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "Commit", - Value: fmt.Sprintf("[``%s``](%s) - %s | [%s](%s)", gh.Commit.SHA[:7], gh.Commit.HTMLURL, gh.Commit.Commit.Message, gh.Commit.Author.Login, gh.Commit.Author.HTMLURL), - }, - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Context", - Value: gh.Context, - Inline: true, - }, + {Name: "Commit", Value: commitLine, Inline: false}, + {Name: "Context", Value: "`" + ctx + "`", Inline: true}, + {Name: "Commit author", Value: author.Link(), Inline: true}, + {Name: "Webhook actor", Value: gh.Sender.Link(), Inline: true}, }, }, }, diff --git a/webserver/logos/events/watch.go b/webserver/logos/events/watch.go index 529e174..60dc052 100644 --- a/webserver/logos/events/watch.go +++ b/webserver/logos/events/watch.go @@ -1,7 +1,11 @@ package events import ( + "strings" + "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type WatchEvent struct { @@ -13,30 +17,25 @@ type WatchEvent struct { func watchFn(bytes []byte) (*discordgo.MessageSend, error) { var gh WatchEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { return &discordgo.MessageSend{}, err } - var color int - var title string - color = colorGreen - title = "Watch " + gh.Action + ": " + gh.Repo.FullName + actionLabel := cases.Title(language.English).String(strings.ReplaceAll(gh.Action, "_", " ")) + color := colorGreen + desc := gh.Sender.Link() + " · _" + actionLabel + "_ on " + gh.Repo.MarkdownLink() + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: color, - URL: gh.Repo.HTMLURL, - Title: title, - Author: gh.Sender.AuthorEmbed(), - Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - }, - }, + Color: color, + URL: gh.Repo.HTMLURL, + Thumbnail: gh.Repo.OwnerThumbnail(), + Title: "Watch · " + gh.Repo.FullName, + Author: gh.Sender.AuthorEmbed(), + Description: desc, }, }, }, nil diff --git a/webserver/logos/events/workflow_job.go b/webserver/logos/events/workflow_job.go index c18f222..7f37cd5 100644 --- a/webserver/logos/events/workflow_job.go +++ b/webserver/logos/events/workflow_job.go @@ -1,9 +1,10 @@ package events import ( - "github.com/bwmarrin/discordgo" - "strconv" + "strings" + + "github.com/bwmarrin/discordgo" ) type WorkflowJobEvent struct { @@ -46,7 +47,6 @@ type WorkflowJobEvent struct { func workflowJobFn(bytes []byte) (*discordgo.MessageSend, error) { var gh WorkflowJobEvent - // Unmarshal the JSON into our struct err := json.Unmarshal(bytes, &gh) if err != nil { @@ -61,63 +61,57 @@ func workflowJobFn(bytes []byte) (*discordgo.MessageSend, error) { gh.WorkflowJob.Status = "No status yet!" } - var fields = []*discordgo.MessageEmbedField{ - { - Name: "Workflow Name", - Value: gh.WorkflowJob.WorkflowName, - Inline: true, - }, - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Status", - Value: gh.WorkflowJob.Status, - Inline: true, - }, - { - Name: "Conclusion", - Value: gh.WorkflowJob.Conclusion, - Inline: true, - }, - { - Name: "Branch", - Value: gh.WorkflowJob.HeadBranch, - Inline: true, - }, - { - Name: "URL", - Value: gh.WorkflowJob.HTMLURL, - Inline: true, - }, + page := strings.TrimSpace(gh.WorkflowJob.HTMLURL) + if page == "" { + page = strings.TrimSpace(gh.WorkflowJob.RunURL) + } + if page == "" { + page = gh.Repo.HTMLURL + } + + color := CheckConclusionEmbedColor(gh.WorkflowJob.Conclusion) + if strings.EqualFold(gh.WorkflowJob.Status, "in_progress") || strings.EqualFold(gh.WorkflowJob.Status, "queued") { + color = colorYellow + } + + desc := "**" + gh.WorkflowJob.Name + "** · `" + gh.Action + "`\n" + desc += gh.Repo.MarkdownLink() + "\n**Workflow:** `" + gh.WorkflowJob.WorkflowName + "` · **Attempt:** `" + strconv.Itoa(gh.WorkflowJob.RunAttempt) + "`" + if page != "" && strings.HasPrefix(page, "http") { + desc += "\n\n[**View job**](" + page + ")" + } + + fields := []*discordgo.MessageEmbedField{ + {Name: "Status", Value: "`" + gh.WorkflowJob.Status + "`", Inline: true}, + {Name: "Conclusion", Value: "`" + gh.WorkflowJob.Conclusion + "`", Inline: true}, + {Name: "Branch", Value: "`" + gh.WorkflowJob.HeadBranch + "`", Inline: true}, + {Name: "Commit", Value: gh.Repo.Commit(gh.WorkflowJob.HeadSHA), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, } for _, step := range gh.WorkflowJob.Steps { if step.Conclusion == "" { step.Conclusion = "No conclusion yet!" } - if step.Status == "" { step.Status = "No status yet!" } - fields = append(fields, &discordgo.MessageEmbedField{ - Name: "Step " + strconv.Itoa(step.Number) + " (" + step.Name + ")", - Value: "Status: " + step.Status + "\nConclusion: " + step.Conclusion, - Inline: true, + Name: "Step " + strconv.Itoa(step.Number) + " · " + step.Name, + Value: "**Status:** `" + step.Status + "`\n**Conclusion:** `" + step.Conclusion + "`", + Inline: false, }) } return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Workflow Job: " + gh.WorkflowJob.Name, - Fields: fields, + Color: color, + URL: page, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Workflow job · " + gh.Repo.FullName, + Description: desc, + Fields: fields, }, }, }, nil diff --git a/webserver/logos/events/workflow_run.go b/webserver/logos/events/workflow_run.go index 0072398..8627e3c 100644 --- a/webserver/logos/events/workflow_run.go +++ b/webserver/logos/events/workflow_run.go @@ -2,6 +2,7 @@ package events import ( "fmt" + "strings" "github.com/bwmarrin/discordgo" ) @@ -20,6 +21,7 @@ type WorkflowRunEvent struct { Status string `json:"status"` Conclusion string `json:"conclusion"` URL string `json:"url"` + HTMLURL string `json:"html_url"` TriggeringActor User `json:"triggering_actor"` HeadCommit struct { ID string `json:"id"` @@ -50,58 +52,49 @@ func workflowRunFn(bytes []byte) (*discordgo.MessageSend, error) { gh.WorkflowRun.Status = "No status yet!" } + page := strings.TrimSpace(gh.WorkflowRun.HTMLURL) + if page == "" { + page = gh.Repo.HTMLURL + } + + color := CheckConclusionEmbedColor(gh.WorkflowRun.Conclusion) + if strings.EqualFold(gh.WorkflowRun.Status, "in_progress") || strings.EqualFold(gh.WorkflowRun.Status, "queued") || strings.EqualFold(gh.WorkflowRun.Status, "waiting") { + color = colorYellow + } + + desc := fmt.Sprintf("**Run #%d** · _%s_", gh.WorkflowRun.RunNumber, gh.Action) + if msg := strings.TrimSpace(gh.WorkflowRun.HeadCommit.Message); msg != "" { + if len(msg) > 200 { + msg = msg[:197] + "…" + } + desc += "\n\n**Head commit:** " + msg + } + if page != "" && strings.HasPrefix(page, "http") { + desc += "\n\n[**View workflow run**](" + page + ")" + } + + trigger := "—" + if strings.TrimSpace(gh.WorkflowRun.TriggeringActor.Login) != "" { + trigger = gh.WorkflowRun.TriggeringActor.Link() + } + return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{ { - Color: colorGreen, - URL: gh.Repo.HTMLURL, - Author: gh.Sender.AuthorEmbed(), - Title: "Workflow Run: " + gh.WorkflowRun.Name, + Color: color, + URL: page, + Thumbnail: gh.Repo.OwnerThumbnail(), + Author: gh.Sender.AuthorEmbed(), + Title: "Actions · " + gh.WorkflowRun.Name, + Description: desc, Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: gh.Sender.Link(), - Inline: true, - }, - { - Name: "Status", - Value: gh.WorkflowRun.Status, - Inline: true, - }, - { - Name: "Conclusion", - Value: gh.WorkflowRun.Conclusion, - Inline: true, - }, - { - Name: "Branch", - Value: gh.WorkflowRun.HeadBranch, - Inline: true, - }, - { - Name: "Commit", - Value: gh.Repo.Commit(gh.WorkflowRun.HeadCommit.ID), - Inline: true, - }, - { - Name: "URL", - Value: gh.WorkflowRun.URL, - Inline: true, - }, - { - Name: "Event", - Value: gh.WorkflowRun.Event, - Inline: true, - }, - { - Name: "Run Number", - Value: fmt.Sprintf("%d", gh.WorkflowRun.RunNumber), - Inline: true, - }, - { - Name: "Triggered By", - Value: gh.WorkflowRun.TriggeringActor.Link(), - }, + {Name: "Status", Value: "`" + gh.WorkflowRun.Status + "`", Inline: true}, + {Name: "Conclusion", Value: "`" + gh.WorkflowRun.Conclusion + "`", Inline: true}, + {Name: "Event", Value: "`" + gh.WorkflowRun.Event + "`", Inline: true}, + {Name: "Branch", Value: "`" + gh.WorkflowRun.HeadBranch + "`", Inline: true}, + {Name: "Commit", Value: gh.Repo.Commit(gh.WorkflowRun.HeadCommit.ID), Inline: true}, + {Name: "Actor", Value: gh.Sender.Link(), Inline: true}, + {Name: "Triggered by", Value: trigger, Inline: false}, }, }, }, diff --git a/webserver/ontos/ontos.go b/webserver/ontos/ontos.go index 2f37be3..0d93969 100644 --- a/webserver/ontos/ontos.go +++ b/webserver/ontos/ontos.go @@ -25,9 +25,8 @@ import ( func formatBool(b bool) string { if b { return "true" - } else { - return "false" } + return "false" } func GetWebhookRoute(w http.ResponseWriter, r *http.Request) { @@ -193,7 +192,7 @@ func handleGitHubWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byt return } - if r.Header.Get("X-GitHub-Event") == "ping" { + if pneuma.NormalizeGitHubEventHeader(r.Header.Get("X-GitHub-Event")) == "ping" { w.WriteHeader(200) w.Write([]byte("pong")) return @@ -210,7 +209,7 @@ func handleGitHubWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byt return } - var header = r.Header.Get("X-GitHub-Event") + var header = pneuma.NormalizeGitHubEventHeader(r.Header.Get("X-GitHub-Event")) // Get repo_name from database var repoName string @@ -218,9 +217,9 @@ func handleGitHubWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byt err = state.Pool.QueryRow(state.Context, "SELECT id, repo_name FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", strings.ToLower(rw.Repo.FullName), id).Scan(&repoID, &repoName) if err != nil { - state.Logger.Warn("This repository is not configured on git-logs, ignoring", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", id)) + state.Logger.Warn("This repository is not configured on Octoflow, ignoring", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", id)) w.WriteHeader(http.StatusPartialContent) - w.Write([]byte("This repository is not configured on git-logs, ignoring")) + w.Write([]byte("This repository is not configured on Octoflow, ignoring")) return } @@ -285,15 +284,15 @@ func handleGitLabWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byt err := state.Pool.QueryRow(state.Context, "SELECT id, repo_name FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", repoFullName, id).Scan(&repoID, &repoName) if err != nil { - state.Logger.Warn("This repository is not configured on git-logs, ignoring", zap.Error(err), zap.String("repoName", repoFullName), zap.String("webhookID", id)) + state.Logger.Warn("This repository is not configured on Octoflow, ignoring", zap.Error(err), zap.String("repoName", repoFullName), zap.String("webhookID", id)) w.WriteHeader(http.StatusPartialContent) - w.Write([]byte("This repository is not configured on git-logs, ignoring")) + w.Write([]byte("This repository is not configured on Octoflow, ignoring")) return } - // Create a synthetic RepoWrapper for GitLab + // Create a synthetic RepoWrapper for GitLab (display path keeps webhook casing; DB match uses repoFullName). var rw events.RepoWrapper - rw.Repo.FullName = repoFullName + rw.Repo.FullName = strings.TrimSpace(glPayload.Project.PathWithNamespace) rw.Repo.HTMLURL = glPayload.Project.WebURL rw.Action = glPayload.ObjectKind diff --git a/webserver/pneuma/fallback_embed.go b/webserver/pneuma/fallback_embed.go new file mode 100644 index 0000000..1863dac --- /dev/null +++ b/webserver/pneuma/fallback_embed.go @@ -0,0 +1,202 @@ +package pneuma + +import ( + "encoding/json" + "reflect" + "strconv" + "strings" + + "github.com/bwmarrin/discordgo" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// toJSONObject normalizes JSON-decoded objects to map[string]any. Some decoders or edge cases +// yield map types that do not match a plain type switch on map[string]any, so we also use reflection. +func toJSONObject(v any) (map[string]any, bool) { + if v == nil { + return nil, false + } + switch t := v.(type) { + case map[string]any: + return t, true + default: + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { + out := make(map[string]any, rv.Len()) + for _, k := range rv.MapKeys() { + out[k.String()] = rv.MapIndex(k).Interface() + } + return out, true + } + return nil, false + } +} + +func markdownLink(label, url string) string { + label = strings.TrimSpace(label) + url = strings.TrimSpace(url) + if label == "" { + return "—" + } + if url == "" { + return label + } + return "[" + strings.ReplaceAll(label, " ", "%20") + "](" + url + ")" +} + +func appendNestedDiscordFields(out *[]*discordgo.MessageEmbedField, key string, v any) { + m, ok := toJSONObject(v) + if !ok { + return + } + k := strings.ToLower(strings.TrimSpace(key)) + switch k { + case "sender": + if login, ok := m["login"].(string); ok && login != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "Sender", Value: login, Inline: true}) + } + case "user": + if login, ok := m["login"].(string); ok && login != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "User", Value: login, Inline: true}) + } + case "owner": + if login, ok := m["login"].(string); ok && login != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "Owner", Value: login, Inline: true}) + } + case "pusher": + if name, ok := m["name"].(string); ok && name != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "Pusher", Value: name, Inline: true}) + } + case "repository", "project": + if fn, ok := m["full_name"].(string); ok && fn != "" { + html, _ := m["html_url"].(string) + *out = append(*out, &discordgo.MessageEmbedField{Name: "Repository", Value: markdownLink(fn, html), Inline: true}) + } else if pn, ok := m["path_with_namespace"].(string); ok && pn != "" { + web, _ := m["web_url"].(string) + *out = append(*out, &discordgo.MessageEmbedField{Name: "Repository", Value: markdownLink(pn, web), Inline: true}) + } else if nm, ok := m["name"].(string); ok && nm != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "Repository", Value: nm, Inline: true}) + } + case "organization", "org": + login, _ := m["login"].(string) + login = strings.TrimSpace(login) + if login == "" { + return + } + htmlURL, _ := m["html_url"].(string) + url := strings.TrimSpace(htmlURL) + if url == "" { + url = "https://github.com/" + login + } + *out = append(*out, &discordgo.MessageEmbedField{ + Name: "Organization", + Value: "[" + strings.ReplaceAll(login, " ", "%20") + "](" + url + ")", + Inline: true, + }) + case "label": + name, _ := m["name"].(string) + clr, _ := m["color"].(string) + val := name + switch { + case name != "" && clr != "": + val = name + " (`#" + clr + "`)" + case clr != "": + val = "`#" + clr + "`" + } + if val != "" { + *out = append(*out, &discordgo.MessageEmbedField{Name: "Label", Value: val, Inline: true}) + } + case "alert": + var lines []string + if s, ok := m["affected_package_name"].(string); ok && s != "" { + lines = append(lines, "**Package:** "+s) + } + if s, ok := m["affected_range"].(string); ok && s != "" { + lines = append(lines, "**Range:** "+s) + } + if s, ok := m["ghsa_id"].(string); ok && s != "" { + lines = append(lines, "**GHSA:** "+s) + } + if s, ok := m["external_identifier"].(string); ok && s != "" { + lines = append(lines, "**ID:** "+s) + } + if len(lines) > 0 { + val := strings.Join(lines, "\n") + if len(val) > 900 { + val = val[:900] + "…" + } + *out = append(*out, &discordgo.MessageEmbedField{Name: "Alert", Value: val, Inline: false}) + } + } +} + +func scalarDiscordFieldValue(v any) (string, bool) { + switch t := v.(type) { + case string: + return t, true + case bool: + return strconv.FormatBool(t), true + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10), true + } + return strconv.FormatFloat(t, 'f', -1, 64), true + case json.Number: + return t.String(), true + case nil: + return "", false + default: + return "", false + } +} + +// buildFallbackWebhookEmbedFields turns arbitrary webhook JSON into short embed fields without dumping +// raw Go map representations (which Discord users saw as unreadable "map[...]" text). +func buildFallbackWebhookEmbedFields(fields map[string]any) []*discordgo.MessageEmbedField { + var out []*discordgo.MessageEmbedField + + for k, v := range fields { + if _, ok := toJSONObject(v); ok { + appendNestedDiscordFields(&out, k, v) + } + } + + seen := make(map[string]struct{}) + for _, f := range out { + seen[strings.ToLower(f.Name)] = struct{}{} + } + + for k, v := range fields { + if _, ok := toJSONObject(v); ok { + continue + } + s, ok := scalarDiscordFieldValue(v) + if !ok || strings.TrimSpace(s) == "" { + continue + } + name := cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")) + lname := strings.ToLower(name) + if _, dup := seen[lname]; dup { + continue + } + seen[lname] = struct{}{} + val := s + if len(val) > 200 { + val = val[:200] + "…" + } + out = append(out, &discordgo.MessageEmbedField{Name: name, Value: val, Inline: true}) + } + + if len(out) == 0 { + out = append(out, &discordgo.MessageEmbedField{ + Name: "Notice", + Value: "No safe scalar fields were extracted from this payload (nested objects are omitted here).", + Inline: false, + }) + } + if len(out) > EMBED_FIELDS_MAX_COUNT { + out = out[:EMBED_FIELDS_MAX_COUNT] + } + return out +} diff --git a/webserver/pneuma/pneuma.go b/webserver/pneuma/pneuma.go index 5c16c95..8af8ff8 100644 --- a/webserver/pneuma/pneuma.go +++ b/webserver/pneuma/pneuma.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/git-logs/client/webserver/logos/eventmodifiers" "github.com/git-logs/client/webserver/logos/events" @@ -35,6 +36,12 @@ const ( EMBED_TOTAL_LIMIT = 6000 ) +// NormalizeGitHubEventHeader trims BOM/whitespace and lowercases GitHub's X-GitHub-Event value. +func NormalizeGitHubEventHeader(h string) string { + h = strings.TrimPrefix(strings.TrimSpace(h), "\ufeff") + return strings.ToLower(h) +} + func updateLogEntries(logId, webhookId, guildId string, entries ...any) error { // Check for log_id in database var count int @@ -136,6 +143,8 @@ func HandleEvents( l := state.MapMutex.Lock(webhookId) defer l.Unlock() + header = NormalizeGitHubEventHeader(header) + updateLogEntries(logId, webhookId, guildId, "Processing event: "+header, "provider="+provider, "repoName="+rw.Repo.FullName, "webhookID="+webhookId, "event="+header, "logId="+logId) // Check event modifiers @@ -205,6 +214,10 @@ func HandleEvents( evtFn, ok = events.GitLabSupportedEvents[header] } else { evtFn, ok = events.SupportedEvents[header] + if !ok && events.LooksLikeRepositoryVulnerabilityAlert(bodyBytes) { + evtFn = events.RepositoryVulnerabilityAlert + ok = true + } } var messageSend *discordgo.MessageSend @@ -227,73 +240,18 @@ func HandleEvents( } var embed = discordgo.MessageEmbed{ - Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), - Color: 0x8b949e, // neutral gray for unknown events + Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), + Description: "This webhook type is not fully customized yet; key payload fields are shown below.", + Color: 0x8B949E, + Timestamp: time.Now().UTC().Format(time.RFC3339), + URL: rw.Repo.HTMLURL, Footer: &discordgo.MessageEmbedFooter{ - Text: providerLabel + " · Unhandled Event", + Text: rw.Repo.FullName + " · " + providerLabel + " · Unhandled", + IconURL: rw.Repo.Owner.AvatarURL, }, } - // Extract meaningful top-level fields, skip complex nested objects - var embedFields []*discordgo.MessageEmbedField - for k, v := range fields { - // Skip large nested objects that render as ugly map[...] dumps - switch v.(type) { - case map[string]any: - // For known important nested objects, extract key info - nested := v.(map[string]any) - if k == "sender" || k == "user" { - if login, ok := nested["login"].(string); ok { - embedFields = append(embedFields, &discordgo.MessageEmbedField{ - Name: "User", - Value: login, - Inline: true, - }) - } else if name, ok := nested["name"].(string); ok { - embedFields = append(embedFields, &discordgo.MessageEmbedField{ - Name: "User", - Value: name, - Inline: true, - }) - } - } else if k == "repository" || k == "project" { - if fullName, ok := nested["full_name"].(string); ok { - embedFields = append(embedFields, &discordgo.MessageEmbedField{ - Name: "Repository", - Value: fullName, - Inline: true, - }) - } else if name, ok := nested["name"].(string); ok { - embedFields = append(embedFields, &discordgo.MessageEmbedField{ - Name: "Repository", - Value: name, - Inline: true, - }) - } - } - // Skip other nested objects entirely - continue - case []any: - // Skip arrays (they render terribly) - continue - } - - val := fmt.Sprintf("%v", v) - if val == "" || val == "" { - continue - } - if len(val) > 200 { - val = val[:200] + "..." - } - - embedFields = append(embedFields, &discordgo.MessageEmbedField{ - Name: cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")), - Value: val, - Inline: true, - }) - } - - embed.Fields = embedFields + embed.Fields = buildFallbackWebhookEmbedFields(fields) messageSend = &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{&embed}, @@ -316,6 +274,8 @@ func HandleEvents( return } + events.EnrichEmbedsBeforeSend(messageSend.Embeds, rw, provider) + for i, embed := range messageSend.Embeds { messageSend.Embeds[i] = applyEmbedLimits(embed) } diff --git a/webserver/server.go b/webserver/server.go index ec9461d..3c39906 100644 --- a/webserver/server.go +++ b/webserver/server.go @@ -1,72 +1,38 @@ package main import ( - "context" "net/http" - "os" - "os/signal" - "syscall" "time" + "github.com/git-logs/client/webserver/ontos" + "github.com/git-logs/client/webserver/state" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/infinitybotlist/eureka/zapchi" - "go.uber.org/zap" - - "github.com/git-logs/client/webserver/ontos" - "github.com/git-logs/client/webserver/state" ) func main() { state.Setup() + defer state.Close() - r := chi.NewRouter() + r := chi.NewMux() - r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api")) - r.Use(middleware.Recoverer) - r.Use(middleware.RealIP) - r.Use(middleware.RequestID) - r.Use(middleware.Timeout(60 * time.Second)) + r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api"), middleware.Recoverer, middleware.RealIP, middleware.RequestID, middleware.Timeout(60*time.Second)) + // Webhook route + r.Get("/kittycat", ontos.GetWebhookRoute) + r.Post("/kittycat", ontos.HandleWebhookRoute) r.HandleFunc("/", ontos.IndexPage) + r.HandleFunc("/audit", ontos.AuditEvent) + r.HandleFunc("/health", ontos.HealthCheck) // API r.HandleFunc("/api/counts", ontos.ApiStats) r.HandleFunc("/api/events/listview", ontos.ApiEventsListView) - r.HandleFunc("/api/events/csview", ontos.ApiEventsCommaSepView) - r.HandleFunc("/health", ontos.HealthCheck) - - // KittyCat (webhook route) - r.Get("/kittycat", ontos.GetWebhookRoute) - r.Post("/kittycat", ontos.HandleWebhookRoute) - r.HandleFunc("/audit", ontos.AuditEvent) - - srv := &http.Server{ - Addr: state.Config.Port, - Handler: r, - } - // Graceful shutdown channel - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - go func() { - state.Logger.Info("Starting webserver on " + state.Config.Port) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - state.Logger.Fatal("Failed to start server", zap.Error(err)) - } - }() - - <-done - state.Logger.Info("Webserver is shutting down...") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - state.Logger.Fatal("Server shutdown failed", zap.Error(err)) - } + r.HandleFunc("/api/events/csview", ontos.ApiEventsCommaSepView) - state.Logger.Info("Webserver gracefully stopped") + http.ListenAndServe(state.Config.Port, r) } From c3e50079d71721e29e65906a5f559a47de98e1c7 Mon Sep 17 00:00:00 2001 From: Ranveer Soni Date: Thu, 14 May 2026 12:13:12 +0530 Subject: [PATCH 3/3] quite a lot of things --- ...c777279ae33e2296570d0c0a27f470ad255a5.json | 46 ++++++++++++++++ ...1c6e95ec2c64e47c42859ebf22d8421d6a9d1.json | 17 ++++++ ...f101ebbd3851b6cdd61306d67bcc6532c2999.json | 16 ++++++ ...cad63659cc0d92b2c50d012400197df2a231d.json | 21 ++++++++ bot/migrations/20260513045506_init.sql | 52 +++++++++++++++++++ .../20260514190000_webhooks_broken.sql | 1 + bot/src/commands/webhook_provider.rs | 18 +++++++ 7 files changed, 171 insertions(+) create mode 100644 bot/.sqlx/query-13299acf5b2a1c24bac7a0d1101c777279ae33e2296570d0c0a27f470ad255a5.json create mode 100644 bot/.sqlx/query-3b93c9d7efedb8b2b3a9a721ed31c6e95ec2c64e47c42859ebf22d8421d6a9d1.json create mode 100644 bot/.sqlx/query-b1e66fdd4b8f3e1502ccb1a70b8f101ebbd3851b6cdd61306d67bcc6532c2999.json create mode 100644 bot/.sqlx/query-e52624722bf61adb112e1fbec96cad63659cc0d92b2c50d012400197df2a231d.json create mode 100644 bot/migrations/20260513045506_init.sql create mode 100644 bot/migrations/20260514190000_webhooks_broken.sql create mode 100644 bot/src/commands/webhook_provider.rs diff --git a/bot/.sqlx/query-13299acf5b2a1c24bac7a0d1101c777279ae33e2296570d0c0a27f470ad255a5.json b/bot/.sqlx/query-13299acf5b2a1c24bac7a0d1101c777279ae33e2296570d0c0a27f470ad255a5.json new file mode 100644 index 0000000..302366d --- /dev/null +++ b/bot/.sqlx/query-13299acf5b2a1c24bac7a0d1101c777279ae33e2296570d0c0a27f470ad255a5.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, broken, comment, created_at, COALESCE(provider, 'github') as provider FROM webhooks WHERE guild_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "broken", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "provider", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + null + ] + }, + "hash": "13299acf5b2a1c24bac7a0d1101c777279ae33e2296570d0c0a27f470ad255a5" +} diff --git a/bot/.sqlx/query-3b93c9d7efedb8b2b3a9a721ed31c6e95ec2c64e47c42859ebf22d8421d6a9d1.json b/bot/.sqlx/query-3b93c9d7efedb8b2b3a9a721ed31c6e95ec2c64e47c42859ebf22d8421d6a9d1.json new file mode 100644 index 0000000..974d232 --- /dev/null +++ b/bot/.sqlx/query-3b93c9d7efedb8b2b3a9a721ed31c6e95ec2c64e47c42859ebf22d8421d6a9d1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE repos SET repo_name = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3b93c9d7efedb8b2b3a9a721ed31c6e95ec2c64e47c42859ebf22d8421d6a9d1" +} diff --git a/bot/.sqlx/query-b1e66fdd4b8f3e1502ccb1a70b8f101ebbd3851b6cdd61306d67bcc6532c2999.json b/bot/.sqlx/query-b1e66fdd4b8f3e1502ccb1a70b8f101ebbd3851b6cdd61306d67bcc6532c2999.json new file mode 100644 index 0000000..a6d6a3b --- /dev/null +++ b/bot/.sqlx/query-b1e66fdd4b8f3e1502ccb1a70b8f101ebbd3851b6cdd61306d67bcc6532c2999.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhooks SET provider = $1 WHERE id = $2 AND guild_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b1e66fdd4b8f3e1502ccb1a70b8f101ebbd3851b6cdd61306d67bcc6532c2999" +} diff --git a/bot/.sqlx/query-e52624722bf61adb112e1fbec96cad63659cc0d92b2c50d012400197df2a231d.json b/bot/.sqlx/query-e52624722bf61adb112e1fbec96cad63659cc0d92b2c50d012400197df2a231d.json new file mode 100644 index 0000000..10ffa67 --- /dev/null +++ b/bot/.sqlx/query-e52624722bf61adb112e1fbec96cad63659cc0d92b2c50d012400197df2a231d.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webhooks (id, guild_id, comment, secret, broken, provider, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Bool", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e52624722bf61adb112e1fbec96cad63659cc0d92b2c50d012400197df2a231d" +} diff --git a/bot/migrations/20260513045506_init.sql b/bot/migrations/20260513045506_init.sql new file mode 100644 index 0000000..a2369ef --- /dev/null +++ b/bot/migrations/20260513045506_init.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS guilds ( + id TEXT PRIMARY KEY NOT NULL, + banned BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY NOT NULL, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE ON UPDATE CASCADE, + comment TEXT NOT NULL, -- A comment to help identify the webhook + broken BOOLEAN NOT NULL DEFAULT FALSE, + secret TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'github', -- 'github' or 'gitlab' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT NOT NULL, + last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_updated_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS repos ( + id TEXT PRIMARY KEY NOT NULL, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE ON UPDATE CASCADE, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE, + repo_name TEXT NOT NULL, + channel_id TEXT NOT NULL, -- Channel ID to post to + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT NOT NULL, + last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_updated_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS event_modifiers ( + id TEXT PRIMARY KEY NOT NULL, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE ON UPDATE CASCADE, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE, -- Webhook to apply to + repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE, -- Optional, if not set, will assume all repos + events TEXT[] NOT NULL DEFAULT '{}', -- Events to capture in this modifier + blacklisted boolean not null default false, -- Whether or not these events are blacklisted or not + whitelisted boolean not null default false, -- Whether or not only these events can be sent + redirect_channel TEXT, -- Channel ID to redirect to, otherwise use default channel + priority INTEGER NOT NULL, -- Priority to apply the modifiers in, applied in descending order + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT NOT NULL, + last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_updated_by TEXT NOT NULL +); + +create table IF NOT EXISTS webhook_logs ( + log_id text primary key not null, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE ON UPDATE CASCADE, + webhook_id text not null references webhooks (id) ON UPDATE CASCADE ON DELETE CASCADE, + entries text[] not null default '{}' +); diff --git a/bot/migrations/20260514190000_webhooks_broken.sql b/bot/migrations/20260514190000_webhooks_broken.sql new file mode 100644 index 0000000..dee6281 --- /dev/null +++ b/bot/migrations/20260514190000_webhooks_broken.sql @@ -0,0 +1 @@ +ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS broken BOOLEAN NOT NULL DEFAULT false; diff --git a/bot/src/commands/webhook_provider.rs b/bot/src/commands/webhook_provider.rs new file mode 100644 index 0000000..c6fba78 --- /dev/null +++ b/bot/src/commands/webhook_provider.rs @@ -0,0 +1,18 @@ +#[derive(Clone, Copy, Debug, poise::ChoiceParameter)] +pub enum WebhookProvider { + #[name = "GitHub"] + #[name = "github"] + Github, + #[name = "GitLab"] + #[name = "gitlab"] + Gitlab, +} + +impl WebhookProvider { + pub fn as_db(self) -> &'static str { + match self { + Self::Github => "github", + Self::Gitlab => "gitlab", + } + } +}