diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 2eaaa32e7..e81d14d0f 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -102,9 +102,13 @@ jobs: echo "TEMP: skipping AWS ECR push (Harbor only)" fi + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + docker buildx build \ --platform "$PLATFORM" \ --build-arg TIPTAP_PRIVATE_REGISTRY_KEY="$TIPTAP_PRIVATE_REGISTRY_KEY" \ + --build-arg GIT_SHA="${GITHUB_SHA}" \ + --build-arg BUILD_TIME="${BUILD_TIME}" \ --cache-from type=gha,scope=mega-ui \ --cache-to type=gha,mode=max,scope=mega-ui \ --provenance=false \ diff --git a/Cargo.lock b/Cargo.lock index 57bfb2bb1..1052451f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,7 +230,7 @@ dependencies = [ "parse-display 0.11.0", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "utoipa", ] @@ -512,7 +512,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -608,9 +608,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -682,9 +682,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -693,9 +693,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -997,7 +997,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1192,9 +1192,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -1353,9 +1353,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -1381,7 +1381,7 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1408,7 +1408,7 @@ dependencies = [ "serde_with", "smol_str", "stacker", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-security", ] @@ -1680,7 +1680,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml 1.1.4+spec-1.1.0", ] @@ -1800,9 +1800,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -2271,7 +2271,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2485,7 +2485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df6e616b67e0e0057a0952ef9991d66300044b897b879ef6fa89851495065b35" dependencies = [ "sysinfo 0.35.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -2989,9 +2989,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "fixedbitset" @@ -3284,9 +3284,9 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8befc9216110293b0ed8277b2b224180fc3859ed4699bc71f3ed070475a9d1" +checksum = "d90aeb6d1c7457e28043023d7f31a0f6012d02966f5b6cac238c1eaf586ccf4a" dependencies = [ "ahash 0.8.12", "async-trait", @@ -3317,7 +3317,7 @@ dependencies = [ "sha2 0.11.0", "similar", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "threadpool", "tokio", "tokio-stream", @@ -3487,9 +3487,9 @@ dependencies = [ [[package]] name = "hcl-primitives" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829a11d304c89e2cfe0dbb494a686bbe2b48ade17705c62cd1957b04aa4630f6" +checksum = "bd662a8afeca01b5b5318f35baed70017b9f854bfa38bdcdadb87de946a49071" dependencies = [ "itoa", "kstring", @@ -4045,7 +4045,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -4134,10 +4134,12 @@ dependencies = [ "defmt", "jiff-core", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link 0.2.1", ] [[package]] @@ -4161,6 +4163,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -4173,7 +4190,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -4222,9 +4239,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -4945,9 +4962,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "async-lock", "crossbeam-channel", @@ -5373,7 +5390,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -5729,7 +5746,7 @@ dependencies = [ "log", "rand 0.10.2", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "windows 0.62.2", "windows-strings 0.5.1", @@ -6231,9 +6248,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -6591,7 +6608,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -6614,7 +6631,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -6880,7 +6897,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -7209,9 +7226,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.5" +version = "0.62.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" +checksum = "b41043523e0edcbd4e31d00903e26f12994f63b21bae9904f7405c1ed92752a5" dependencies = [ "aes 0.9.2", "aws-lc-rs", @@ -7272,7 +7289,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash 0.6.1", @@ -7305,7 +7322,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -7567,7 +7584,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "tracing-subscriber", ] @@ -7641,7 +7658,7 @@ dependencies = [ "serde", "serde_json", "sled", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "toml 0.9.12+spec-1.1.0", "tracing", @@ -7704,7 +7721,7 @@ dependencies = [ "sqlx", "sqlx-core", "strum 0.28.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "url", @@ -7720,7 +7737,7 @@ checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" dependencies = [ "arrow", "sea-query", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -7798,7 +7815,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -8030,9 +8047,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -8040,6 +8057,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -8050,9 +8068,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -8490,7 +8508,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -8562,7 +8580,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "uuid", @@ -8600,7 +8618,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "uuid", @@ -8627,7 +8645,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "url", @@ -9046,11 +9064,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -9066,9 +9084,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -9551,7 +9569,7 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -9589,7 +9607,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -9709,7 +9727,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1 0.10.7", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -9726,7 +9744,7 @@ dependencies = [ "native-tls", "rand 0.10.2", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -10161,9 +10179,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -10175,9 +10193,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -10185,9 +10203,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10195,9 +10213,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -10208,9 +10226,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -10230,9 +10248,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -10723,7 +10741,7 @@ dependencies = [ "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 629386783..8289162c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ orion = { path = "orion" } orion-client = { path = "clients/orion-client" } orion-scheduler-client = { path = "clients/orion-scheduler-client" } -git-internal = "0.8.6" +git-internal = "0.8.7" libvault-core = "0.1.0" #==== @@ -49,21 +49,21 @@ serde_urlencoded = "0.7" tracing = "0.1.44" tracing-subscriber = "0.3.23" tracing-appender = "0.2.5" -thiserror = "2.0.19" +thiserror = "2.0.20" clap = "4.6.5" #==== tokio = "1.53.1" tokio-stream = "0.1.19" tokio-util = "0.7.19" -async-trait = "0.1.91" +async-trait = "0.1.92" async-stream = "0.3.6" async-recursion = "1.1.1" futures = "0.3.33" futures-util = "0.3.33" axum = { version = "0.8.9", features = ["macros", "json"] } axum-extra = "0.12.6" -russh = "0.62.5" +russh = "0.62.6" tower-http = "0.7.0" tower = "0.5.3" tower-sessions = { version = "0.15", features = ["memory-store"] } @@ -100,7 +100,7 @@ ctrlc = "3.5.2" cedar-policy = "4.12.0" secp256k1 = "0.31.1" pgp = "0.20.0" -base64 = "0.23.0" +base64 = "0.23.1" utoipa = { version = "5.5.0", features = ["chrono"] } utoipa-axum = "0.2.0" @@ -129,7 +129,7 @@ rustls = "0.23.43" object_store = "0.14.1" parse-display = "0.11.0" toml = "1.1.3" -rkyv = "0.8.17" +rkyv = "0.8.18" percent-encoding = "2.3" diff --git a/ceres/src/application/api_service/mono/cla.rs b/ceres/src/application/api_service/mono/cla.rs index 0f9d36472..9765203d3 100644 --- a/ceres/src/application/api_service/mono/cla.rs +++ b/ceres/src/application/api_service/mono/cla.rs @@ -6,7 +6,7 @@ use futures::{StreamExt, stream}; use io_orbit::object_storage::{ObjectKey, ObjectMeta, ObjectNamespace}; use super::context::UserApplicationService; -use crate::merge_checker::CheckerRegistry; +use crate::{application::member_identity, merge_checker::CheckerRegistry}; const CLA_CONTENT_OBJECT_KEY: &str = "cla/content/current.txt"; @@ -21,6 +21,20 @@ impl UserApplicationService { .cla_storage() .get_or_create_status(username) .await?; + // Heal transitional CL authors (username/github_login) after a public-id sign. + if model.cla_signed { + let aliases = member_identity::aliases_for_actor(self.ctx.storage(), username).await; + if let Err(e) = self + .refresh_checks_for_open_cls_by_author_aliases(&aliases) + .await + { + tracing::warn!( + error = %e, + username, + "failed to refresh CLA checks for open CLs after status read" + ); + } + } Ok((model.cla_signed, model.cla_signed_at)) } @@ -85,11 +99,21 @@ impl UserApplicationService { username: &str, ) -> Result<(bool, Option), MegaError> { let model = self.ctx.storage().cla_storage().sign(username).await?; - self.refresh_checks_for_open_cls_by_author(username).await?; + let aliases = member_identity::aliases_for_actor(self.ctx.storage(), username).await; + self.refresh_checks_for_open_cls_by_author_aliases(&aliases) + .await?; Ok((model.cla_signed, model.cla_signed_at)) } - async fn refresh_checks_for_open_cls_by_author(&self, username: &str) -> Result<(), MegaError> { + async fn refresh_checks_for_open_cls_by_author_aliases( + &self, + aliases: &[String], + ) -> Result<(), MegaError> { + if aliases.is_empty() { + return Ok(()); + } + let alias_set: std::collections::HashSet<&str> = + aliases.iter().map(String::as_str).collect(); let open_cls = self .ctx .storage() @@ -98,14 +122,20 @@ impl UserApplicationService { .get_open_cls() .await? .into_iter() - .filter(|cl| cl.campsite_user_id == username) + .filter(|cl| alias_set.contains(cl.campsite_user_id.as_str())) .collect::>(); if open_cls.is_empty() { return Ok(()); } - let check_reg = - CheckerRegistry::new(self.ctx.storage().clone().into(), username.to_string()); + // CheckerRegistry username is only used for logging / non-CLA checks context; + // prefer the canonical (first) alias when available. + let actor = aliases + .first() + .map(String::as_str) + .unwrap_or_default() + .to_string(); + let check_reg = CheckerRegistry::new(self.ctx.storage().clone().into(), actor); for cl in open_cls { check_reg.run_checks(cl.into()).await?; } diff --git a/ceres/src/application/api_service/mono/user_approval.rs b/ceres/src/application/api_service/mono/user_approval.rs index df309534b..2ef94de28 100644 --- a/ceres/src/application/api_service/mono/user_approval.rs +++ b/ceres/src/application/api_service/mono/user_approval.rs @@ -2,12 +2,18 @@ use callisto::user_approval_status; use common::errors::MegaError; -use jupiter::storage::user_approval_storage::{ - APPROVAL_STATUS_APPROVED, APPROVAL_STATUS_REJECTED, UserApprovalProfile, +use jupiter::storage::{ + campsite_member_identity_storage::MemberIdentityProfile, + user_approval_storage::{ + APPROVAL_STATUS_APPROVED, APPROVAL_STATUS_REJECTED, UserApprovalProfile, + }, }; use super::context::UserApplicationService; -use crate::model::user::UserApprovalStatusRes; +use crate::{ + application::member_identity::{display_labels_for_actors, upsert_local_identity}, + model::user::UserApprovalStatusRes, +}; impl UserApplicationService { pub async fn get_or_init_user_approval_status( @@ -15,8 +21,10 @@ impl UserApplicationService { campsite_user_id: &str, display_name: &str, email: &str, + github_login: Option<&str>, ) -> Result { - self.ctx + let model = self + .ctx .storage() .user_approval_storage() .get_or_create(UserApprovalProfile { @@ -24,7 +32,25 @@ impl UserApplicationService { display_name: display_name.to_string(), email: email.to_string(), }) - .await + .await?; + + let login = github_login + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let _ = upsert_local_identity( + self.ctx.storage(), + MemberIdentityProfile { + campsite_user_id: campsite_user_id.to_string(), + username: login.clone().unwrap_or_default(), + github_login: login, + display_name: display_name.to_string(), + email: email.to_string(), + }, + ) + .await; + + Ok(model) } pub async fn list_user_approvals( @@ -39,6 +65,23 @@ impl UserApplicationService { .await } + pub async fn list_user_approval_responses( + &self, + status: Option<&str>, + limit: u64, + ) -> Result, MegaError> { + let models = self.list_user_approvals(status, limit).await?; + let reviewer_ids: Vec = models + .iter() + .filter_map(|m| m.reviewed_by.clone()) + .collect(); + let labels = display_labels_for_actors(self.ctx.storage(), &reviewer_ids).await; + Ok(models + .into_iter() + .map(|model| Self::model_to_res(model, &labels)) + .collect()) + } + pub async fn approve_user( &self, campsite_user_id: &str, @@ -62,6 +105,54 @@ impl UserApplicationService { .set_status(campsite_user_id, APPROVAL_STATUS_REJECTED, reviewed_by) .await } + + /// Upsert the reviewer's local identity, then approve. + pub async fn approve_user_response( + &self, + campsite_user_id: &str, + reviewed_by: &str, + reviewer: MemberIdentityProfile, + ) -> Result { + let _ = upsert_local_identity(self.ctx.storage(), reviewer).await; + let model = self.approve_user(campsite_user_id, reviewed_by).await?; + Ok(self.to_approval_status_res(model).await) + } + + /// Upsert the reviewer's local identity, then reject. + pub async fn reject_user_response( + &self, + campsite_user_id: &str, + reviewed_by: &str, + reviewer: MemberIdentityProfile, + ) -> Result { + let _ = upsert_local_identity(self.ctx.storage(), reviewer).await; + let model = self.reject_user(campsite_user_id, reviewed_by).await?; + Ok(self.to_approval_status_res(model).await) + } + + pub async fn to_approval_status_res( + &self, + model: user_approval_status::Model, + ) -> UserApprovalStatusRes { + let reviewer_ids: Vec = model.reviewed_by.iter().cloned().collect(); + let labels = display_labels_for_actors(self.ctx.storage(), &reviewer_ids).await; + Self::model_to_res(model, &labels) + } + + fn model_to_res( + model: user_approval_status::Model, + labels: &std::collections::HashMap, + ) -> UserApprovalStatusRes { + let mut res = UserApprovalStatusRes::from(model); + if let Some(label) = res + .reviewed_by + .as_deref() + .and_then(|reviewed_by| labels.get(reviewed_by)) + { + res.reviewed_by = Some(label.clone()); + } + res + } } impl From for UserApprovalStatusRes { diff --git a/ceres/src/application/code_edit/model.rs b/ceres/src/application/code_edit/model.rs index e03002bbf..b289a14b1 100644 --- a/ceres/src/application/code_edit/model.rs +++ b/ceres/src/application/code_edit/model.rs @@ -8,7 +8,6 @@ use jupiter::{ storage::{Storage, mono_storage::MonoStorage}, utils::converter::FromMegaModel, }; -use serde::Deserialize; use crate::{ application::{ @@ -140,15 +139,8 @@ pub(crate) trait Director { } } -#[derive(Debug, Deserialize)] -struct CampsiteMemberIdentity { - campsite_user_id: String, - #[serde(default)] - github_login: Option, -} - -/// Resolve Cedar github logins → campsite public ids from local tables and, -/// when configured, Campsite `internal/member_identities`. +/// Resolve Cedar github logins → campsite public ids from local tables +/// (`access_token`, reviewers, `campsite_member_identity`). async fn load_github_login_map(storage: &Storage) -> HashMap { let mut map = HashMap::new(); @@ -165,79 +157,30 @@ async fn load_github_login_map(storage: &Storage) -> HashMap { Err(e) => tracing::warn!(error = %e, "failed to load github_login map from reviewers"), } - match fetch_campsite_github_login_map(storage).await { - Ok(from_campsite) => map.extend(from_campsite), + match storage.campsite_member_identity_storage().list_all().await { + Ok(rows) => { + for row in rows { + if let Some(login) = row + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + map.insert(login.to_string(), row.campsite_user_id); + } + } + } Err(e) => { - tracing::debug!(error = %e, "campsite member_identities unavailable for reviewer map") + tracing::debug!(error = %e, "local campsite_member_identity unavailable for reviewer map") } } map } -/// Prefer github_login for human-readable conversation text; persist actor remains campsite id. +/// Prefer local identity label for human-readable conversation text; persist actor remains campsite id. async fn resolve_actor_display_name(storage: &Storage, campsite_user_id: &str) -> String { - let id = campsite_user_id.trim(); - if id.is_empty() { - return campsite_user_id.to_string(); - } - match storage.user_storage().github_login_to_campsite_ids().await { - Ok(map) => map - .into_iter() - .find_map(|(login, mapped_id)| (mapped_id == id).then_some(login)) - .unwrap_or_else(|| id.to_string()), - Err(_) => id.to_string(), - } -} - -async fn fetch_campsite_github_login_map( - storage: &Storage, -) -> Result, MegaError> { - let config = storage.config(); - let secret = config.oauth.mega_internal_secret.trim(); - let api_base = config.oauth.campsite_api_domain.trim(); - if secret.is_empty() || api_base.is_empty() { - return Ok(HashMap::new()); - } - - let url = format!( - "{}/v1/organizations/mega/internal/member_identities", - api_base.trim_end_matches('/') - ); - let client = reqwest::Client::builder() - .no_proxy() - .build() - .map_err(|e| MegaError::Other(e.to_string()))?; - let resp = client - .get(&url) - .header("X-Mega-Internal-Secret", secret) - .send() - .await - .map_err(|e| MegaError::Other(format!("campsite member_identities request failed: {e}")))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(MegaError::Other(format!( - "campsite member_identities HTTP {status}: {body}" - ))); - } - let identities: Vec = resp - .json() - .await - .map_err(|e| MegaError::Other(format!("parse member_identities JSON: {e}")))?; - - let mut map = HashMap::new(); - for identity in identities { - let id = identity.campsite_user_id.trim(); - let Some(login) = identity.github_login.as_deref().map(str::trim) else { - continue; - }; - if id.is_empty() || login.is_empty() { - continue; - } - map.insert(login.to_string(), id.to_string()); - } - Ok(map) + crate::application::member_identity::display_label_for_actor(storage, campsite_user_id).await } fn cl_with_latest_to_hash(mut cl: mega_cl::Model, to_hash: &str) -> mega_cl::Model { diff --git a/ceres/src/application/member_identity.rs b/ceres/src/application/member_identity.rs new file mode 100644 index 000000000..adf11f40f --- /dev/null +++ b/ceres/src/application/member_identity.rs @@ -0,0 +1,337 @@ +//! Campsite member identity helpers for transitional actor strings +//! (campsite username / github_login ↔ campsite public id). +//! +//! Display / alias resolution reads the local `campsite_member_identity` table. +//! Campsite HTTP is only used for startup sync / backfill write paths. + +use std::collections::{HashMap, HashSet}; + +use common::errors::MegaError; +use jupiter::storage::{Storage, campsite_member_identity_storage::MemberIdentityProfile}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct CampsiteMemberIdentity { + pub campsite_user_id: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub github_login: Option, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub email: Option, +} + +/// Fetch org=mega member identities when `mega_internal_secret` is configured. +/// Used by startup sync / backfill — not by per-request display resolution. +pub async fn fetch_campsite_member_identities( + storage: &Storage, +) -> Result, MegaError> { + let config = storage.config(); + let secret = config.oauth.mega_internal_secret.trim(); + let api_base = config.oauth.campsite_api_domain.trim(); + if secret.is_empty() || api_base.is_empty() { + return Ok(Vec::new()); + } + + let url = format!( + "{}/v1/organizations/mega/internal/member_identities", + api_base.trim_end_matches('/') + ); + let client = reqwest::Client::builder() + .no_proxy() + .build() + .map_err(|e| MegaError::Other(e.to_string()))?; + let resp = client + .get(&url) + .header("X-Mega-Internal-Secret", secret) + .send() + .await + .map_err(|e| MegaError::Other(format!("campsite member_identities request failed: {e}")))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(MegaError::Other(format!( + "campsite member_identities HTTP {status}: {body}" + ))); + } + resp.json() + .await + .map_err(|e| MegaError::Other(format!("parse member_identities JSON: {e}"))) +} + +fn push_unique(out: &mut Vec, seen: &mut HashSet, value: &str) { + let v = value.trim(); + if v.is_empty() || !seen.insert(v.to_string()) { + return; + } + out.push(v.to_string()); +} + +fn identity_handles_from_row( + campsite_user_id: &str, + username: &str, + github_login: Option<&str>, +) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + push_unique(&mut out, &mut seen, campsite_user_id); + push_unique(&mut out, &mut seen, username); + if let Some(login) = github_login { + push_unique(&mut out, &mut seen, login); + } + out +} + +/// Upsert a local identity row (write-through from login / approve / sync). +pub async fn upsert_local_identity( + storage: &Storage, + profile: MemberIdentityProfile, +) -> Result<(), MegaError> { + storage + .campsite_member_identity_storage() + .upsert(profile) + .await +} + +/// Persist Campsite API identities into the local directory table. +pub async fn sync_identities_to_local( + storage: &Storage, + identities: &[CampsiteMemberIdentity], +) -> Result { + let profiles: Vec = identities + .iter() + .filter(|i| !i.campsite_user_id.trim().is_empty()) + .map(|i| { + let username = i.username.trim().to_string(); + let github_login = i + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let display_name = i + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + let email = i + .email + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + MemberIdentityProfile { + campsite_user_id: i.campsite_user_id.trim().to_string(), + username: if !username.is_empty() { + username + } else { + github_login.clone().unwrap_or_default() + }, + github_login, + display_name, + email, + } + }) + .collect(); + storage + .campsite_member_identity_storage() + .upsert_many(&profiles) + .await +} + +/// Resolve all known actor strings for the same person as `actor`. +/// Always includes `actor` itself; expands via local identity table + github_login maps. +pub async fn aliases_for_actor(storage: &Storage, actor: &str) -> Vec { + let actor = actor.trim(); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + push_unique(&mut out, &mut seen, actor); + if actor.is_empty() { + return out; + } + + match storage + .campsite_member_identity_storage() + .find_by_actor(actor) + .await + { + Ok(Some(row)) => { + for handle in identity_handles_from_row( + &row.campsite_user_id, + &row.username, + row.github_login.as_deref(), + ) { + push_unique(&mut out, &mut seen, &handle); + } + } + Ok(None) => {} + Err(e) => { + tracing::debug!(error = %e, actor, "local member identity lookup failed for CLA aliases") + } + } + + match storage.user_storage().github_login_to_campsite_ids().await { + Ok(map) => extend_from_login_map(&mut out, &mut seen, actor, &map), + Err(e) => { + tracing::debug!(error = %e, "failed to load github_login map for CLA aliases") + } + } + match storage + .reviewer_storage() + .github_login_to_campsite_ids() + .await + { + Ok(map) => extend_from_login_map(&mut out, &mut seen, actor, &map), + Err(e) => { + tracing::debug!(error = %e, "failed to load reviewer github_login map for CLA aliases") + } + } + + out +} + +fn extend_from_login_map( + out: &mut Vec, + seen: &mut HashSet, + actor: &str, + map: &HashMap, +) { + if let Some(id) = map.get(actor) { + push_unique(out, seen, id); + } + for (login, id) in map { + if id.eq_ignore_ascii_case(actor) || login.eq_ignore_ascii_case(actor) { + push_unique(out, seen, login); + push_unique(out, seen, id); + } + } +} + +/// Resolve a stored actor id (usually campsite public id) to a human-readable label +/// for UI surfaces such as account-review "reviewed by". +/// +/// Preference order: local username → github_login → display_name → approval display_name → raw actor. +pub async fn display_label_for_actor(storage: &Storage, actor: &str) -> String { + let labels = display_labels_for_actors(storage, &[actor.to_string()]).await; + labels + .get(actor.trim()) + .cloned() + .unwrap_or_else(|| actor.trim().to_string()) +} + +/// Batch variant of [`display_label_for_actor`] — reads only the local identity table +/// (plus local approval / github_login fallbacks). Does **not** call Campsite. +pub async fn display_labels_for_actors( + storage: &Storage, + actors: &[String], +) -> HashMap { + let mut out = HashMap::new(); + let mut needed: Vec = Vec::new(); + let mut seen = HashSet::new(); + for actor in actors { + let actor = actor.trim(); + if actor.is_empty() || !seen.insert(actor.to_string()) { + continue; + } + needed.push(actor.to_string()); + } + if needed.is_empty() { + return out; + } + + let rows = storage + .campsite_member_identity_storage() + .get_by_ids(&needed) + .await + .unwrap_or_default(); + let mut by_id: HashMap = HashMap::new(); + let mut by_handle: HashMap = HashMap::new(); + for row in rows { + by_handle.insert(row.campsite_user_id.to_ascii_lowercase(), row.clone()); + if !row.username.trim().is_empty() { + by_handle.insert(row.username.to_ascii_lowercase(), row.clone()); + } + if let Some(login) = row + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + by_handle.insert(login.to_ascii_lowercase(), row.clone()); + } + by_id.insert(row.campsite_user_id.clone(), row); + } + + // Also resolve actors that matched username/github_login but weren't in the id list. + for actor in &needed { + if by_id.contains_key(actor) || by_handle.contains_key(&actor.to_ascii_lowercase()) { + continue; + } + if let Ok(Some(row)) = storage + .campsite_member_identity_storage() + .find_by_actor(actor) + .await + { + by_handle.insert(actor.to_ascii_lowercase(), row.clone()); + by_id.insert(row.campsite_user_id.clone(), row); + } + } + + let login_map = storage + .user_storage() + .github_login_to_campsite_ids() + .await + .unwrap_or_default(); + + for actor in &needed { + if let Some(row) = by_id + .get(actor) + .or_else(|| by_handle.get(&actor.to_ascii_lowercase())) + { + let username = row.username.trim(); + if !username.is_empty() { + out.insert(actor.clone(), username.to_string()); + continue; + } + if let Some(login) = row + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + out.insert(actor.clone(), login.to_string()); + continue; + } + let display = row.display_name.trim(); + if !display.is_empty() { + out.insert(actor.clone(), display.to_string()); + continue; + } + } + + if let Ok(Some(row)) = storage.user_approval_storage().get(actor).await { + let name = row.display_name.trim(); + if !name.is_empty() { + out.insert(actor.clone(), name.to_string()); + continue; + } + } + + if let Some(login) = login_map + .iter() + .find_map(|(login, id)| id.eq_ignore_ascii_case(actor).then_some(login.clone())) + { + out.insert(actor.clone(), login); + continue; + } + + out.insert(actor.clone(), actor.clone()); + } + + out +} diff --git a/ceres/src/application/mod.rs b/ceres/src/application/mod.rs index 34fa165d4..0f27558ec 100644 --- a/ceres/src/application/mod.rs +++ b/ceres/src/application/mod.rs @@ -3,5 +3,6 @@ pub mod artifact; pub mod buck; pub mod build_trigger; pub mod code_edit; +pub mod member_identity; pub mod notification; pub mod webhook; diff --git a/ceres/src/merge_checker/cla_sign_checker.rs b/ceres/src/merge_checker/cla_sign_checker.rs index 97faa3727..38c8bf376 100644 --- a/ceres/src/merge_checker/cla_sign_checker.rs +++ b/ceres/src/merge_checker/cla_sign_checker.rs @@ -6,7 +6,10 @@ use jupiter::{model::cl_dto::ClInfoDto, storage::Storage}; use serde::Deserialize; use serde_json::Value; -use crate::merge_checker::{CheckResult, CheckType, Checker, ConditionResult}; +use crate::{ + application::member_identity, + merge_checker::{CheckResult, CheckType, Checker, ConditionResult}, +}; pub struct ClaSignChecker { pub storage: Arc, @@ -33,12 +36,8 @@ impl Checker for ClaSignChecker { message: String::new(), }; - match self - .storage - .cla_service - .get_or_create_status(¶ms.username) - .await - { + let aliases = member_identity::aliases_for_actor(&self.storage, ¶ms.username).await; + match self.storage.cla_storage().any_signed(&aliases).await { Ok(true) => { res.status = ConditionResult::PASSED; res.message = "CLA signed".to_string(); diff --git a/jupiter-migrate/src/migration/m20260811_100000_create_campsite_member_identity.rs b/jupiter-migrate/src/migration/m20260811_100000_create_campsite_member_identity.rs new file mode 100644 index 000000000..7341ae8e5 --- /dev/null +++ b/jupiter-migrate/src/migration/m20260811_100000_create_campsite_member_identity.rs @@ -0,0 +1,105 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CampsiteMemberIdentity::Table) + .if_not_exists() + .col( + ColumnDef::new(CampsiteMemberIdentity::CampsiteUserId) + .string() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CampsiteMemberIdentity::Username) + .string() + .not_null() + .default(""), + ) + .col(string_null(CampsiteMemberIdentity::GithubLogin)) + .col( + ColumnDef::new(CampsiteMemberIdentity::DisplayName) + .string() + .not_null() + .default(""), + ) + .col( + ColumnDef::new(CampsiteMemberIdentity::Email) + .string() + .not_null() + .default(""), + ) + .col(date_time(CampsiteMemberIdentity::UpdatedAt)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_campsite_member_identity_username") + .table(CampsiteMemberIdentity::Table) + .col(CampsiteMemberIdentity::Username) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_campsite_member_identity_github_login") + .table(CampsiteMemberIdentity::Table) + .col(CampsiteMemberIdentity::GithubLogin) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_campsite_member_identity_github_login") + .table(CampsiteMemberIdentity::Table) + .to_owned(), + ) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_campsite_member_identity_username") + .table(CampsiteMemberIdentity::Table) + .to_owned(), + ) + .await?; + manager + .drop_table( + Table::drop() + .table(CampsiteMemberIdentity::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum CampsiteMemberIdentity { + Table, + CampsiteUserId, + Username, + GithubLogin, + DisplayName, + Email, + UpdatedAt, +} diff --git a/jupiter-migrate/src/migration/mod.rs b/jupiter-migrate/src/migration/mod.rs index 3270b8dc8..b6f052c50 100644 --- a/jupiter-migrate/src/migration/mod.rs +++ b/jupiter-migrate/src/migration/mod.rs @@ -103,6 +103,7 @@ mod m20260720_060000_rename_webhook_event_type_underscores; mod m20260723_080000_cla_sign_check_not_required; mod m20260804_120000_actor_to_campsite_user_id; mod m20260804_130000_data_backfill_ledger; +mod m20260811_100000_create_campsite_member_identity; mod runner; pub use runner::apply_migrations; @@ -193,6 +194,7 @@ impl MigratorTrait for Migrator { Box::new(m20260723_080000_cla_sign_check_not_required::Migration), Box::new(m20260804_120000_actor_to_campsite_user_id::Migration), Box::new(m20260804_130000_data_backfill_ledger::Migration), + Box::new(m20260811_100000_create_campsite_member_identity::Migration), ] } } diff --git a/jupiter/callisto/src/campsite_member_identity.rs b/jupiter/callisto/src/campsite_member_identity.rs new file mode 100644 index 000000000..c6a851475 --- /dev/null +++ b/jupiter/callisto/src/campsite_member_identity.rs @@ -0,0 +1,19 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "campsite_member_identity")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub campsite_user_id: String, + pub username: String, + pub github_login: Option, + pub display_name: String, + pub email: String, + pub updated_at: DateTime, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index b2067d1aa..17aa1536d 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -16,6 +16,7 @@ pub mod buck_session_file; pub mod build_events; pub mod build_targets; pub mod build_triggers; +pub mod campsite_member_identity; pub mod check_result; pub mod cla_sign_status; pub mod commit_auths; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index f528522c8..6c6317aef 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -7,16 +7,17 @@ pub use super::{ bot_keys::Entity as BotKeys, bot_tokens::Entity as BotTokens, bots::Entity as Bots, buck_session::Entity as BuckSession, buck_session_file::Entity as BuckSessionFile, build_events::Entity as BuildEvents, build_targets::Entity as BuildTargets, - build_triggers::Entity as BuildTriggers, check_result::Entity as CheckResult, - cla_sign_status::Entity as ClaSignStatus, commit_auths::Entity as CommitAuths, - data_backfill_ledger::Entity as DataBackfillLedger, dynamic_sidebar::Entity as DynamicSidebar, - email_jobs::Entity as EmailJobs, git_blob::Entity as GitBlob, git_commit::Entity as GitCommit, - git_issue::Entity as GitIssue, git_pr::Entity as GitPr, git_repo::Entity as GitRepo, - git_tag::Entity as GitTag, git_tree::Entity as GitTree, gpg_key::Entity as GpgKey, - import_refs::Entity as ImportRefs, issue_cl_references::Entity as IssueClReferences, - item_assignees::Entity as ItemAssignees, item_labels::Entity as ItemLabels, - label::Entity as Label, lfs_locks::Entity as LfsLocks, lfs_objects::Entity as LfsObjects, - mega_blob::Entity as MegaBlob, mega_cl::Entity as MegaCl, + build_triggers::Entity as BuildTriggers, + campsite_member_identity::Entity as CampsiteMemberIdentity, + check_result::Entity as CheckResult, cla_sign_status::Entity as ClaSignStatus, + commit_auths::Entity as CommitAuths, data_backfill_ledger::Entity as DataBackfillLedger, + dynamic_sidebar::Entity as DynamicSidebar, email_jobs::Entity as EmailJobs, + git_blob::Entity as GitBlob, git_commit::Entity as GitCommit, git_issue::Entity as GitIssue, + git_pr::Entity as GitPr, git_repo::Entity as GitRepo, git_tag::Entity as GitTag, + git_tree::Entity as GitTree, gpg_key::Entity as GpgKey, import_refs::Entity as ImportRefs, + issue_cl_references::Entity as IssueClReferences, item_assignees::Entity as ItemAssignees, + item_labels::Entity as ItemLabels, label::Entity as Label, lfs_locks::Entity as LfsLocks, + lfs_objects::Entity as LfsObjects, mega_blob::Entity as MegaBlob, mega_cl::Entity as MegaCl, mega_cl_commits::Entity as MegaClCommits, mega_cl_reviewer::Entity as MegaClReviewer, mega_code_review_anchor::Entity as MegaCodeReviewAnchor, mega_code_review_comment::Entity as MegaCodeReviewComment, diff --git a/jupiter/src/storage/campsite_member_identity_storage.rs b/jupiter/src/storage/campsite_member_identity_storage.rs new file mode 100644 index 000000000..93203ae98 --- /dev/null +++ b/jupiter/src/storage/campsite_member_identity_storage.rs @@ -0,0 +1,165 @@ +use std::ops::Deref; + +use callisto::campsite_member_identity; +use common::errors::MegaError; +use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, Set, sea_query::OnConflict}; + +use crate::storage::base_storage::{BaseStorage, StorageConnector}; + +#[derive(Clone, Debug, Default)] +pub struct MemberIdentityProfile { + pub campsite_user_id: String, + pub username: String, + pub github_login: Option, + pub display_name: String, + pub email: String, +} + +#[derive(Clone, Debug)] +pub struct CampsiteMemberIdentityStorage { + pub base: BaseStorage, +} + +impl Deref for CampsiteMemberIdentityStorage { + type Target = BaseStorage; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl CampsiteMemberIdentityStorage { + pub async fn get( + &self, + campsite_user_id: &str, + ) -> Result, MegaError> { + Ok( + campsite_member_identity::Entity::find_by_id(campsite_user_id.to_string()) + .one(self.get_connection()) + .await?, + ) + } + + pub async fn get_by_ids( + &self, + campsite_user_ids: &[String], + ) -> Result, MegaError> { + if campsite_user_ids.is_empty() { + return Ok(Vec::new()); + } + Ok(campsite_member_identity::Entity::find() + .filter( + campsite_member_identity::Column::CampsiteUserId.is_in(campsite_user_ids.to_vec()), + ) + .all(self.get_connection()) + .await?) + } + + pub async fn list_all(&self) -> Result, MegaError> { + Ok(campsite_member_identity::Entity::find() + .all(self.get_connection()) + .await?) + } + + /// Find rows whose campsite_user_id, username, or github_login matches `actor`. + pub async fn find_by_actor( + &self, + actor: &str, + ) -> Result, MegaError> { + let actor = actor.trim(); + if actor.is_empty() { + return Ok(None); + } + + if let Some(row) = self.get(actor).await? { + return Ok(Some(row)); + } + + Ok(campsite_member_identity::Entity::find() + .filter( + Condition::any() + .add(campsite_member_identity::Column::Username.eq(actor)) + .add(campsite_member_identity::Column::GithubLogin.eq(actor)), + ) + .one(self.get_connection()) + .await?) + } + + /// Insert or merge identity fields. Non-empty incoming values overwrite; + /// empty incoming strings do not wipe existing non-empty values. + pub async fn upsert(&self, profile: MemberIdentityProfile) -> Result<(), MegaError> { + let id = profile.campsite_user_id.trim(); + if id.is_empty() { + return Ok(()); + } + + let now = chrono::Utc::now().naive_utc(); + let existing = self.get(id).await?; + + let username = non_empty_or( + profile.username.trim(), + existing.as_ref().map(|r| r.username.as_str()).unwrap_or(""), + ); + let display_name = non_empty_or( + profile.display_name.trim(), + existing + .as_ref() + .map(|r| r.display_name.as_str()) + .unwrap_or(""), + ); + let email = non_empty_or( + profile.email.trim(), + existing.as_ref().map(|r| r.email.as_str()).unwrap_or(""), + ); + let github_login = profile + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| existing.and_then(|r| r.github_login)); + + let model = campsite_member_identity::ActiveModel { + campsite_user_id: Set(id.to_string()), + username: Set(username), + github_login: Set(github_login), + display_name: Set(display_name), + email: Set(email), + updated_at: Set(now), + }; + + campsite_member_identity::Entity::insert(model) + .on_conflict( + OnConflict::column(campsite_member_identity::Column::CampsiteUserId) + .update_columns([ + campsite_member_identity::Column::Username, + campsite_member_identity::Column::GithubLogin, + campsite_member_identity::Column::DisplayName, + campsite_member_identity::Column::Email, + campsite_member_identity::Column::UpdatedAt, + ]) + .to_owned(), + ) + .exec(self.get_connection()) + .await?; + + Ok(()) + } + + pub async fn upsert_many(&self, profiles: &[MemberIdentityProfile]) -> Result { + let mut count = 0u64; + for profile in profiles { + self.upsert(profile.clone()).await?; + count += 1; + } + Ok(count) + } +} + +fn non_empty_or(preferred: &str, fallback: &str) -> String { + if !preferred.is_empty() { + preferred.to_string() + } else { + fallback.to_string() + } +} diff --git a/jupiter/src/storage/cla_storage.rs b/jupiter/src/storage/cla_storage.rs index b40ef18aa..fb66459db 100644 --- a/jupiter/src/storage/cla_storage.rs +++ b/jupiter/src/storage/cla_storage.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use callisto::cla_sign_status; use common::errors::MegaError; use sea_orm::{ - ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, Set, prelude::Expr, + ColumnTrait, DbErr, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set, prelude::Expr, sea_query::OnConflict, }; @@ -76,6 +76,19 @@ impl ClaStorage { .unwrap_or(false)) } + /// True if any of the given actor keys has `cla_signed = true`. + pub async fn any_signed(&self, actors: &[String]) -> Result { + if actors.is_empty() { + return Ok(false); + } + let count = cla_sign_status::Entity::find() + .filter(cla_sign_status::Column::CampsiteUserId.is_in(actors.iter().cloned())) + .filter(cla_sign_status::Column::ClaSigned.eq(true)) + .count(self.get_connection()) + .await?; + Ok(count > 0) + } + pub async fn sign(&self, username: &str) -> Result { let now = chrono::Utc::now().naive_utc(); diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 5729c62f6..03d583d6d 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -4,6 +4,7 @@ pub mod base_storage; pub mod bots_storage; pub mod buck_storage; pub mod build_trigger_storage; +pub mod campsite_member_identity_storage; pub mod cl_reviewer_storage; pub mod cl_storage; pub mod cla_storage; @@ -50,6 +51,7 @@ use crate::{ bots_storage::BotsStorage, buck_storage::BuckStorage, build_trigger_storage::BuildTriggerStorage, + campsite_member_identity_storage::CampsiteMemberIdentityStorage, cl_reviewer_storage::ClReviewerStorage, cl_storage::ClStorage, cla_storage::ClaStorage, @@ -83,6 +85,7 @@ pub struct AppService { pub lfs_db_storage: LfsDbStorage, pub cla_storage: ClaStorage, pub user_approval_storage: UserApprovalStorage, + pub campsite_member_identity_storage: CampsiteMemberIdentityStorage, pub user_storage: UserStorage, pub group_storage: GroupStorage, pub vault_storage: VaultStorage, @@ -119,6 +122,7 @@ impl AppService { lfs_db_storage: LfsDbStorage { base: mock.clone() }, cla_storage: ClaStorage { base: mock.clone() }, user_approval_storage: UserApprovalStorage { base: mock.clone() }, + campsite_member_identity_storage: CampsiteMemberIdentityStorage { base: mock.clone() }, user_storage: UserStorage { base: mock.clone() }, group_storage: GroupStorage { base: mock.clone() }, vault_storage: VaultStorage { base: mock.clone() }, @@ -173,6 +177,7 @@ impl Storage { let lfs_db_storage = LfsDbStorage { base: base.clone() }; let cla_storage = ClaStorage { base: base.clone() }; let user_approval_storage = UserApprovalStorage { base: base.clone() }; + let campsite_member_identity_storage = CampsiteMemberIdentityStorage { base: base.clone() }; let user_storage = UserStorage { base: base.clone() }; let group_storage = GroupStorage { base: base.clone() }; let cl_storage = ClStorage { base: base.clone() }; @@ -243,6 +248,7 @@ impl Storage { lfs_db_storage, cla_storage, user_approval_storage, + campsite_member_identity_storage, user_storage, group_storage, vault_storage, @@ -407,6 +413,10 @@ impl Storage { self.app_service.user_approval_storage.clone() } + pub fn campsite_member_identity_storage(&self) -> CampsiteMemberIdentityStorage { + self.app_service.campsite_member_identity_storage.clone() + } + pub fn group_storage(&self) -> GroupStorage { self.app_service.group_storage.clone() } diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index c8db56752..be3ec7582 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -25,6 +25,7 @@ use crate::{ bots_storage::BotsStorage, buck_storage::BuckStorage, build_trigger_storage::BuildTriggerStorage, + campsite_member_identity_storage::CampsiteMemberIdentityStorage, cl_reviewer_storage::ClReviewerStorage, cl_storage::ClStorage, cla_storage::ClaStorage, @@ -79,6 +80,7 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { lfs_db_storage: LfsDbStorage { base: base.clone() }, cla_storage: ClaStorage { base: base.clone() }, user_approval_storage: UserApprovalStorage { base: base.clone() }, + campsite_member_identity_storage: CampsiteMemberIdentityStorage { base: base.clone() }, user_storage: UserStorage { base: base.clone() }, group_storage: GroupStorage { base: base.clone() }, cl_storage: ClStorage { base: base.clone() }, diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index d059cce31..e715843f4 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -23,6 +23,7 @@ use ceres::model::{ admin::{AdminListResponse, GenerateCedarRequest, GenerateCedarResponse, IsAdminResponse}, user::{ListUserApprovalsQuery, UserApprovalListRes, UserApprovalStatusRes}, }; +use jupiter::storage::campsite_member_identity_storage::MemberIdentityProfile; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -33,6 +34,22 @@ use crate::api::{ oauth::model::LoginUser, }; +fn reviewer_identity_profile(user: &LoginUser, reviewed_by: &str) -> MemberIdentityProfile { + let github_login = user + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + MemberIdentityProfile { + campsite_user_id: reviewed_by.to_string(), + username: github_login.clone().unwrap_or_default(), + github_login: github_login.clone(), + display_name: github_login.unwrap_or_default(), + email: user.email.clone(), + } +} + /// Build the admin router. pub fn routers() -> OpenApiRouter { OpenApiRouter::new().nest( @@ -189,11 +206,8 @@ async fn list_user_approvals( let items = state .services() .user() - .list_user_approvals(Some(status), limit) - .await? - .into_iter() - .map(UserApprovalStatusRes::from) - .collect(); + .list_user_approval_responses(Some(status), limit) + .await?; Ok(Json(CommonResult::success(Some(UserApprovalListRes { items, @@ -222,16 +236,15 @@ async fn approve_user( ensure_admin(&state, &user).await?; // Path `username` is the target campsite_user_id; reviewed_by is the admin's campsite id. let reviewed_by = collaboration_actor(&user)?; + let reviewer = reviewer_identity_profile(&user, reviewed_by); - let model = state + let res = state .services() .user() - .approve_user(&username, reviewed_by) + .approve_user_response(&username, reviewed_by, reviewer) .await?; - Ok(Json(CommonResult::success(Some( - UserApprovalStatusRes::from(model), - )))) + Ok(Json(CommonResult::success(Some(res)))) } /// POST /api/v1/admin/user-approvals/{username}/reject @@ -255,14 +268,13 @@ async fn reject_user( ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; let reviewed_by = collaboration_actor(&user)?; + let reviewer = reviewer_identity_profile(&user, reviewed_by); - let model = state + let res = state .services() .user() - .reject_user(&username, reviewed_by) + .reject_user_response(&username, reviewed_by, reviewer) .await?; - Ok(Json(CommonResult::success(Some( - UserApprovalStatusRes::from(model), - )))) + Ok(Json(CommonResult::success(Some(res)))) } diff --git a/mono/src/api/router/preview_router.rs b/mono/src/api/router/preview_router.rs index 24a43a291..b5af8b1a0 100644 --- a/mono/src/api/router/preview_router.rs +++ b/mono/src/api/router/preview_router.rs @@ -16,7 +16,10 @@ use ceres::model::{ }; use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::api::{MonoApiServiceState, api_doc::CODE_PREVIEW, error::ApiError}; +use crate::api::{ + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::CODE_PREVIEW, + error::ApiError, oauth::model::LoginUser, +}; async fn upsert_commit_binding( state: &MonoApiServiceState, @@ -38,6 +41,14 @@ async fn upsert_commit_binding( Ok(()) } +/// Web edits persist the session campsite public id as CL author. +fn resolve_edit_author( + user: &LoginUser, + _client_author: Option, +) -> Result { + Ok(collaboration_actor(user)?.to_string()) +} + pub fn routers() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(create_entry)) @@ -88,9 +99,11 @@ async fn get_blob_string( tag = CODE_PREVIEW )] async fn create_entry( + user: LoginUser, state: State, - Json(json): Json, + Json(mut json): Json, ) -> Result>, ApiError> { + json.author_username = Some(resolve_edit_author(&user, json.author_username.take())?); let handler = state.api_handler(json.path.as_ref()).await?; let result = handler.create_monorepo_entry(json.clone()).await?; @@ -371,9 +384,11 @@ async fn preview_diff( tag = CODE_PREVIEW )] async fn save_edit( + user: LoginUser, state: State, - Json(payload): Json, + Json(mut payload): Json, ) -> Result>, ApiError> { + payload.author_username = Some(resolve_edit_author(&user, payload.author_username.take())?); let handler = state.api_handler(payload.path.as_ref()).await?; let res = handler.save_file_edit(payload.clone()).await?; diff --git a/mono/src/api/router/user_router.rs b/mono/src/api/router/user_router.rs index 3cd7c5627..07ebd67e7 100644 --- a/mono/src/api/router/user_router.rs +++ b/mono/src/api/router/user_router.rs @@ -403,19 +403,19 @@ async fn get_user_approval_status( state: State, ) -> Result>, ApiError> { let campsite_user_id = collaboration_actor(&user)?; - let display_name = user + let github_login = user .github_login .as_deref() .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(campsite_user_id); + .filter(|s| !s.is_empty()); + let display_name = github_login.unwrap_or(campsite_user_id); let model = state .services() .user() - .get_or_init_user_approval_status(campsite_user_id, display_name, &user.email) + .get_or_init_user_approval_status(campsite_user_id, display_name, &user.email, github_login) .await?; Ok(Json(CommonResult::success(Some( - UserApprovalStatusRes::from(model), + state.services().user().to_approval_status_res(model).await, )))) } diff --git a/mono/src/backfill/actor_identity.rs b/mono/src/backfill/actor_identity.rs index 7ae4f5651..609e4edb5 100644 --- a/mono/src/backfill/actor_identity.rs +++ b/mono/src/backfill/actor_identity.rs @@ -1,14 +1,15 @@ -//! Automatic handle → campsite_user_id backfill on mono startup. +//! Automatic Campsite member identity sync + handle → campsite_user_id backfill. //! -//! Fetches org=mega member identities from Campsite -//! `GET /v1/organizations/mega/internal/member_identities` using -//! `X-Mega-Internal-Secret`, then applies SQL updates via -//! [`jupiter::storage::data_backfill_storage`]. +//! On every mono boot (when `mega_internal_secret` is set): +//! 1. Fetch org=mega `internal/member_identities` +//! 2. UPSERT into local `campsite_member_identity` (for display / alias resolution) +//! 3. Run one-shot actor remapping backfill if not yet completed use std::time::Instant; use jupiter::storage::{ Storage, + campsite_member_identity_storage::MemberIdentityProfile, data_backfill_storage::{BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, MemberIdentityMapping}, }; use serde::Deserialize; @@ -24,26 +25,51 @@ struct CampsiteMemberIdentity { username: String, #[serde(default)] github_login: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + email: Option, } -/// Spawn non-blocking backfill after Storage is ready. Failures are logged; -/// ledger stays `failed`/`pending` so the next boot retries. +/// Spawn non-blocking identity sync + optional remapping backfill after Storage is ready. pub fn spawn_actor_identity_backfill(storage: Storage) { tokio::spawn(async move { - if let Err(e) = run_actor_identity_backfill(storage).await { - error!(error = %e, "actor identity backfill failed (will retry on next boot)"); + if let Err(e) = run_member_identity_sync_and_backfill(storage).await { + error!(error = %e, "member identity sync/backfill failed (will retry on next boot)"); } }); } -async fn run_actor_identity_backfill(storage: Storage) -> anyhow::Result<()> { +async fn run_member_identity_sync_and_backfill(storage: Storage) -> anyhow::Result<()> { let config = storage.config(); let secret = config.oauth.mega_internal_secret.trim(); if secret.is_empty() { - warn!("oauth.mega_internal_secret unset; skipping automatic actor identity backfill"); + warn!("oauth.mega_internal_secret unset; skipping member identity sync/backfill"); return Ok(()); } + let started = Instant::now(); + let identities = fetch_member_identities(&config.oauth.campsite_api_domain, secret).await?; + + let profiles = identities_to_profiles(&identities); + let synced = storage + .campsite_member_identity_storage() + .upsert_many(&profiles) + .await?; + info!( + synced, + elapsed_ms = started.elapsed().as_millis() as u64, + "campsite_member_identity directory synced from Campsite" + ); + + run_actor_remap_backfill_if_needed(&storage, &identities).await?; + Ok(()) +} + +async fn run_actor_remap_backfill_if_needed( + storage: &Storage, + identities: &[CampsiteMemberIdentity], +) -> anyhow::Result<()> { let ledger = storage.data_backfill_storage(); if let Some(row) = ledger.get(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1).await? && row.status == "completed" @@ -64,14 +90,33 @@ async fn run_actor_identity_backfill(storage: Storage) -> anyhow::Result<()> { } let started = Instant::now(); - match fetch_and_apply(&storage, &config.oauth.campsite_api_domain, secret).await { - Ok((mappings, affected)) => { + let mappings: Vec = identities + .iter() + .filter(|i| !i.campsite_user_id.trim().is_empty()) + .map(|i| MemberIdentityMapping { + campsite_user_id: i.campsite_user_id.trim().to_string(), + username: i.username.clone(), + github_login: i + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + }) + .collect(); + + match storage + .data_backfill_storage() + .apply_member_identity_mappings(&mappings) + .await + { + Ok(affected) => { ledger .mark_completed(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1) .await?; info!( backfill = BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, - mappings, + mappings = mappings.len(), rows_affected = affected, elapsed_ms = started.elapsed().as_millis() as u64, "actor identity backfill completed" @@ -83,16 +128,15 @@ async fn run_actor_identity_backfill(storage: Storage) -> anyhow::Result<()> { let _ = ledger .mark_failed(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, &msg) .await; - Err(e) + Err(e.into()) } } } -async fn fetch_and_apply( - storage: &Storage, +async fn fetch_member_identities( api_base: &str, secret: &str, -) -> anyhow::Result<(usize, u64)> { +) -> anyhow::Result> { let url = format!( "{}/v1/organizations/{}/internal/member_identities", api_base.trim_end_matches('/'), @@ -113,28 +157,46 @@ async fn fetch_and_apply( anyhow::bail!("campsite member_identities HTTP {status}: {body}"); } - let identities: Vec = resp - .json() + resp.json() .await - .map_err(|e| anyhow::anyhow!("parse member_identities JSON: {e}"))?; + .map_err(|e| anyhow::anyhow!("parse member_identities JSON: {e}")) +} - let mappings: Vec = identities - .into_iter() +fn identities_to_profiles(identities: &[CampsiteMemberIdentity]) -> Vec { + identities + .iter() .filter(|i| !i.campsite_user_id.trim().is_empty()) - .map(|i| MemberIdentityMapping { - campsite_user_id: i.campsite_user_id.trim().to_string(), - username: i.username, - github_login: i + .map(|i| { + let github_login = i .github_login - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let username = i.username.trim().to_string(); + MemberIdentityProfile { + campsite_user_id: i.campsite_user_id.trim().to_string(), + username: if !username.is_empty() { + username + } else { + github_login.clone().unwrap_or_default() + }, + github_login, + display_name: i + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(), + email: i + .email + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(), + } }) - .collect(); - - let count = mappings.len(); - let affected = storage - .data_backfill_storage() - .apply_member_identity_mappings(&mappings) - .await?; - Ok((count, affected)) + .collect() } diff --git a/moon/apps/web/Dockerfile b/moon/apps/web/Dockerfile index 853150e3c..335a81d51 100644 --- a/moon/apps/web/Dockerfile +++ b/moon/apps/web/Dockerfile @@ -20,6 +20,9 @@ WORKDIR /app FROM base AS installer ARG TARGETARCH ARG TIPTAP_PRIVATE_REGISTRY_KEY +# Baked into the client bundle at next build (not runtime-injected). +ARG GIT_SHA=dev +ARG BUILD_TIME= # Debian slim defaults to http://deb.debian.org. Through corporate proxies (NFCLOUD): # - plain HTTP hangs # - HTTPS works but MITM breaks apt's CA verify @@ -51,6 +54,10 @@ COPY --from=builder /app/out/full/ . RUN rm -f /app/apps/web/.env.local \ && cp /app/apps/web/.env.runtime /app/apps/web/.env.production +# Image identity: short/full git SHA + UTC build time for sidebar version display. +ENV NEXT_PUBLIC_APP_VERSION=${GIT_SHA} +ENV NEXT_PUBLIC_APP_BUILD_TIME=${BUILD_TIME} + RUN --mount=type=cache,id=mega-ui-next-${TARGETARCH},target=/app/apps/web/.next/cache,sharing=locked \ --mount=type=cache,id=mega-ui-turbo-${TARGETARCH},target=/app/.turbo,sharing=locked \ npx turbo run build --filter=@gitmono/web diff --git a/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx b/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx index 45c4db99c..1d38e8b9a 100644 --- a/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx +++ b/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx @@ -196,7 +196,8 @@ export default function BlobEditor({ fileContent, filePath, fileName, onCancel } content: content, commit_message: commitMessage, author_email: currentUser?.email, - author_username: currentUser?.username, + // Persist campsite public id (not Campsite username / github login). + author_username: currentUser?.id, mode: 'force_create', skip_build: skipBuild }) @@ -218,7 +219,7 @@ export default function BlobEditor({ fileContent, filePath, fileName, onCancel } content, commitMessage, currentUser?.email, - currentUser?.username, + currentUser?.id, skipBuild, onCancel ]) diff --git a/moon/apps/web/components/CodeView/NewCodeView/NewCodeView.tsx b/moon/apps/web/components/CodeView/NewCodeView/NewCodeView.tsx index 515e2996e..31011a728 100644 --- a/moon/apps/web/components/CodeView/NewCodeView/NewCodeView.tsx +++ b/moon/apps/web/components/CodeView/NewCodeView/NewCodeView.tsx @@ -46,7 +46,8 @@ const NewCodeView = ({ currentPath = '', onClose, defaultType = 'file' }: NewCod is_directory: fileType === 'folder', content: fileType === 'file' ? content : '', author_email: currentUser?.email, - author_username: currentUser?.username, + // Persist campsite public id (not Campsite username / github login). + author_username: currentUser?.id, mode: 'force_create', skip_build: skipBuild }, diff --git a/moon/apps/web/components/NavigationSidebar/ChangelogDropdown.tsx b/moon/apps/web/components/NavigationSidebar/ChangelogDropdown.tsx deleted file mode 100644 index b6dce09f8..000000000 --- a/moon/apps/web/components/NavigationSidebar/ChangelogDropdown.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useEffect, useState } from 'react' - -import { SITE_URL } from '@gitmono/config' -import { ArrowUpRightIcon, Button, ShipIcon, ShipUnreadIcon } from '@gitmono/ui' -import { DropdownMenu } from '@gitmono/ui/DropdownMenu' -import { buildMenuItems } from '@gitmono/ui/Menu' - -import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' -import { useGetChangelog } from '@/hooks/useGetLatestRelease' -import { useStoredState } from '@/hooks/useStoredState' -import { Changelog } from '@/utils/types' - -export function ChangelogDropdown({ - side = 'top', - align = 'start' -}: { - side?: 'top' | 'bottom' - align?: 'start' | 'end' | 'center' -}) { - const { data: currentUser } = useGetCurrentUser() - const { data: changelog } = useGetChangelog({ enabled: true }) - const [ls, setLs] = useStoredState('seen-changelogs', []) - const isUnread = (release: Changelog) => !ls.includes(release.slug) - - const [v2Ls] = useStoredState('latest-release-upsell-sidebar', '') - const [hasUnread, setHasUnread] = useState(false) - - // mark as unread - useEffect(() => { - if (!changelog || changelog.length === 0) return - - // don't trigger unread dot for users who onboarded after the latest changelog was published - const userOnboardedAfterLatestChangelog = - currentUser?.onboarded_at && currentUser.onboarded_at > changelog[0].published_at - - if (userOnboardedAfterLatestChangelog) return - - const v2LsHasLatestRelease = v2Ls && v2Ls === changelog[0].slug - - if (v2LsHasLatestRelease) return - - // otherwise, mark it unread if the user hasn't seen the latest changelogs - setHasUnread(changelog.some((c) => !ls.includes(c.slug))) - }, [ls, changelog, currentUser, v2Ls]) - - const trigger = ( -