From 887c492464cadc7c34e3ac9b7a090e12d4c1f243 Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Tue, 4 Aug 2026 10:49:22 +0800 Subject: [PATCH 1/3] feat(web): pick reviewers when editing policies.cedar Add a BlobEditor member multi-select that rewrites the matching path's `to [...]` list, mirroring MegaCedarAdminPicker without a new API. --- Cargo.lock | 734 +++--------------- Cargo.toml | 12 +- .../CodeView/BlobView/BlobEditor.tsx | 20 + .../BlobView/CedarPoliciesReviewerPicker.tsx | 180 +++++ .../__tests__/cedarPoliciesUtils.test.ts | 61 ++ .../CodeView/BlobView/cedarPoliciesUtils.ts | 154 ++++ 6 files changed, 545 insertions(+), 616 deletions(-) create mode 100644 moon/apps/web/components/CodeView/BlobView/CedarPoliciesReviewerPicker.tsx create mode 100644 moon/apps/web/components/CodeView/BlobView/__tests__/cedarPoliciesUtils.test.ts create mode 100644 moon/apps/web/components/CodeView/BlobView/cedarPoliciesUtils.ts diff --git a/Cargo.lock b/Cargo.lock index f1d8244e8..c038b1870 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,9 +117,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -236,9 +236,9 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ "object", ] @@ -328,9 +328,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arrow" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" dependencies = [ "arrow-arith", "arrow-array", @@ -346,9 +346,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ "arrow-array", "arrow-buffer", @@ -360,9 +360,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -370,7 +370,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -378,9 +378,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -390,9 +390,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ "arrow-array", "arrow-buffer", @@ -411,9 +411,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", @@ -424,9 +424,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ "arrow-array", "arrow-buffer", @@ -437,9 +437,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ "arrow-array", "arrow-buffer", @@ -450,15 +450,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" [[package]] name = "arrow-select" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash 0.8.12", "arrow-array", @@ -470,9 +470,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" dependencies = [ "arrow-array", "arrow-buffer", @@ -1318,7 +1318,7 @@ dependencies = [ "hex", "idgenerator", "rand 0.10.2", - "sea-orm 2.0.0", + "sea-orm", "serde", "sha2 0.11.0", ] @@ -1442,7 +1442,7 @@ dependencies = [ "chrono", "common", "futures", - "git-internal 0.8.5", + "git-internal", "hex", "io-orbit", "jupiter", @@ -1559,9 +1559,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -1569,9 +1569,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -1671,13 +1671,13 @@ dependencies = [ "config", "directories", "envsubst", - "git-internal 0.8.5", + "git-internal", "idgenerator", "pgp", "redis", "regex", "rkyv 0.8.17", - "sea-orm 2.0.0", + "sea-orm", "serde", "serde_json", "thiserror 2.0.19", @@ -2230,9 +2230,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbl" @@ -2671,14 +2671,14 @@ dependencies = [ [[package]] name = "educe" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c3e1715a2bf74bc8f68cd7bae12ff144f02669c602106ad1fa16f2ba62e646" +checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd" dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2820,13 +2820,13 @@ dependencies = [ [[package]] name = "enum-ordinalize-derive" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2916,17 +2916,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "etcetera" version = "0.11.0" @@ -3017,22 +3006,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", "zlib-rs", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin 0.9.9", -] - [[package]] name = "flume" version = "0.12.0" @@ -3307,55 +3284,9 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3373b0719c11eed1df7d80242354816316b44c7f842f519daf60efacec7a861a" -dependencies = [ - "ahash 0.8.12", - "async-trait", - "axum", - "bincode 2.0.1", - "bstr", - "byteorder", - "bytes", - "chrono", - "colored", - "crc32fast", - "dashmap 6.2.1", - "diffs", - "encoding_rs", - "flate2", - "futures", - "futures-util", - "hex", - "libc", - "lru-mem", - "memchr", - "natord", - "num_cpus", - "path-absolutize 3.1.1", - "rayon", - "sea-orm 1.1.20", - "serde", - "sha1 0.10.7", - "sha2 0.10.9", - "similar 2.7.0", - "tempfile", - "thiserror 2.0.19", - "threadpool", - "tokio", - "tokio-stream", - "tracing", - "tracing-subscriber", - "uuid", - "zstd-sys", -] - -[[package]] -name = "git-internal" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cc02fd83b0fa7dff557b1ed4a6095b027083af2f6d84244fb6813da7caaaae" +checksum = "4e8befc9216110293b0ed8277b2b224180fc3859ed4699bc71f3ed070475a9d1" dependencies = [ "ahash 0.8.12", "async-trait", @@ -3376,18 +3307,15 @@ dependencies = [ "libc", "lru-mem", "memchr", - "natord", "num_cpus", - "path-absolutize 4.0.1", + "path-absolutize", "rayon", - "ring", "rkyv 0.8.17", - "sea-orm 1.1.20", "serde", "serde_json", "sha1 0.11.0", "sha2 0.11.0", - "similar 3.1.1", + "similar", "tempfile", "thiserror 2.0.19", "threadpool", @@ -3536,15 +3464,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "hashlink" version = "0.11.1" @@ -3763,9 +3682,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", "subtle", @@ -4065,17 +3984,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inherent" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2c455ca60511a054699102d40ce7153621cb2429558f9eaa600e4499b5984" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "inout" version = "0.1.4" @@ -4145,9 +4053,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ipnetwork" @@ -4346,7 +4254,7 @@ dependencies = [ "chrono", "common", "futures", - "git-internal 0.8.5", + "git-internal", "hex", "hmac 0.13.0", "idgenerator", @@ -4362,7 +4270,7 @@ dependencies = [ "rsa 0.9.10", "rustls", "saturn", - "sea-orm 2.0.0", + "sea-orm", "serde", "serde_json", "sha1 0.11.0", @@ -4381,7 +4289,7 @@ dependencies = [ "callisto", "chrono", "common", - "sea-orm 2.0.0", + "sea-orm", "sea-orm-migration", "serde_json", "tempfile", @@ -4506,12 +4414,12 @@ dependencies = [ [[package]] name = "lettre" -version = "0.11.22" +version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.0", "email-encoding", "email_address", "fastrand", @@ -4658,9 +4566,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "bitflags 2.13.1", "libc", @@ -4746,17 +4654,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -5085,7 +4982,7 @@ dependencies = [ "common", "ctrlc", "futures", - "git-internal 0.8.5", + "git-internal", "http", "jemallocator", "jupiter", @@ -5176,12 +5073,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "natord" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -5443,9 +5334,9 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "memchr", ] @@ -5696,7 +5587,7 @@ dependencies = [ "io-orbit", "once_cell", "rand 0.10.2", - "sea-orm 2.0.0", + "sea-orm", "serde", "serde_json", "tempfile", @@ -5975,31 +5866,13 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "path-absolutize" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" -dependencies = [ - "path-dedot 3.1.1", -] - [[package]] name = "path-absolutize" version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f808742975794703469f67a28dd14b1d1009a1743c18b0353b4b951dbb0068ad" dependencies = [ - "path-dedot 4.0.1", -] - -[[package]] -name = "path-dedot" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" -dependencies = [ - "once_cell", + "path-dedot", ] [[package]] @@ -6554,9 +6427,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -7336,9 +7209,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.4" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f" +checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ "aes 0.9.2", "aws-lc-rs", @@ -7420,9 +7293,9 @@ dependencies = [ [[package]] name = "russh-sftp" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" +checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" dependencies = [ "bitflags 2.13.1", "bytes", @@ -7756,7 +7629,7 @@ dependencies = [ "dashmap 6.2.1", "env_logger", "futures", - "git-internal 0.8.5", + "git-internal", "hex", "libc", "libfuse-fs", @@ -7804,40 +7677,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" -dependencies = [ - "async-stream", - "async-trait", - "bigdecimal", - "chrono", - "derive_more 2.1.1", - "futures-util", - "log", - "mac_address", - "ouroboros", - "pgvector", - "rust_decimal", - "sea-orm-macros 1.1.20", - "sea-query 0.32.7", - "sea-query-binder", - "serde", - "serde_json", - "sqlx 0.8.6", - "strum 0.26.3", - "thiserror 2.0.19", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "sea-orm" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "549c6d29a2e7a84e35c5dfbc06370a62c4d35f2328c31005267bec586b34f682" +checksum = "e29e1a2e2e6da3d2486086f9d96b4bf5b7d8bf344e16c48bd87ce7977b8d0154" dependencies = [ "async-stream", "async-trait", @@ -7853,14 +7695,14 @@ dependencies = [ "pgvector", "rust_decimal", "sea-orm-arrow", - "sea-orm-macros 2.0.0", - "sea-query 1.0.1", + "sea-orm-macros", + "sea-query", "sea-query-sqlx", "sea-schema", "serde", "serde_json", - "sqlx 0.9.0", - "sqlx-core 0.9.0", + "sqlx", + "sqlx-core", "strum 0.28.0", "thiserror 2.0.19", "time", @@ -7872,20 +7714,20 @@ dependencies = [ [[package]] name = "sea-orm-arrow" -version = "2.0.0-rc.3" +version = "2.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" dependencies = [ "arrow", - "sea-query 1.0.1", + "sea-query", "thiserror 2.0.19", ] [[package]] name = "sea-orm-cli" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec4b7c39c90efa92b8bb0b91ac7159684b9fa0d6371ce39bfab0a9793ce7394" +checksum = "53cc7731b9472864d21bc6223c6747de539c5ec0cfe29bbd92aac0537c22812c" dependencies = [ "chrono", "clap", @@ -7900,23 +7742,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9a3f90e336ec74803e8eb98c61bc98754c1adfba3b4f84d946237b752b1c88" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "sea-bae", - "syn 2.0.119", - "unicode-ident", -] - -[[package]] -name = "sea-orm-macros" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4cbb7ae053b9b37c919ea944a3bc52f7830a90c223f8a765c908293bca8f22d" +checksum = "88a6fd7ae2932a838d3bc7ca1e5483ffa10d6b25e4c43c8aebb4cd14773c2d08" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -7930,36 +7758,20 @@ dependencies = [ [[package]] name = "sea-orm-migration" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef3576c5301b67ea7d2ce4515858d700066e51911c7d228a439c00ff258695c8" +checksum = "95ae369463e80e8a75e57434ba10dd9008a1010d81ec66cba5909561add7223f" dependencies = [ "async-trait", "clap", "dotenvy", - "sea-orm 2.0.0", + "sea-orm", "sea-orm-cli", "sea-schema", "tracing", "tracing-subscriber", ] -[[package]] -name = "sea-query" -version = "0.32.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" -dependencies = [ - "bigdecimal", - "chrono", - "inherent", - "ordered-float", - "rust_decimal", - "serde_json", - "time", - "uuid", -] - [[package]] name = "sea-query" version = "1.0.1" @@ -7975,22 +7787,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "sea-query-binder" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" -dependencies = [ - "bigdecimal", - "chrono", - "rust_decimal", - "sea-query 0.32.7", - "serde_json", - "sqlx 0.8.6", - "time", - "uuid", -] - [[package]] name = "sea-query-derive" version = "1.0.0" @@ -8011,8 +7807,8 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" dependencies = [ - "sea-query 1.0.1", - "sqlx 0.9.0", + "sea-query", + "sqlx", ] [[package]] @@ -8022,10 +7818,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" dependencies = [ "async-trait", - "sea-query 1.0.1", + "sea-query", "sea-query-sqlx", "sea-schema-derive", - "sqlx 0.9.0", + "sqlx", ] [[package]] @@ -8513,12 +8309,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "similar" version = "3.1.1" @@ -8657,67 +8447,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core 0.8.6", - "sqlx-macros 0.8.6", - "sqlx-mysql 0.8.6", - "sqlx-postgres 0.8.6", - "sqlx-sqlite 0.8.6", -] - [[package]] name = "sqlx" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ - "sqlx-core 0.9.0", - "sqlx-macros 0.9.0", - "sqlx-mysql 0.9.0", - "sqlx-postgres 0.9.0", - "sqlx-sqlite 0.9.0", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64 0.22.1", - "bigdecimal", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink 0.10.0", - "indexmap 2.14.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rust_decimal", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror 2.0.19", - "time", - "tracing", - "url", - "uuid", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] @@ -8739,7 +8479,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink 0.11.1", + "hashlink", "indexmap 2.14.0", "log", "memchr", @@ -8760,19 +8500,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core 0.8.6", - "sqlx-macros-core 0.8.6", - "syn 2.0.119", -] - [[package]] name = "sqlx-macros" version = "0.9.0" @@ -8781,35 +8508,11 @@ checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", - "sqlx-core 0.9.0", - "sqlx-macros-core 0.9.0", + "sqlx-core", + "sqlx-macros-core", "syn 2.0.119", ] -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck 0.5.0", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core 0.8.6", - "sqlx-mysql 0.8.6", - "sqlx-postgres 0.8.6", - "sqlx-sqlite 0.8.6", - "syn 2.0.119", - "url", -] - [[package]] name = "sqlx-macros-core" version = "0.9.0" @@ -8826,62 +8529,15 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core 0.9.0", - "sqlx-mysql 0.9.0", - "sqlx-postgres 0.9.0", - "sqlx-sqlite 0.9.0", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", "syn 2.0.119", "tokio", "url", ] -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64 0.22.1", - "bigdecimal", - "bitflags 2.13.1", - "byteorder", - "bytes", - "chrono", - "crc", - "digest 0.10.7", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array 0.14.7", - "hex", - "hkdf 0.12.4", - "hmac 0.12.1", - "itoa", - "log", - "md-5 0.10.6", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.7", - "rsa 0.9.10", - "rust_decimal", - "serde", - "sha1 0.10.7", - "sha2 0.10.9", - "smallvec", - "sqlx-core 0.8.6", - "stringprep", - "thiserror 2.0.19", - "time", - "tracing", - "uuid", - "whoami 1.6.1", -] - [[package]] name = "sqlx-mysql" version = "0.9.0" @@ -8905,56 +8561,13 @@ dependencies = [ "serde", "sha1 0.11.0", "sha2 0.11.0", - "sqlx-core 0.9.0", + "sqlx-core", "thiserror 2.0.19", "time", "tracing", "uuid", ] -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64 0.22.1", - "bigdecimal", - "bitflags 2.13.1", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera 0.8.0", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf 0.12.4", - "hmac 0.12.1", - "home", - "itoa", - "log", - "md-5 0.10.6", - "memchr", - "num-bigint", - "once_cell", - "rand 0.8.7", - "rust_decimal", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "sqlx-core 0.8.6", - "stringprep", - "thiserror 2.0.19", - "time", - "tracing", - "uuid", - "whoami 1.6.1", -] - [[package]] name = "sqlx-postgres" version = "0.9.0" @@ -8968,7 +8581,7 @@ dependencies = [ "chrono", "crc", "dotenvy", - "etcetera 0.11.0", + "etcetera", "futures-channel", "futures-core", "futures-util", @@ -8985,7 +8598,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "smallvec", - "sqlx-core 0.9.0", + "sqlx-core", "stringprep", "thiserror 2.0.19", "time", @@ -8994,33 +8607,6 @@ dependencies = [ "whoami 2.1.2", ] -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume 0.11.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core 0.8.6", - "thiserror 2.0.19", - "time", - "tracing", - "url", - "uuid", -] - [[package]] name = "sqlx-sqlite" version = "0.9.0" @@ -9029,7 +8615,7 @@ checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume 0.12.0", + "flume", "form_urlencoded", "futures-channel", "futures-core", @@ -9040,7 +8626,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sqlx-core 0.9.0", + "sqlx-core", "thiserror 2.0.19", "time", "tracing", @@ -9115,9 +8701,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", @@ -9234,12 +8820,6 @@ dependencies = [ "strum_macros", ] -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" - [[package]] name = "strum" version = "0.28.0" @@ -9515,9 +9095,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -10959,22 +10539,13 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -10983,7 +10554,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -10995,35 +10566,20 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -11044,36 +10600,18 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -11086,48 +10624,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -11227,7 +10741,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.1", + "hashlink", ] [[package]] @@ -11369,9 +10883,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 7bf4dd644..629386783 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.5" +git-internal = "0.8.6" libvault-core = "0.1.0" #==== @@ -50,7 +50,7 @@ tracing = "0.1.44" tracing-subscriber = "0.3.23" tracing-appender = "0.2.5" thiserror = "2.0.19" -clap = "4.6.4" +clap = "4.6.5" #==== tokio = "1.53.1" @@ -63,11 +63,11 @@ 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.4" +russh = "0.62.5" tower-http = "0.7.0" tower = "0.5.3" tower-sessions = { version = "0.15", features = ["memory-store"] } -time = { version = "0.3.54", features = ["serde"] } +time = { version = "0.3.55", features = ["serde"] } lettre = { version = "0.11", default-features = false, features = [ "builder", "smtp-transport", @@ -77,8 +77,8 @@ lettre = { version = "0.11", default-features = false, features = [ "rustls-platform-verifier", ] } #==== -sea-orm = "2.0.0" -sea-orm-migration = "2.0.0" +sea-orm = "2.0.1" +sea-orm-migration = "2.0.1" #==== rand = "0.10.2" diff --git a/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx b/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx index 8c817e989..45c4db99c 100644 --- a/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx +++ b/moon/apps/web/components/CodeView/BlobView/BlobEditor.tsx @@ -16,6 +16,7 @@ import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' import { useUpdateBlob } from '@/hooks/useUpdateBlob' import { getLanguageForFile } from '@/utils/shikiLanguageFallback' +import { CedarPoliciesReviewerPicker } from './CedarPoliciesReviewerPicker' import { MegaCedarAdminPicker } from './MegaCedarAdminPicker' type ShikiLine = Array<{ content: string; color?: string }> @@ -33,6 +34,15 @@ function isMegaCedarJsonFile(name: string, path: string) { return name === '.mega_cedar.json' || path.endsWith('/.mega_cedar.json') || path === '.mega_cedar.json' } +function isCedarPoliciesFile(name: string, path: string) { + return ( + name === 'policies.cedar' || + path.endsWith('/.cedar/policies.cedar') || + path === '.cedar/policies.cedar' || + path.endsWith('/policies.cedar') + ) +} + export default function BlobEditor({ fileContent, filePath, fileName, onCancel }: BlobEditorProps) { const { data: currentUser } = useGetCurrentUser() const { theme, resolvedTheme } = useTheme() @@ -79,6 +89,7 @@ export default function BlobEditor({ fileContent, filePath, fileName, onCancel } }, [pathSegments, editedFileName]) const showCedarAdminPicker = isMegaCedarJsonFile(editedFileName, fullEditedPath) + const showCedarPoliciesPicker = isCedarPoliciesFile(editedFileName, fullEditedPath) const detectedLanguage = useMemo(() => getLanguageForFile(editedFileName), [editedFileName]) @@ -416,6 +427,15 @@ export default function BlobEditor({ fileContent, filePath, fileName, onCancel } /> )} + {showCedarPoliciesPicker && viewMode === 'edit' && ( + + )} +
{viewMode === 'edit' && renderEditView()} {viewMode === 'preview' && renderPreviewView()} diff --git a/moon/apps/web/components/CodeView/BlobView/CedarPoliciesReviewerPicker.tsx b/moon/apps/web/components/CodeView/BlobView/CedarPoliciesReviewerPicker.tsx new file mode 100644 index 000000000..44f790f0f --- /dev/null +++ b/moon/apps/web/components/CodeView/BlobView/CedarPoliciesReviewerPicker.tsx @@ -0,0 +1,180 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import toast from 'react-hot-toast' + +import { LoadingSpinner } from '@gitmono/ui' + +import { useGetSyncMembers } from '@/hooks/useGetSyncMembers' + +import { + inferPathPatternFromFilePath, + reviewersForPathPattern, + rewriteReviewersForPathPattern +} from './cedarPoliciesUtils' + +interface CedarPoliciesReviewerPickerProps { + filePath: string + fileContent: string + onContentGenerated: (content: string) => void + disabled?: boolean +} + +export function CedarPoliciesReviewerPicker({ + filePath, + fileContent, + onContentGenerated, + disabled = false +}: CedarPoliciesReviewerPickerProps) { + const [memberSearchQuery, setMemberSearchQuery] = useState('') + const [selectedReviewers, setSelectedReviewers] = useState([]) + const initializedRef = useRef(false) + + const pathPattern = useMemo(() => inferPathPatternFromFilePath(filePath), [filePath]) + + const { + members, + isLoading: isMembersLoading, + refetch: refetchMembers, + error: membersError + } = useGetSyncMembers({ + query: memberSearchQuery, + excludeCurrentUser: false, + enabled: true + }) + + const parsedReviewers = useMemo(() => reviewersForPathPattern(fileContent, pathPattern), [fileContent, pathPattern]) + + useEffect(() => { + if (initializedRef.current) return + + setSelectedReviewers(parsedReviewers) + initializedRef.current = true + }, [parsedReviewers]) + + const applyReviewers = useCallback( + (reviewers: string[]) => { + if (reviewers.length === 0) { + toast.error('Select at least one reviewer') + return + } + + const next = rewriteReviewersForPathPattern(fileContent, pathPattern, reviewers) + + onContentGenerated(next) + }, + [fileContent, onContentGenerated, pathPattern] + ) + + const handleToggle = useCallback( + (username: string) => { + setSelectedReviewers((prev) => { + const next = prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username].sort() + + if (next.length === 0) { + toast.error('Select at least one reviewer') + return prev + } + + applyReviewers(next) + return next + }) + }, + [applyReviewers] + ) + + const pathLabel = pathPattern === '' ? '(all paths)' : pathPattern + + return ( +
+
+
+
Required reviewers
+
+ Select GitHub logins for{' '} + startsWith("{pathPattern}") →{' '} + {pathLabel} +
+
+
+ + {membersError && ( +
+

Failed to load organization members

+ +
+ )} + + setMemberSearchQuery(e.target.value)} + className='mb-3 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-hidden' + placeholder='Search members by name or username...' + disabled={disabled || !!membersError} + /> + +
+ {isMembersLoading ? ( +
+ + Loading members… +
+ ) : members.length === 0 ? ( +
No members found
+ ) : ( +
+ {members.map((member) => { + const cedarId = member.user.github_login || member.user.username + const isSelected = selectedReviewers.includes(cedarId) + + return ( + + ) + })} +
+ )} +
+ +
+ Selected: {selectedReviewers.length > 0 ? selectedReviewers.join(', ') : 'none'} +
+
+ ) +} diff --git a/moon/apps/web/components/CodeView/BlobView/__tests__/cedarPoliciesUtils.test.ts b/moon/apps/web/components/CodeView/BlobView/__tests__/cedarPoliciesUtils.test.ts new file mode 100644 index 000000000..b842fef5f --- /dev/null +++ b/moon/apps/web/components/CodeView/BlobView/__tests__/cedarPoliciesUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { + inferPathPatternFromFilePath, + parseReviewerRules, + reviewersForPathPattern, + rewriteReviewersForPathPattern +} from '../cedarPoliciesUtils' + +describe('inferPathPatternFromFilePath', () => { + it('maps root .cedar policy to empty pattern', () => { + expect(inferPathPatternFromFilePath('.cedar/policies.cedar')).toBe('') + expect(inferPathPatternFromFilePath('/.cedar/policies.cedar')).toBe('') + }) + + it('maps nested .cedar policy to parent path with trailing slash', () => { + expect(inferPathPatternFromFilePath('project/svc/.cedar/policies.cedar')).toBe('project/svc/') + expect(inferPathPatternFromFilePath('/project/svc/.cedar/policies.cedar')).toBe('project/svc/') + }) +}) + +describe('parseReviewerRules / rewriteReviewersForPathPattern', () => { + const sample = `permit(action == "code:review", principal, resource) + when { resource.path.startsWith("") } + to ["alice", "bob"]; + +permit(action == "code:review", principal, resource) + when { resource.path.startsWith("project/svc/") } + to ["charlie"]; +` + + it('parses multiple rules', () => { + const rules = parseReviewerRules(sample) + + expect(rules).toHaveLength(2) + expect(rules[0].pathPattern).toBe('') + expect(rules[0].reviewers).toEqual(['alice', 'bob']) + expect(rules[1].pathPattern).toBe('project/svc/') + expect(rules[1].reviewers).toEqual(['charlie']) + }) + + it('reads reviewers for a path pattern', () => { + expect(reviewersForPathPattern(sample, '')).toEqual(['alice', 'bob']) + expect(reviewersForPathPattern(sample, 'project/svc/')).toEqual(['charlie']) + expect(reviewersForPathPattern(sample, 'missing/')).toEqual([]) + }) + + it('rewrites one path pattern and keeps others', () => { + const next = rewriteReviewersForPathPattern(sample, 'project/svc/', ['dave', 'erin']) + + expect(reviewersForPathPattern(next, '')).toEqual(['alice', 'bob']) + expect(reviewersForPathPattern(next, 'project/svc/')).toEqual(['dave', 'erin']) + }) + + it('appends a rule when path pattern is missing', () => { + const next = rewriteReviewersForPathPattern(sample, 'other/', ['zoe']) + + expect(reviewersForPathPattern(next, 'other/')).toEqual(['zoe']) + expect(reviewersForPathPattern(next, '')).toEqual(['alice', 'bob']) + }) +}) diff --git a/moon/apps/web/components/CodeView/BlobView/cedarPoliciesUtils.ts b/moon/apps/web/components/CodeView/BlobView/cedarPoliciesUtils.ts new file mode 100644 index 000000000..9e3a470ef --- /dev/null +++ b/moon/apps/web/components/CodeView/BlobView/cedarPoliciesUtils.ts @@ -0,0 +1,154 @@ +/** + * Parse / rewrite helpers for `.cedar/policies.cedar` reviewer rules. + * Matches the custom syntax understood by saturn/src/reviewer_parser.rs: + * + * permit(action == "code:review", principal, resource) + * when { resource.path.startsWith("path/") } + * to ["alice", "bob"]; + */ + +export type CedarReviewerRule = { + pathPattern: string + reviewers: string[] + /** Full matched rule text including trailing semicolon when present */ + raw: string +} + +const RULE_PATTERN = + /permit\s*\([^)]*\)\s*when\s*\{\s*resource\.path\.startsWith\s*\(\s*"([^"]*)"\s*\)\s*\}\s*to\s*\[([^\]]+)\]\s*;?/gs + +const REVIEWER_PATTERN = /"([^"]+)"/g + +/** Infer startsWith path pattern from a policies.cedar file path. */ +export function inferPathPatternFromFilePath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, '') + + // Expect .../.cedar/policies.cedar + const cedarSuffix = '/.cedar/policies.cedar' + + if (normalized === '.cedar/policies.cedar' || normalized.endsWith(cedarSuffix)) { + const parent = normalized.endsWith(cedarSuffix) ? normalized.slice(0, -cedarSuffix.length) : '' + + if (!parent) return '' + return parent.endsWith('/') ? parent : `${parent}/` + } + + if (normalized === 'policies.cedar') return '' + + return '' +} + +export function parseReviewerRules(content: string): CedarReviewerRule[] { + const rules: CedarReviewerRule[] = [] + const re = new RegExp(RULE_PATTERN.source, RULE_PATTERN.flags) + + let match: RegExpExecArray | null + + while ((match = re.exec(content)) !== null) { + const pathPattern = match[1] ?? '' + const reviewersStr = match[2] ?? '' + const reviewers: string[] = [] + const reviewerRe = new RegExp(REVIEWER_PATTERN.source, 'g') + let rMatch: RegExpExecArray | null + + while ((rMatch = reviewerRe.exec(reviewersStr)) !== null) { + if (rMatch[1]) reviewers.push(rMatch[1]) + } + + if (reviewers.length > 0) { + rules.push({ + pathPattern, + reviewers, + raw: match[0] + }) + } + } + + return rules +} + +export function reviewersForPathPattern(content: string, pathPattern: string): string[] { + const normalized = pathPattern.trim() + const rules = parseReviewerRules(content) + const matched = rules.filter((r) => r.pathPattern === normalized) + + if (matched.length === 0) return [] + + const seen = new Set() + const out: string[] = [] + + for (const rule of matched) { + for (const name of rule.reviewers) { + if (!seen.has(name)) { + seen.add(name) + out.push(name) + } + } + } + + return out.sort() +} + +function formatRule(pathPattern: string, reviewers: string[]): string { + const list = reviewers.map((r) => `"${r}"`).join(', ') + + return `permit(action == "code:review", principal, resource) + when { resource.path.startsWith("${pathPattern}") } + to [${list}];` +} + +/** + * Update or insert the rule for `pathPattern` with the given reviewers. + * Other path-pattern rules are preserved in order. + */ +export function rewriteReviewersForPathPattern(content: string, pathPattern: string, reviewers: string[]): string { + if (reviewers.length === 0) { + return content + } + + const sortedReviewers = [...reviewers].sort() + const newRule = formatRule(pathPattern, sortedReviewers) + const re = new RegExp(RULE_PATTERN.source, RULE_PATTERN.flags) + + const parts: string[] = [] + let lastIndex = 0 + let replaced = false + let match: RegExpExecArray | null + + while ((match = re.exec(content)) !== null) { + const rulePath = match[1] ?? '' + + parts.push(content.slice(lastIndex, match.index)) + + if (!replaced && rulePath === pathPattern) { + parts.push(newRule) + replaced = true + } else if (rulePath === pathPattern) { + // Drop duplicate rules for the same path once we've replaced the first. + } else { + // Preserve original formatting for other rules. + parts.push(match[0].endsWith(';') ? match[0] : `${match[0]};`) + } + + lastIndex = match.index + match[0].length + } + + parts.push(content.slice(lastIndex)) + + if (replaced) { + return ( + parts + .join('') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ) + } + + const trimmed = content.trimEnd() + + if (trimmed.length === 0) { + return `${newRule}\n` + } + + return `${trimmed}\n\n${newRule}\n` +} From 3844c915020bb9f0e24fdbad4a338ab8fd4a7d25 Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Tue, 4 Aug 2026 14:34:59 +0800 Subject: [PATCH 2/3] feat(identity): persist mono actors as campsite_user_id Replace username-keyed collaboration storage with Campsite public ids, keep github_login for Cedar/display, and resolve CL UI members by id so handles no longer render as opaque ids. --- .gitignore | 3 + .../api_service/mono/cl/lifecycle.rs | 6 +- .../application/api_service/mono/cl/merge.rs | 3 +- ceres/src/application/api_service/mono/cla.rs | 2 +- .../api_service/mono/code_review.rs | 4 +- .../api_service/mono/label_assignee.rs | 5 +- .../application/api_service/mono/reviewer.rs | 23 +- .../src/application/api_service/mono/user.rs | 8 +- .../api_service/mono/user_approval.rs | 25 +- ceres/src/application/build_trigger/model.rs | 2 +- ceres/src/application/code_edit/model.rs | 16 +- ceres/src/application/code_edit/on_edit.rs | 2 +- ceres/src/application/code_edit/on_push.rs | 2 +- .../src/application/notification/triggers.rs | 15 +- .../src/merge_checker/code_review_checker.rs | 19 +- ceres/src/merge_checker/mod.rs | 4 +- ceres/src/model/change_list.rs | 7 +- ceres/src/model/conversation.rs | 6 +- ceres/src/model/group.rs | 4 +- ceres/src/model/issue.rs | 8 +- common/README.md | 1 + common/src/config/loader.rs | 27 +- common/src/config/mod.rs | 5 + config/config.local.toml.example | 8 + config/config.toml | 6 +- docs/development.md | 3 +- jupiter-migrate/README.md | 2 + ...260804_120000_actor_to_campsite_user_id.rs | 268 ++++++++++++++++++ .../m20260804_130000_data_backfill_ledger.rs | 69 +++++ jupiter-migrate/src/migration/mod.rs | 4 + jupiter-migrate/src/migration/runner.rs | 10 +- jupiter/callisto/src/access_token.rs | 3 +- jupiter/callisto/src/cla_sign_status.rs | 2 +- jupiter/callisto/src/data_backfill_ledger.rs | 19 ++ jupiter/callisto/src/email_jobs.rs | 2 +- jupiter/callisto/src/entity_ext/mega_cl.rs | 212 +++++++------- .../entity_ext/mega_code_review_comment.rs | 4 +- .../src/entity_ext/mega_conversation.rs | 4 +- jupiter/callisto/src/entity_ext/mega_issue.rs | 4 +- jupiter/callisto/src/entity_ext/reactions.rs | 4 +- jupiter/callisto/src/item_assignees.rs | 2 +- jupiter/callisto/src/mega_cl.rs | 2 +- jupiter/callisto/src/mega_cl_reviewer.rs | 3 +- .../callisto/src/mega_code_review_comment.rs | 2 +- jupiter/callisto/src/mega_conversation.rs | 2 +- jupiter/callisto/src/mega_group_member.rs | 2 +- jupiter/callisto/src/mega_issue.rs | 2 +- jupiter/callisto/src/mod.rs | 1 + jupiter/callisto/src/prelude.rs | 15 +- jupiter/callisto/src/reactions.rs | 2 +- jupiter/callisto/src/ssh_keys.rs | 2 +- jupiter/callisto/src/user_approval_status.rs | 1 - .../src/user_notification_preferences.rs | 6 +- .../src/user_notification_settings.rs | 2 +- jupiter/src/model/cl_dto.rs | 2 +- jupiter/src/model/code_review_dto.rs | 2 +- jupiter/src/service/reviewer_service.rs | 96 +++++-- jupiter/src/service/webhook_service.rs | 4 +- jupiter/src/storage/cl_reviewer_storage.rs | 88 ++++-- jupiter/src/storage/cl_storage.rs | 6 +- jupiter/src/storage/cla_storage.rs | 14 +- jupiter/src/storage/conversation_storage.rs | 2 +- jupiter/src/storage/data_backfill_storage.rs | 248 ++++++++++++++++ jupiter/src/storage/group_storage.rs | 10 +- jupiter/src/storage/issue_storage.rs | 8 +- jupiter/src/storage/mod.rs | 10 + jupiter/src/storage/notification_storage.rs | 12 +- jupiter/src/storage/stg_common/mod.rs | 11 +- jupiter/src/storage/stg_common/query_build.rs | 2 +- jupiter/src/storage/user_approval_storage.rs | 30 +- jupiter/src/storage/user_storage.rs | 51 ++-- jupiter/src/tests.rs | 8 +- mono/README.md | 2 + mono/src/api/api_common/comment.rs | 9 +- mono/src/api/api_common/group_permission.rs | 8 +- mono/src/api/api_common/identity.rs | 16 ++ mono/src/api/api_common/label_assignee.rs | 11 +- mono/src/api/api_common/mod.rs | 1 + mono/src/api/oauth/mod.rs | 6 +- mono/src/api/oauth/model.rs | 24 +- mono/src/api/router/admin_router.rs | 35 +-- mono/src/api/router/buck_router.rs | 19 +- mono/src/api/router/build_trigger_router.rs | 9 +- mono/src/api/router/cl_router.rs | 35 +-- mono/src/api/router/code_review_router.rs | 15 +- mono/src/api/router/conv_router.rs | 9 +- mono/src/api/router/group_router.rs | 11 +- mono/src/api/router/issue_router.rs | 29 +- mono/src/api/router/permission_router.rs | 7 +- mono/src/api/router/repo_router.rs | 6 +- mono/src/api/router/reviewer_router.rs | 56 ++-- mono/src/api/router/user_router.rs | 56 ++-- mono/src/backfill/actor_identity.rs | 140 +++++++++ mono/src/backfill/mod.rs | 5 + mono/src/git_protocol/http.rs | 16 +- mono/src/lib.rs | 1 + mono/src/server/http_server.rs | 3 + moon/apps/web/components/ClBox/MergeBox.tsx | 8 +- .../web/components/ClBox/MergeSection.tsx | 4 +- .../web/components/ClBox/ReviewerSection.tsx | 2 +- .../web/components/ClView/ApproveItem.tsx | 4 +- .../web/components/ClView/AssigneeItem.tsx | 4 +- moon/apps/web/components/ClView/CLComment.tsx | 4 +- moon/apps/web/components/ClView/CloseItem.tsx | 4 +- .../web/components/ClView/ConversationTab.tsx | 18 +- moon/apps/web/components/ClView/EditItem.tsx | 4 +- moon/apps/web/components/ClView/LabelItem.tsx | 4 +- .../apps/web/components/ClView/MergedItem.tsx | 4 +- .../apps/web/components/ClView/ReopenItem.tsx | 4 +- .../web/components/ClView/ReviewComment.tsx | 6 +- .../web/components/ClView/TimelineItems.tsx | 4 +- .../ClView/components/MegaUserLabel.tsx | 4 +- moon/apps/web/components/ClView/index.tsx | 4 +- .../components/ClView/useReviewerSelector.tsx | 17 +- .../BlobView/MegaCedarAdminPicker.tsx | 66 ++++- .../web/components/Issues/IssueDetailPage.tsx | 20 +- .../web/components/Issues/IssueNewPage.tsx | 20 +- .../components/Issues/utils/sideEffect.tsx | 25 +- .../web/components/Setting/PersonalToken.tsx | 5 +- moon/apps/web/hooks/admin/useAdminList.ts | 11 +- moon/apps/web/hooks/useMemberByActor.ts | 14 + moon/apps/web/next-env.d.ts | 2 +- moon/apps/web/utils/apiErrorToast.ts | 19 +- moon/apps/web/utils/megaUser.ts | 11 +- moon/apps/web/utils/queryClient.ts | 41 ++- moon/packages/types/generated.ts | 17 +- 126 files changed, 1777 insertions(+), 571 deletions(-) create mode 100644 config/config.local.toml.example create mode 100644 jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs create mode 100644 jupiter-migrate/src/migration/m20260804_130000_data_backfill_ledger.rs create mode 100644 jupiter/callisto/src/data_backfill_ledger.rs create mode 100644 jupiter/src/storage/data_backfill_storage.rs create mode 100644 mono/src/api/api_common/identity.rs create mode 100644 mono/src/backfill/actor_identity.rs create mode 100644 mono/src/backfill/mod.rs create mode 100644 moon/apps/web/hooks/useMemberByActor.ts diff --git a/.gitignore b/.gitignore index 4e0379e11..ea9e9877b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ orion-server/docker-compose.override.yml # local dev environment /scripts/dev/.env +# local mono config override (preferred over config/config.toml when present) +/config/config.local.toml + .buildx-cache # Claude Code configuration and cache diff --git a/ceres/src/application/api_service/mono/cl/lifecycle.rs b/ceres/src/application/api_service/mono/cl/lifecycle.rs index 0aa3a6038..6f9e49a72 100644 --- a/ceres/src/application/api_service/mono/cl/lifecycle.rs +++ b/ceres/src/application/api_service/mono/cl/lifecycle.rs @@ -162,7 +162,7 @@ impl ClApplicationService { // Attribute the merge to the CL author (e.g. mega-init bot), not a // synthetic "system" user — so the timeline can show a bot badge/avatar. - let merge_actor = model.username.clone(); + let merge_actor = model.campsite_user_id.clone(); self.merge_cl(&merge_actor, model.clone()).await?; if let Some(updated_model) = cl_storage.get_cl(link).await? { dispatch_cl_webhook(self.storage(), WebhookEvent::ClMerged, &updated_model); @@ -346,9 +346,9 @@ async fn enqueue_cl_comment_notifications( .await?; let mut recipients: HashSet = HashSet::new(); - recipients.insert(cl.username); + recipients.insert(cl.campsite_user_id); for r in reviewers { - recipients.insert(r.username); + recipients.insert(r.campsite_user_id); } recipients.remove(actor_username); diff --git a/ceres/src/application/api_service/mono/cl/merge.rs b/ceres/src/application/api_service/mono/cl/merge.rs index 531a56cb3..316a44269 100644 --- a/ceres/src/application/api_service/mono/cl/merge.rs +++ b/ceres/src/application/api_service/mono/cl/merge.rs @@ -29,7 +29,8 @@ use crate::{ impl ClApplicationService { // This function is intended to be called before merging a CL to ensure it meets all required checks. pub(crate) async fn ensure_cl_mergeable(&self, cl: &mega_cl::Model) -> Result<(), MegaError> { - let check_reg = CheckerRegistry::new(self.storage().clone().into(), cl.username.clone()); + let check_reg = + CheckerRegistry::new(self.storage().clone().into(), cl.campsite_user_id.clone()); check_reg.run_checks(cl.clone().into()).await?; let required_check_types = self diff --git a/ceres/src/application/api_service/mono/cla.rs b/ceres/src/application/api_service/mono/cla.rs index 3defe2921..0f9d36472 100644 --- a/ceres/src/application/api_service/mono/cla.rs +++ b/ceres/src/application/api_service/mono/cla.rs @@ -98,7 +98,7 @@ impl UserApplicationService { .get_open_cls() .await? .into_iter() - .filter(|cl| cl.username == username) + .filter(|cl| cl.campsite_user_id == username) .collect::>(); if open_cls.is_empty() { return Ok(()); diff --git a/ceres/src/application/api_service/mono/code_review.rs b/ceres/src/application/api_service/mono/code_review.rs index c89048048..2033d8af2 100644 --- a/ceres/src/application/api_service/mono/code_review.rs +++ b/ceres/src/application/api_service/mono/code_review.rs @@ -80,7 +80,7 @@ impl CodeReviewApplicationService { .await? .ok_or_else(|| MegaError::NotFound("Comment not found".to_string()))?; - if comment.user_name != username { + if comment.campsite_user_id != username { return Err(MegaError::Other( "Cannot update others' comments".to_string(), )); @@ -143,7 +143,7 @@ impl CodeReviewApplicationService { .await? .ok_or_else(|| MegaError::NotFound("Comment not found".to_string()))?; - if comment.user_name != username { + if comment.campsite_user_id != username { return Err(MegaError::Other( "Cannot update others' comments".to_string(), )); diff --git a/ceres/src/application/api_service/mono/label_assignee.rs b/ceres/src/application/api_service/mono/label_assignee.rs index 87ed54195..c9d62fc11 100644 --- a/ceres/src/application/api_service/mono/label_assignee.rs +++ b/ceres/src/application/api_service/mono/label_assignee.rs @@ -75,7 +75,10 @@ impl IssueApplicationService { let issue_storage = self.ctx.storage().issue_service.issue_store(); let old_models = issue_storage.find_item_exist_assignees(item_id).await?; - let old_ids: HashSet = old_models.iter().map(|m| m.assignnee_id.clone()).collect(); + let old_ids: HashSet = old_models + .iter() + .map(|m| m.campsite_user_id.clone()) + .collect(); let new_ids: HashSet = assignees.iter().cloned().collect(); let to_add: Vec = new_ids.difference(&old_ids).cloned().collect(); diff --git a/ceres/src/application/api_service/mono/reviewer.rs b/ceres/src/application/api_service/mono/reviewer.rs index cca58eaa8..c35cd2afd 100644 --- a/ceres/src/application/api_service/mono/reviewer.rs +++ b/ceres/src/application/api_service/mono/reviewer.rs @@ -4,7 +4,13 @@ use super::context::ReviewerApplicationService; use crate::model::change_list::{ReviewerInfo, ReviewersResponse}; impl ReviewerApplicationService { - pub async fn add_reviewers(&self, link: &str, reviewers: Vec) -> Result<(), MegaError> { + /// `reviewers` are `(campsite_user_id, github_login)` pairs. + /// Mono may pass `(id, None)` when the frontend only has campsite ids. + pub async fn add_reviewers( + &self, + link: &str, + reviewers: Vec<(String, Option)>, + ) -> Result<(), MegaError> { self.ctx .storage() .reviewer_storage() @@ -33,7 +39,12 @@ impl ReviewerApplicationService { .await? .into_iter() .map(|r| ReviewerInfo { - username: r.username, + campsite_user_id: r.campsite_user_id.clone(), + github_login: r.github_login.clone(), + username: r + .github_login + .clone() + .unwrap_or_else(|| r.campsite_user_id.clone()), approved: r.approved, system_required: r.system_required, }) @@ -44,21 +55,21 @@ impl ReviewerApplicationService { pub async fn reviewer_change_state( &self, link: &str, - username: &str, + campsite_user_id: &str, approved: bool, ) -> Result<(), MegaError> { self.ctx .storage() .reviewer_storage() - .reviewer_change_state(link, username, approved) + .reviewer_change_state(link, campsite_user_id, approved) .await } - pub async fn is_reviewer(&self, link: &str, username: &str) -> Result { + pub async fn is_reviewer(&self, link: &str, campsite_user_id: &str) -> Result { self.ctx .storage() .reviewer_storage() - .is_reviewer(link, username) + .is_reviewer(link, campsite_user_id) .await } } diff --git a/ceres/src/application/api_service/mono/user.rs b/ceres/src/application/api_service/mono/user.rs index 450b0d454..dcc928f86 100644 --- a/ceres/src/application/api_service/mono/user.rs +++ b/ceres/src/application/api_service/mono/user.rs @@ -42,11 +42,15 @@ impl UserApplicationService { Ok(keys.into_iter().map(|k| k.into()).collect()) } - pub async fn generate_user_token(&self, username: String) -> Result { + pub async fn generate_user_token( + &self, + campsite_user_id: String, + github_login: Option, + ) -> Result { self.ctx .storage() .user_storage() - .generate_token(username) + .generate_token(campsite_user_id, github_login) .await } diff --git a/ceres/src/application/api_service/mono/user_approval.rs b/ceres/src/application/api_service/mono/user_approval.rs index d00372514..df309534b 100644 --- a/ceres/src/application/api_service/mono/user_approval.rs +++ b/ceres/src/application/api_service/mono/user_approval.rs @@ -12,7 +12,6 @@ use crate::model::user::UserApprovalStatusRes; impl UserApplicationService { pub async fn get_or_init_user_approval_status( &self, - username: &str, campsite_user_id: &str, display_name: &str, email: &str, @@ -20,14 +19,11 @@ impl UserApplicationService { self.ctx .storage() .user_approval_storage() - .get_or_create( - username, - UserApprovalProfile { - campsite_user_id: campsite_user_id.to_string(), - display_name: display_name.to_string(), - email: email.to_string(), - }, - ) + .get_or_create(UserApprovalProfile { + campsite_user_id: campsite_user_id.to_string(), + display_name: display_name.to_string(), + email: email.to_string(), + }) .await } @@ -45,25 +41,25 @@ impl UserApplicationService { pub async fn approve_user( &self, - username: &str, + campsite_user_id: &str, reviewed_by: &str, ) -> Result { self.ctx .storage() .user_approval_storage() - .set_status(username, APPROVAL_STATUS_APPROVED, reviewed_by) + .set_status(campsite_user_id, APPROVAL_STATUS_APPROVED, reviewed_by) .await } pub async fn reject_user( &self, - username: &str, + campsite_user_id: &str, reviewed_by: &str, ) -> Result { self.ctx .storage() .user_approval_storage() - .set_status(username, APPROVAL_STATUS_REJECTED, reviewed_by) + .set_status(campsite_user_id, APPROVAL_STATUS_REJECTED, reviewed_by) .await } } @@ -71,7 +67,8 @@ impl UserApplicationService { impl From for UserApprovalStatusRes { fn from(value: user_approval_status::Model) -> Self { Self { - username: value.username, + // API compat: `username` mirrors campsite_user_id after username column drop. + username: value.campsite_user_id.clone(), campsite_user_id: value.campsite_user_id, display_name: value.display_name, email: value.email, diff --git a/ceres/src/application/build_trigger/model.rs b/ceres/src/application/build_trigger/model.rs index 4e982ebb1..202768ab6 100644 --- a/ceres/src/application/build_trigger/model.rs +++ b/ceres/src/application/build_trigger/model.rs @@ -456,7 +456,7 @@ impl From for TriggerContext { TriggerContext { trigger_type: BuildTriggerType::WebEdit, trigger_source: TriggerSource::User, - triggered_by: Some(cl.username), + triggered_by: Some(cl.campsite_user_id), repo_path: cl.path.clone(), cl_path: Some(cl.path), from_hash: cl.from_hash, diff --git a/ceres/src/application/code_edit/model.rs b/ceres/src/application/code_edit/model.rs index db3ad65c8..6cc1b4258 100644 --- a/ceres/src/application/code_edit/model.rs +++ b/ceres/src/application/code_edit/model.rs @@ -120,7 +120,12 @@ pub(crate) trait Director { let reviewer_service = self.get_review_service(storage).await?; if let Err(e) = reviewer_service - .assign_system_reviewers(&cl.link, &policy_contents, &changed_files) + .assign_system_reviewers( + &cl.link, + &policy_contents, + &changed_files, + &std::collections::HashMap::new(), + ) .await { tracing::warn!("Failed to assign Cedar reviewers: {}", e); @@ -128,7 +133,12 @@ pub(crate) trait Director { // Resync reviewers when existing CL updates policy files if let Err(e) = reviewer_service - .sync_system_reviewers(&cl.link, &policy_contents, &changed_files) + .sync_system_reviewers( + &cl.link, + &policy_contents, + &changed_files, + &std::collections::HashMap::new(), + ) .await { tracing::warn!("Failed to resync Cedar reviewers: {}", e); @@ -463,7 +473,7 @@ mod tests { to_hash: to_hash.to_string(), created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), - username: "tester".to_string(), + campsite_user_id: "tester".to_string(), base_branch: "main".to_string(), } } diff --git a/ceres/src/application/code_edit/on_edit.rs b/ceres/src/application/code_edit/on_edit.rs index 47bac6cb3..4fbd1e008 100644 --- a/ceres/src/application/code_edit/on_edit.rs +++ b/ceres/src/application/code_edit/on_edit.rs @@ -224,7 +224,7 @@ mod tests { to_hash: "2".repeat(40), created_at: now, updated_at: now, - username: "tester".to_string(), + campsite_user_id: "tester".to_string(), base_branch: "main".to_string(), } } diff --git a/ceres/src/application/code_edit/on_push.rs b/ceres/src/application/code_edit/on_push.rs index ee59b54b5..e8bc9b5c0 100644 --- a/ceres/src/application/code_edit/on_push.rs +++ b/ceres/src/application/code_edit/on_push.rs @@ -178,7 +178,7 @@ mod tests { to_hash: "2".repeat(40), created_at: now, updated_at: now, - username: "tester".to_string(), + campsite_user_id: "tester".to_string(), base_branch: "main".to_string(), } } diff --git a/ceres/src/application/notification/triggers.rs b/ceres/src/application/notification/triggers.rs index 6c71f68d4..a7cc631ca 100644 --- a/ceres/src/application/notification/triggers.rs +++ b/ceres/src/application/notification/triggers.rs @@ -66,9 +66,9 @@ pub async fn on_cl_comment_created( let reviewers = reviewer_stg.list_reviewers(cl_link).await?; let mut recipients: HashSet = HashSet::new(); - recipients.insert(cl.username); + recipients.insert(cl.campsite_user_id); for r in reviewers { - recipients.insert(r.username); + recipients.insert(r.campsite_user_id); } recipients.remove(actor_username); @@ -145,7 +145,7 @@ mod tests { to_hash: Set("b".to_string()), created_at: Set(now), updated_at: Set(now), - username: Set("alice".to_string()), + campsite_user_id: Set("alice".to_string()), base_branch: Set("main".to_string()), } .insert(&db) @@ -155,7 +155,8 @@ mod tests { mega_cl_reviewer::ActiveModel { id: Set(1), cl_link: Set("CL1".to_string()), - username: Set("bob".to_string()), + campsite_user_id: Set("bob".to_string()), + github_login: Set(Some("bob".to_string())), approved: Set(false), system_required: Set(false), created_at: Set(now), @@ -186,14 +187,14 @@ mod tests { assert_eq!(jobs.len(), 2); let alice_job = email_jobs::Entity::find() - .filter(email_jobs::Column::Username.eq("alice")) + .filter(email_jobs::Column::CampsiteUserId.eq("alice")) .one(&db) .await .unwrap(); assert!(alice_job.is_some()); let bob_job = email_jobs::Entity::find() - .filter(email_jobs::Column::Username.eq("bob")) + .filter(email_jobs::Column::CampsiteUserId.eq("bob")) .one(&db) .await .unwrap(); @@ -223,7 +224,7 @@ mod tests { to_hash: Set("b".to_string()), created_at: Set(now), updated_at: Set(now), - username: Set("alice".to_string()), + campsite_user_id: Set("alice".to_string()), base_branch: Set("main".to_string()), } .insert(&db) diff --git a/ceres/src/merge_checker/code_review_checker.rs b/ceres/src/merge_checker/code_review_checker.rs index 90cc626ce..82cc20545 100644 --- a/ceres/src/merge_checker/code_review_checker.rs +++ b/ceres/src/merge_checker/code_review_checker.rs @@ -37,7 +37,7 @@ impl Checker for CodeReviewChecker { match approved { Ok(_) => { res.status = crate::merge_checker::ConditionResult::PASSED; - res.message = String::from("All reviewers have approved the CL."); + res.message = String::from("At least one reviewer has approved the CL."); } Err(e) => { @@ -64,17 +64,14 @@ impl CodeReviewChecker { .list_reviewers(cl_link) .await?; - let mut err_message = String::new(); - for reviewer in reviewers { - if !reviewer.approved { - let msg = format!("Reviewer {} has not approved the CL.\n", reviewer.id); - err_message = err_message + &msg; - } + // No assigned reviewers → nothing to gate on. + // Otherwise any single approval is enough to pass. + if reviewers.is_empty() || reviewers.iter().any(|reviewer| reviewer.approved) { + return Ok(()); } - if !err_message.is_empty() { - return Err(MegaError::Other(err_message)); - } - Ok(()) + Err(MegaError::Other( + "No reviewer has approved the CL.".to_string(), + )) } } diff --git a/ceres/src/merge_checker/mod.rs b/ceres/src/merge_checker/mod.rs index 9d5c9d348..6fb47b5a7 100644 --- a/ceres/src/merge_checker/mod.rs +++ b/ceres/src/merge_checker/mod.rs @@ -99,9 +99,7 @@ impl CheckType { CheckType::CiStatus => { "Verify that all required continuous integration pipelines have passed" } - CheckType::CodeReview => { - "Ensure the required reviewers have approved the merge request" - } + CheckType::CodeReview => "Ensure at least one reviewer has approved the merge request", CheckType::ClaSign => { "Report whether the CL author has signed CLA (advisory; does not block merge)" } diff --git a/ceres/src/model/change_list.rs b/ceres/src/model/change_list.rs index fdbd0d2e7..ca8ed9fc5 100644 --- a/ceres/src/model/change_list.rs +++ b/ceres/src/model/change_list.rs @@ -69,7 +69,7 @@ impl From for CLDetailRes { link: value.cl.link, title: value.cl.title, status: value.cl.status.into(), - author: value.cl.username.clone(), + author: value.cl.campsite_user_id.clone(), author_is_bot: false, open_timestamp: value.cl.created_at.and_utc().timestamp(), merge_timestamp: value.cl.merge_date.map(|dt| dt.and_utc().timestamp()), @@ -82,7 +82,7 @@ impl From for CLDetailRes { assignees: value .assignees .into_iter() - .map(|x| x.assignnee_id) + .map(|x| x.campsite_user_id) .collect(), path: value.cl.path, } @@ -349,6 +349,9 @@ pub struct SetSystemReviewersPayload { #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct ReviewerInfo { + pub campsite_user_id: String, + pub github_login: Option, + /// Display name: github_login if present, else campsite_user_id (API compat). pub username: String, pub approved: bool, pub system_required: bool, diff --git a/ceres/src/model/conversation.rs b/ceres/src/model/conversation.rs index f767d9298..b62f60ca5 100644 --- a/ceres/src/model/conversation.rs +++ b/ceres/src/model/conversation.rs @@ -27,7 +27,7 @@ impl ConversationItem { ) -> Self { let mut item = Self { id: conversation.id, - username: conversation.username, + username: conversation.campsite_user_id, is_bot: false, conv_type: conversation.conv_type.into(), comment: conversation.comment, @@ -62,10 +62,10 @@ impl ConversationItem { custom_content: String::new(), }); entry.reactions_count += 1; - if r.username == username { + if r.campsite_user_id == username { entry.viewer_reaction_id = r.public_id.clone(); } - entry.tooltip.push(r.username.clone()); + entry.tooltip.push(r.campsite_user_id.clone()); } } diff --git a/ceres/src/model/group.rs b/ceres/src/model/group.rs index 881418787..82a4911e3 100644 --- a/ceres/src/model/group.rs +++ b/ceres/src/model/group.rs @@ -31,6 +31,7 @@ pub struct GroupResponse { #[derive(Debug, Deserialize, ToSchema)] pub struct AddMembersRequest { + /// Campsite public user ids (field name kept for API compat). pub usernames: Vec, } @@ -38,6 +39,7 @@ pub struct AddMembersRequest { pub struct GroupMemberResponse { pub id: i64, pub group_id: i64, + /// Campsite public user id (field name kept for API compat). pub username: String, pub joined_at: i64, } @@ -160,7 +162,7 @@ impl From for GroupMemberResponse { Self { id: value.id, group_id: value.group_id, - username: value.username, + username: value.campsite_user_id, joined_at: value.joined_at.and_utc().timestamp(), } } diff --git a/ceres/src/model/issue.rs b/ceres/src/model/issue.rs index 55b3ade2c..d42dcb454 100644 --- a/ceres/src/model/issue.rs +++ b/ceres/src/model/issue.rs @@ -42,7 +42,7 @@ impl From for ItemRes { link: model.link, title: model.title, status: model.status.to_string(), - author: model.author, + author: model.campsite_user_id, author_is_bot: false, open_timestamp: model.created_at.and_utc().timestamp(), merge_timestamp: None, @@ -58,7 +58,7 @@ impl From for ItemRes { link: model.link, title: model.title, status: format!("{:?}", model.status), - author: model.username, + author: model.campsite_user_id, author_is_bot: false, open_timestamp: model.created_at.and_utc().timestamp(), merge_timestamp: model.merge_date.map(|dt| dt.and_utc().timestamp()), @@ -118,7 +118,7 @@ impl From for IssueDetailRes { link: value.issue.link, title: value.issue.title, status: value.issue.status.to_string(), - author: value.issue.author.clone(), + author: value.issue.campsite_user_id.clone(), author_is_bot: false, open_timestamp: value.issue.created_at.and_utc().timestamp(), conversations: value @@ -130,7 +130,7 @@ impl From for IssueDetailRes { assignees: value .assignees .into_iter() - .map(|x| x.assignnee_id) + .map(|x| x.campsite_user_id) .collect(), } } diff --git a/common/README.md b/common/README.md index eb2a1fea3..42680344a 100644 --- a/common/README.md +++ b/common/README.md @@ -5,6 +5,7 @@ Shared workspace crate: configuration loading, error types, and utilities used b ## Configuration - Default template: [`config/config.toml`](../config/config.toml) +- Local override: [`config/config.local.toml`](../config/config.local.toml) (preferred over `config.toml` when present; gitignored) - Loader: `common::config::loader::ConfigLoader` - Env overrides: `MEGA_*` with `__` for nested keys (e.g. `MEGA_LOG__LEVEL`) - String substitution: `${base_dir}`, `${section.key}` in TOML values diff --git a/common/src/config/loader.rs b/common/src/config/loader.rs index 34b65b001..1ef40ac34 100644 --- a/common/src/config/loader.rs +++ b/common/src/config/loader.rs @@ -15,6 +15,9 @@ use crate::{ pub enum ConfigSource { Cli, Env, + /// `config/config.local.toml` under the current working directory + CwdLocal, + /// `config/config.toml` under the current working directory Cwd, Global, DefaultGenerated, @@ -59,11 +62,8 @@ impl ConfigLoader { }); } - if let Some(path) = Self::cwd_config_path()? { - return Ok(LoadedConfig { - path, - source: ConfigSource::Cwd, - }); + if let Some(loaded) = Self::cwd_config()? { + return Ok(loaded); } if let Some(path) = Self::global_config_path()? { @@ -80,10 +80,23 @@ impl ConfigLoader { }) } - fn cwd_config_path() -> Result> { + /// Prefer `config/config.local.toml`, then fall back to `config/config.toml`. + fn cwd_config() -> Result> { let cwd = env::current_dir().context("failed to get current dir")?; + + let local = cwd.join("config/config.local.toml"); + if local.exists() { + return Ok(Some(LoadedConfig { + path: local, + source: ConfigSource::CwdLocal, + })); + } + let path = cwd.join("config/config.toml"); - Ok(path.exists().then_some(path)) + Ok(path.exists().then_some(LoadedConfig { + path, + source: ConfigSource::Cwd, + })) } fn global_config_path() -> Result> { diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index 68985c341..13e114906 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -662,6 +662,10 @@ pub struct OauthConfig { pub allowed_cors_origins: Vec, #[serde(default = "default_campsite_api_session_cookie")] pub campsite_api_session_cookie: String, + /// Shared secret for Campsite `internal/member_identities` (mono startup backfill). + /// Empty = skip automatic identity backfill. + #[serde(default)] + pub mega_internal_secret: String, } pub const DEFAULT_CAMPSITE_API_SESSION_COOKIE: &str = "_campsite_api_session"; @@ -694,6 +698,7 @@ impl Default for OauthConfig { .map(|s| s.to_string()) .collect(), campsite_api_session_cookie: default_campsite_api_session_cookie(), + mega_internal_secret: String::new(), } } } diff --git a/config/config.local.toml.example b/config/config.local.toml.example new file mode 100644 index 000000000..996261d00 --- /dev/null +++ b/config/config.local.toml.example @@ -0,0 +1,8 @@ +# Local mono config override. +# +# Copy this file (or config/config.toml) to config/config.local.toml. +# When present, ConfigLoader prefers config.local.toml over config.toml. +# config.local.toml is gitignored — keep personal paths/secrets there. +# +# Example: +# cp config/config.toml config/config.local.toml diff --git a/config/config.toml b/config/config.toml index ccc016e20..6421bff9e 100644 --- a/config/config.toml +++ b/config/config.toml @@ -54,7 +54,7 @@ import_dir = "/third-party" # Set System Admin(s) in directory init, these users will be added to the admin group # Supports multiple admins: admin = ["user1", "user2", "user3"] -admin = ["benjamin_747"] +admin = ["benjamin-747"] # Set serveral root dirs in directory init root_dirs = ["third-party", "project", "doc", "release", "model", "toolchains"] @@ -170,12 +170,16 @@ tinyship_api_domain = "https://libra.tools" # Which auth API to use when loading users (campsite or tinyship) api_store_backend = "campsite" +# Shared secret for Campsite GET /v1/organizations/mega/internal/member_identities +# (mono startup actor identity backfill). Empty skips the backfill. +mega_internal_secret = "" # allowed cors origins allowed_cors_origins = [ "http://local.gitmega.com", "https://app.gitmega.com", "http://app.gitmono.test", + "http://app.gitmono.local" ] [blame] diff --git a/docs/development.md b/docs/development.md index f0d4bdc91..af99775d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,7 +20,8 @@ cargo build -p mono Default config: [config/config.toml](../config/config.toml). -- **Path:** `./config.toml` in the working directory, or `--config /path/to/config.toml` +- **Path discovery (cwd):** `config/config.local.toml` if present, otherwise `config/config.toml`. Copy the default file to `config.local.toml` for personal overrides (gitignored). +- **Explicit path:** `--config /path/to/config.toml` or `MEGA_CONFIG` - **Environment:** `MEGA_*` overrides nested keys with `__` (e.g. `MEGA_LOG__LEVEL` → `log.level`) - **Substitution:** `${base_dir}` and `${key.subkey}` in string values (see `common/src/config.rs`) diff --git a/jupiter-migrate/README.md b/jupiter-migrate/README.md index e4367d83b..1d74eaed0 100644 --- a/jupiter-migrate/README.md +++ b/jupiter-migrate/README.md @@ -6,6 +6,8 @@ SeaORM database migrations for Mega, extracted from `jupiter` so day-to-day `car `mono` enables `jupiter/migrate`. On startup, `Storage::new` calls `jupiter_migrate::apply_migrations` automatically (`jupiter/src/storage/init.rs`). No separate `init` CLI step is required. +After schema migrations, mono HTTP boot may run **application data backfills** (e.g. actor handle → `campsite_user_id`) using Campsite `internal/member_identities` and `data_backfill_ledger`. Deploy campsite-api with `MEGA_INTERNAL_SECRET` before mono with `MEGA_OAUTH__MEGA_INTERNAL_SECRET`. + Crates that need a migrated DB in tests should enable `jupiter/migrate` or `ceres` feature `migrate`. ## Generate a new migration diff --git a/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs b/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs new file mode 100644 index 000000000..dcfcde846 --- /dev/null +++ b/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs @@ -0,0 +1,268 @@ +//! Rename/replace actor string columns with `campsite_user_id`, and add +//! `access_token.github_login` / `mega_cl_reviewer.github_login` for collaboration identity. +//! +//! Data backfill (handle → campsite_user_id) runs automatically on mono startup +//! via `data_backfill_ledger` + Campsite `internal/member_identities` +//! (`oauth.mega_internal_secret` / `MEGA_INTERNAL_SECRET`). + +use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + match manager.get_database_backend() { + DatabaseBackend::Postgres => up_postgres(manager).await, + DatabaseBackend::Sqlite => up_sqlite(manager).await, + other => Err(DbErr::Custom(format!( + "unsupported database backend for actor→campsite_user_id migration: {other:?}" + ))), + } + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // Irreversible without restoring dropped username data. + Ok(()) + } +} + +async fn up_postgres(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // --- access_token: add github_login (before username → campsite_user_id rename) --- + db.execute_unprepared( + r#"ALTER TABLE access_token ADD COLUMN IF NOT EXISTS github_login VARCHAR"#, + ) + .await?; + + // --- mega_cl: username → campsite_user_id --- + db.execute_unprepared(r#"ALTER TABLE mega_cl RENAME COLUMN username TO campsite_user_id"#) + .await?; + + // --- mega_issue: author → campsite_user_id --- + db.execute_unprepared(r#"ALTER TABLE mega_issue RENAME COLUMN author TO campsite_user_id"#) + .await?; + + // --- mega_conversation: username → campsite_user_id --- + db.execute_unprepared( + r#"ALTER TABLE mega_conversation RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- mega_cl_reviewer: username → campsite_user_id + github_login --- + db.execute_unprepared( + r#"ALTER TABLE mega_cl_reviewer ADD COLUMN IF NOT EXISTS github_login VARCHAR"#, + ) + .await?; + // Preserve prior handle as github_login for display/Cedar until backfill maps id + db.execute_unprepared( + r#"UPDATE mega_cl_reviewer SET github_login = username WHERE github_login IS NULL"#, + ) + .await?; + db.execute_unprepared( + r#"ALTER TABLE mega_cl_reviewer RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- item_assignees: assignnee_id → campsite_user_id --- + db.execute_unprepared( + r#"ALTER TABLE item_assignees RENAME COLUMN assignnee_id TO campsite_user_id"#, + ) + .await?; + + // --- reactions --- + db.execute_unprepared(r#"ALTER TABLE reactions RENAME COLUMN username TO campsite_user_id"#) + .await?; + + // --- mega_code_review_comment --- + db.execute_unprepared( + r#"ALTER TABLE mega_code_review_comment RENAME COLUMN user_name TO campsite_user_id"#, + ) + .await?; + + // --- access_token --- + db.execute_unprepared( + r#"ALTER TABLE access_token RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- ssh_keys --- + db.execute_unprepared(r#"ALTER TABLE ssh_keys RENAME COLUMN username TO campsite_user_id"#) + .await?; + + // --- cla_sign_status (PK was username) --- + db.execute_unprepared( + r#"ALTER TABLE cla_sign_status RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- user_notification_settings --- + db.execute_unprepared( + r#"ALTER TABLE user_notification_settings RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- user_notification_preferences --- + db.execute_unprepared( + r#"ALTER TABLE user_notification_preferences RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- email_jobs --- + db.execute_unprepared(r#"ALTER TABLE email_jobs RENAME COLUMN username TO campsite_user_id"#) + .await?; + + // --- mega_group_member --- + db.execute_unprepared( + r#"ALTER TABLE mega_group_member RENAME COLUMN username TO campsite_user_id"#, + ) + .await?; + + // --- user_approval_status: drop username PK, use campsite_user_id as PK --- + // reviewed_by stays as opaque actor id string (will hold campsite_user_id going forward) + db.execute_unprepared( + r#" + ALTER TABLE user_approval_status DROP CONSTRAINT IF EXISTS user_approval_status_pkey; + ALTER TABLE user_approval_status DROP COLUMN IF EXISTS username; + ALTER TABLE user_approval_status ADD PRIMARY KEY (campsite_user_id); + "#, + ) + .await?; + + Ok(()) +} + +async fn up_sqlite(manager: &SchemaManager<'_>) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // SQLite does not support `ADD COLUMN IF NOT EXISTS`; sea-orm handles it. + manager + .alter_table( + Table::alter() + .table(Alias::new("access_token")) + .add_column_if_not_exists(ColumnDef::new(Alias::new("github_login")).string().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("mega_cl_reviewer")) + .add_column_if_not_exists(ColumnDef::new(Alias::new("github_login")).string().null()) + .to_owned(), + ) + .await?; + + if sqlite_has_column(db, "mega_cl_reviewer", "username").await? { + db.execute_unprepared( + r#"UPDATE mega_cl_reviewer SET github_login = username WHERE github_login IS NULL"#, + ) + .await?; + } + + // Plain RENAME COLUMN is supported on modern SQLite. + // Note: m20250812 skipped user_id→username for access_token/ssh_keys on SQLite. + for (table, from, to) in [ + ("mega_cl", "username", "campsite_user_id"), + ("mega_issue", "author", "campsite_user_id"), + ("mega_conversation", "username", "campsite_user_id"), + ("mega_cl_reviewer", "username", "campsite_user_id"), + ("item_assignees", "assignnee_id", "campsite_user_id"), + ("reactions", "username", "campsite_user_id"), + ("mega_code_review_comment", "user_name", "campsite_user_id"), + ("access_token", "username", "campsite_user_id"), + ("access_token", "user_id", "campsite_user_id"), + ("ssh_keys", "username", "campsite_user_id"), + ("ssh_keys", "user_id", "campsite_user_id"), + ("cla_sign_status", "username", "campsite_user_id"), + ("user_notification_settings", "username", "campsite_user_id"), + ( + "user_notification_preferences", + "username", + "campsite_user_id", + ), + ("email_jobs", "username", "campsite_user_id"), + ("mega_group_member", "username", "campsite_user_id"), + ] { + rename_sqlite_column_if_needed(db, table, from, to).await?; + } + + // SQLite cannot drop PK / change PK in place — rebuild the table. + db.execute_raw(Statement::from_string( + DatabaseBackend::Sqlite, + r#" + CREATE TABLE user_approval_status_new ( + campsite_user_id TEXT NOT NULL PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by TEXT NULL, + reviewed_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO user_approval_status_new ( + campsite_user_id, display_name, email, status, + reviewed_by, reviewed_at, created_at, updated_at + ) + SELECT + campsite_user_id, display_name, email, status, + reviewed_by, reviewed_at, created_at, updated_at + FROM user_approval_status; + DROP TABLE user_approval_status; + ALTER TABLE user_approval_status_new RENAME TO user_approval_status; + CREATE INDEX IF NOT EXISTS idx_user_approval_status_status_created_at + ON user_approval_status (status, created_at); + "# + .to_owned(), + )) + .await?; + + Ok(()) +} + +async fn sqlite_has_column( + db: &SchemaManagerConnection<'_>, + table: &str, + column: &str, +) -> Result { + let rows = db + .query_all_raw(Statement::from_string( + DatabaseBackend::Sqlite, + format!(r#"PRAGMA table_info("{table}")"#), + )) + .await?; + + for row in rows { + // PRAGMA table_info: cid, name, type, notnull, dflt_value, pk + if let Ok(name) = row.try_get_by_index::(1) + && name == column + { + return Ok(true); + } + } + Ok(false) +} + +async fn rename_sqlite_column_if_needed( + db: &SchemaManagerConnection<'_>, + table: &str, + from: &str, + to: &str, +) -> Result<(), DbErr> { + if !sqlite_has_column(db, table, from).await? { + return Ok(()); + } + if sqlite_has_column(db, table, to).await? { + return Ok(()); + } + db.execute_unprepared(&format!( + r#"ALTER TABLE "{table}" RENAME COLUMN "{from}" TO "{to}""# + )) + .await?; + Ok(()) +} diff --git a/jupiter-migrate/src/migration/m20260804_130000_data_backfill_ledger.rs b/jupiter-migrate/src/migration/m20260804_130000_data_backfill_ledger.rs new file mode 100644 index 000000000..ad9c63726 --- /dev/null +++ b/jupiter-migrate/src/migration/m20260804_130000_data_backfill_ledger.rs @@ -0,0 +1,69 @@ +//! Ledger for application-level data backfills (not schema migrations). +//! Used by mono startup identity backfill (`actor_campsite_user_id_v1`). + +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(DataBackfillLedger::Table) + .if_not_exists() + .col( + ColumnDef::new(DataBackfillLedger::Name) + .string() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(DataBackfillLedger::Status) + .string() + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(DataBackfillLedger::Error).text().null()) + .col( + timestamp(DataBackfillLedger::CreatedAt).default(Expr::current_timestamp()), + ) + .col( + timestamp(DataBackfillLedger::UpdatedAt).default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + // Seed the known backfill so claim UPDATE can target a row. + let db = manager.get_connection(); + db.execute_unprepared( + r#" + INSERT INTO data_backfill_ledger (name, status) + VALUES ('actor_campsite_user_id_v1', 'pending') + ON CONFLICT (name) DO NOTHING + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DataBackfillLedger::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum DataBackfillLedger { + Table, + Name, + Status, + Error, + CreatedAt, + UpdatedAt, +} diff --git a/jupiter-migrate/src/migration/mod.rs b/jupiter-migrate/src/migration/mod.rs index 0447cfbdd..3270b8dc8 100644 --- a/jupiter-migrate/src/migration/mod.rs +++ b/jupiter-migrate/src/migration/mod.rs @@ -101,6 +101,8 @@ mod m20260612_011232_drop_build_events_log; mod m20260714_021900_create_user_approval_status; 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 runner; pub use runner::apply_migrations; @@ -189,6 +191,8 @@ impl MigratorTrait for Migrator { Box::new(m20260714_021900_create_user_approval_status::Migration), Box::new(m20260720_060000_rename_webhook_event_type_underscores::Migration), 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), ] } } diff --git a/jupiter-migrate/src/migration/runner.rs b/jupiter-migrate/src/migration/runner.rs index 762b0125c..cbbbccd22 100644 --- a/jupiter-migrate/src/migration/runner.rs +++ b/jupiter-migrate/src/migration/runner.rs @@ -102,7 +102,7 @@ mod tests { .expect("insert event type"); user_notification_settings::ActiveModel { - username: Set("alice".to_owned()), + campsite_user_id: Set("alice".to_owned()), email: Set("alice@example.com".to_owned()), enabled: Set(true), delivery_mode: Set("realtime".to_owned()), @@ -114,7 +114,7 @@ mod tests { .expect("insert user settings"); user_notification_preferences::ActiveModel { - username: Set("alice".to_owned()), + campsite_user_id: Set("alice".to_owned()), event_type_code: Set("cl.comment.created".to_owned()), enabled: Set(false), created_at: Set(now), @@ -125,7 +125,7 @@ mod tests { .expect("insert user preference"); let res = user_notification_preferences::ActiveModel { - username: Set("alice".to_owned()), + campsite_user_id: Set("alice".to_owned()), event_type_code: Set("does.not.exist".to_owned()), enabled: Set(true), created_at: Set(now), @@ -137,7 +137,7 @@ mod tests { email_jobs::ActiveModel { id: Default::default(), - username: Set("alice".to_owned()), + campsite_user_id: Set("alice".to_owned()), to_email: Set("alice@example.com".to_owned()), event_type_code: Set("cl.comment.created".to_owned()), subject: Set("Test".to_owned()), @@ -157,7 +157,7 @@ mod tests { let res = email_jobs::ActiveModel { id: Default::default(), - username: Set("alice".to_owned()), + campsite_user_id: Set("alice".to_owned()), to_email: Set("alice@example.com".to_owned()), event_type_code: Set("does.not.exist".to_owned()), subject: Set("Test".to_owned()), diff --git a/jupiter/callisto/src/access_token.rs b/jupiter/callisto/src/access_token.rs index ae2741af5..b649d6bc5 100644 --- a/jupiter/callisto/src/access_token.rs +++ b/jupiter/callisto/src/access_token.rs @@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: i64, - pub username: String, + pub campsite_user_id: String, #[sea_orm(column_type = "Text")] pub token: String, pub created_at: DateTime, + pub github_login: Option, } impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/cla_sign_status.rs b/jupiter/callisto/src/cla_sign_status.rs index 2603d4354..8fedf34e6 100644 --- a/jupiter/callisto/src/cla_sign_status.rs +++ b/jupiter/callisto/src/cla_sign_status.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "cla_sign_status")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] - pub username: String, + pub campsite_user_id: String, pub cla_signed: bool, pub cla_signed_at: Option, pub created_at: DateTime, diff --git a/jupiter/callisto/src/data_backfill_ledger.rs b/jupiter/callisto/src/data_backfill_ledger.rs new file mode 100644 index 000000000..beafdcc13 --- /dev/null +++ b/jupiter/callisto/src/data_backfill_ledger.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 = "data_backfill_ledger")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub name: String, + pub status: String, + #[sea_orm(column_type = "Text", nullable)] + pub error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/email_jobs.rs b/jupiter/callisto/src/email_jobs.rs index 5dbeae486..238082926 100644 --- a/jupiter/callisto/src/email_jobs.rs +++ b/jupiter/callisto/src/email_jobs.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: i64, - pub username: String, + pub campsite_user_id: String, pub to_email: String, pub event_type_code: String, #[sea_orm(column_type = "Text")] diff --git a/jupiter/callisto/src/entity_ext/mega_cl.rs b/jupiter/callisto/src/entity_ext/mega_cl.rs index 95be1656b..2bca4c72c 100644 --- a/jupiter/callisto/src/entity_ext/mega_cl.rs +++ b/jupiter/callisto/src/entity_ext/mega_cl.rs @@ -1,106 +1,106 @@ -use sea_orm::entity::prelude::*; - -use crate::{ - entity_ext::generate_id, - mega_cl::{self, Entity}, - sea_orm_active_enums::MergeStatusEnum, -}; - -#[derive(Copy, Clone, Debug, EnumIter)] -pub enum Relation { - ItemLabels, - ItemAssignees, - Conversation, -} - -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - match self { - Self::ItemLabels => Entity::has_many(crate::item_labels::Entity).into(), - Self::ItemAssignees => Entity::has_many(crate::item_assignees::Entity).into(), - Self::Conversation => Entity::has_many(crate::mega_conversation::Entity).into(), - } - } -} - -impl Related for Entity { - fn to() -> RelationDef { - crate::entity_ext::item_labels::Relation::Label.def() - } - - fn via() -> Option { - Some(crate::entity_ext::item_labels::Relation::MegaCl.def().rev()) - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::ItemAssignees.def() - } - fn via() -> Option { - None - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Conversation.def() - } - fn via() -> Option { - None - } -} - -impl mega_cl::Model { - pub fn new( - path: String, - title: String, - link: String, - base_branch: String, - from_hash: String, - to_hash: String, - username: String, - ) -> Self { - let now = chrono::Utc::now().naive_utc(); - Self { - id: generate_id(), - link, - title: title.to_owned(), - status: MergeStatusEnum::Open, - created_at: now, - updated_at: now, - merge_date: None, - path, - base_branch, - from_hash, - to_hash, - username, - } - } - - /// Create a new CL with Draft status - pub fn new_draft( - path: String, - title: String, - link: String, - base_branch: String, - from_hash: String, - username: String, - ) -> Self { - let now = chrono::Utc::now().naive_utc(); - Self { - id: generate_id(), - link, - title: title.to_owned(), - status: MergeStatusEnum::Draft, - created_at: now, - updated_at: now, - merge_date: None, - path, - base_branch, - from_hash, - to_hash: String::new(), - username, - } - } -} +use sea_orm::entity::prelude::*; + +use crate::{ + entity_ext::generate_id, + mega_cl::{self, Entity}, + sea_orm_active_enums::MergeStatusEnum, +}; + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + ItemLabels, + ItemAssignees, + Conversation, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::ItemLabels => Entity::has_many(crate::item_labels::Entity).into(), + Self::ItemAssignees => Entity::has_many(crate::item_assignees::Entity).into(), + Self::Conversation => Entity::has_many(crate::mega_conversation::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + crate::entity_ext::item_labels::Relation::Label.def() + } + + fn via() -> Option { + Some(crate::entity_ext::item_labels::Relation::MegaCl.def().rev()) + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ItemAssignees.def() + } + fn via() -> Option { + None + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Conversation.def() + } + fn via() -> Option { + None + } +} + +impl mega_cl::Model { + pub fn new( + path: String, + title: String, + link: String, + base_branch: String, + from_hash: String, + to_hash: String, + campsite_user_id: String, + ) -> Self { + let now = chrono::Utc::now().naive_utc(); + Self { + id: generate_id(), + link, + title: title.to_owned(), + status: MergeStatusEnum::Open, + created_at: now, + updated_at: now, + merge_date: None, + path, + base_branch, + from_hash, + to_hash, + campsite_user_id, + } + } + + /// Create a new CL with Draft status + pub fn new_draft( + path: String, + title: String, + link: String, + base_branch: String, + from_hash: String, + campsite_user_id: String, + ) -> Self { + let now = chrono::Utc::now().naive_utc(); + Self { + id: generate_id(), + link, + title: title.to_owned(), + status: MergeStatusEnum::Draft, + created_at: now, + updated_at: now, + merge_date: None, + path, + base_branch, + from_hash, + to_hash: String::new(), + campsite_user_id, + } + } +} diff --git a/jupiter/callisto/src/entity_ext/mega_code_review_comment.rs b/jupiter/callisto/src/entity_ext/mega_code_review_comment.rs index 5c40056b7..bcf18228f 100644 --- a/jupiter/callisto/src/entity_ext/mega_code_review_comment.rs +++ b/jupiter/callisto/src/entity_ext/mega_code_review_comment.rs @@ -4,7 +4,7 @@ impl mega_code_review_comment::Model { pub fn new( thread_id: i64, parent_id: Option, - user_name: String, + campsite_user_id: String, content: Option, ) -> Self { let now = chrono::Utc::now().naive_utc(); @@ -13,7 +13,7 @@ impl mega_code_review_comment::Model { id: generate_id(), thread_id, parent_id, - user_name, + campsite_user_id, content, created_at: now, updated_at: now, diff --git a/jupiter/callisto/src/entity_ext/mega_conversation.rs b/jupiter/callisto/src/entity_ext/mega_conversation.rs index e6e7470bf..fa7eb9acb 100644 --- a/jupiter/callisto/src/entity_ext/mega_conversation.rs +++ b/jupiter/callisto/src/entity_ext/mega_conversation.rs @@ -55,7 +55,7 @@ impl mega_conversation::Model { link: &str, conv_type: ConvTypeEnum, comment: Option, - username: &str, + campsite_user_id: &str, ) -> Self { let now = chrono::Utc::now().naive_utc(); let resolved = if conv_type == ConvTypeEnum::Review { @@ -71,7 +71,7 @@ impl mega_conversation::Model { comment, created_at: now, updated_at: now, - username: username.to_owned(), + campsite_user_id: campsite_user_id.to_owned(), resolved, } } diff --git a/jupiter/callisto/src/entity_ext/mega_issue.rs b/jupiter/callisto/src/entity_ext/mega_issue.rs index d8339aeef..caa150d00 100644 --- a/jupiter/callisto/src/entity_ext/mega_issue.rs +++ b/jupiter/callisto/src/entity_ext/mega_issue.rs @@ -55,13 +55,13 @@ impl Related for Entity { } impl mega_issue::Model { - pub fn new(title: String, author: String) -> Self { + pub fn new(title: String, campsite_user_id: String) -> Self { let now = chrono::Utc::now().naive_utc(); Self { id: generate_id(), link: generate_link(), title, - author, + campsite_user_id, status: "open".to_owned(), created_at: now, updated_at: now, diff --git a/jupiter/callisto/src/entity_ext/reactions.rs b/jupiter/callisto/src/entity_ext/reactions.rs index 35a47bd9d..98bc3e330 100644 --- a/jupiter/callisto/src/entity_ext/reactions.rs +++ b/jupiter/callisto/src/entity_ext/reactions.rs @@ -32,7 +32,7 @@ impl reactions::Model { content: Option, subject_id: i64, subject_type: &str, - username: &str, + campsite_user_id: &str, ) -> Self { let now = chrono::Utc::now().naive_utc(); Self { @@ -44,7 +44,7 @@ impl reactions::Model { subject_id, subject_type: subject_type.to_owned(), organization_membership_id: None, - username: username.to_owned(), + campsite_user_id: campsite_user_id.to_owned(), discarded_at: None, } } diff --git a/jupiter/callisto/src/item_assignees.rs b/jupiter/callisto/src/item_assignees.rs index 26561bb47..2696a2bd3 100644 --- a/jupiter/callisto/src/item_assignees.rs +++ b/jupiter/callisto/src/item_assignees.rs @@ -12,7 +12,7 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub item_id: i64, #[sea_orm(primary_key, auto_increment = false)] - pub assignnee_id: String, + pub campsite_user_id: String, pub item_type: String, } diff --git a/jupiter/callisto/src/mega_cl.rs b/jupiter/callisto/src/mega_cl.rs index a506daf42..f761edc2f 100644 --- a/jupiter/callisto/src/mega_cl.rs +++ b/jupiter/callisto/src/mega_cl.rs @@ -23,7 +23,7 @@ pub struct Model { pub to_hash: String, pub created_at: DateTime, pub updated_at: DateTime, - pub username: String, + pub campsite_user_id: String, pub base_branch: String, } diff --git a/jupiter/callisto/src/mega_cl_reviewer.rs b/jupiter/callisto/src/mega_cl_reviewer.rs index 73e70dfe1..cea9d287c 100644 --- a/jupiter/callisto/src/mega_cl_reviewer.rs +++ b/jupiter/callisto/src/mega_cl_reviewer.rs @@ -10,11 +10,12 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: i64, pub approved: bool, - pub username: String, + pub campsite_user_id: String, pub cl_link: String, pub created_at: DateTime, pub updated_at: DateTime, pub system_required: bool, + pub github_login: Option, } impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/mega_code_review_comment.rs b/jupiter/callisto/src/mega_code_review_comment.rs index f8ca89077..e8a406920 100644 --- a/jupiter/callisto/src/mega_code_review_comment.rs +++ b/jupiter/callisto/src/mega_code_review_comment.rs @@ -11,7 +11,7 @@ pub struct Model { pub id: i64, pub thread_id: i64, pub parent_id: Option, - pub user_name: String, + pub campsite_user_id: String, #[sea_orm(column_type = "Text", nullable)] pub content: Option, pub created_at: DateTime, diff --git a/jupiter/callisto/src/mega_conversation.rs b/jupiter/callisto/src/mega_conversation.rs index a4f29e6c0..f33bff221 100644 --- a/jupiter/callisto/src/mega_conversation.rs +++ b/jupiter/callisto/src/mega_conversation.rs @@ -17,7 +17,7 @@ pub struct Model { pub comment: Option, pub created_at: DateTime, pub updated_at: DateTime, - pub username: String, + pub campsite_user_id: String, pub resolved: Option, } diff --git a/jupiter/callisto/src/mega_group_member.rs b/jupiter/callisto/src/mega_group_member.rs index 69003d8b8..e5db32283 100644 --- a/jupiter/callisto/src/mega_group_member.rs +++ b/jupiter/callisto/src/mega_group_member.rs @@ -12,7 +12,7 @@ pub struct Model { #[sea_orm(unique_key = "idx_group_member_unique")] pub group_id: i64, #[sea_orm(unique_key = "idx_group_member_unique")] - pub username: String, + pub campsite_user_id: String, pub joined_at: DateTime, #[sea_orm( belongs_to, diff --git a/jupiter/callisto/src/mega_issue.rs b/jupiter/callisto/src/mega_issue.rs index 15e1859f6..7fbc5ffac 100644 --- a/jupiter/callisto/src/mega_issue.rs +++ b/jupiter/callisto/src/mega_issue.rs @@ -16,7 +16,7 @@ pub struct Model { pub created_at: DateTime, pub updated_at: DateTime, pub closed_at: Option, - pub author: String, + pub campsite_user_id: String, } impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index 19adea8f4..b2067d1aa 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -19,6 +19,7 @@ pub mod build_triggers; pub mod check_result; pub mod cla_sign_status; pub mod commit_auths; +pub mod data_backfill_ledger; pub mod dynamic_sidebar; pub mod email_jobs; pub mod entity_ext; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 4050a30c7..f528522c8 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -9,13 +9,14 @@ pub use super::{ 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, - 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, + 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/callisto/src/reactions.rs b/jupiter/callisto/src/reactions.rs index 3b3ff17f9..2343125ac 100644 --- a/jupiter/callisto/src/reactions.rs +++ b/jupiter/callisto/src/reactions.rs @@ -16,7 +16,7 @@ pub struct Model { pub subject_id: i64, pub subject_type: String, pub organization_membership_id: Option, - pub username: String, + pub campsite_user_id: String, pub discarded_at: Option, } diff --git a/jupiter/callisto/src/ssh_keys.rs b/jupiter/callisto/src/ssh_keys.rs index 2c3cd196c..9a4b343d1 100644 --- a/jupiter/callisto/src/ssh_keys.rs +++ b/jupiter/callisto/src/ssh_keys.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: i64, - pub username: String, + pub campsite_user_id: String, #[sea_orm(column_type = "Text")] pub title: String, #[sea_orm(column_type = "Text")] diff --git a/jupiter/callisto/src/user_approval_status.rs b/jupiter/callisto/src/user_approval_status.rs index 7b5efdcc6..86816bd69 100644 --- a/jupiter/callisto/src/user_approval_status.rs +++ b/jupiter/callisto/src/user_approval_status.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "user_approval_status")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] - pub username: String, pub campsite_user_id: String, pub display_name: String, pub email: String, diff --git a/jupiter/callisto/src/user_notification_preferences.rs b/jupiter/callisto/src/user_notification_preferences.rs index 9bfbd83aa..a67d71d9a 100644 --- a/jupiter/callisto/src/user_notification_preferences.rs +++ b/jupiter/callisto/src/user_notification_preferences.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "user_notification_preferences")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] - pub username: String, + pub campsite_user_id: String, #[sea_orm(primary_key, auto_increment = false)] pub event_type_code: String, pub enabled: bool, @@ -24,8 +24,8 @@ pub struct Model { pub notification_event_types: BelongsTo, #[sea_orm( belongs_to, - from = "username", - to = "username", + from = "campsite_user_id", + to = "campsite_user_id", on_update = "Cascade", on_delete = "Cascade" )] diff --git a/jupiter/callisto/src/user_notification_settings.rs b/jupiter/callisto/src/user_notification_settings.rs index 2dfc7f5bf..273febf89 100644 --- a/jupiter/callisto/src/user_notification_settings.rs +++ b/jupiter/callisto/src/user_notification_settings.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[sea_orm(table_name = "user_notification_settings")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] - pub username: String, + pub campsite_user_id: String, pub email: String, pub enabled: bool, pub delivery_mode: String, diff --git a/jupiter/src/model/cl_dto.rs b/jupiter/src/model/cl_dto.rs index 36de9dd4c..402dc19fd 100644 --- a/jupiter/src/model/cl_dto.rs +++ b/jupiter/src/model/cl_dto.rs @@ -36,7 +36,7 @@ impl From for ClInfoDto { to_hash: value.to_hash, created_at: value.created_at, updated_at: value.updated_at, - username: value.username, + username: value.campsite_user_id, } } } diff --git a/jupiter/src/model/code_review_dto.rs b/jupiter/src/model/code_review_dto.rs index 0419cbb70..deede2591 100644 --- a/jupiter/src/model/code_review_dto.rs +++ b/jupiter/src/model/code_review_dto.rs @@ -86,7 +86,7 @@ impl From for CommentReviewView { fn from(value: mega_code_review_comment::Model) -> Self { Self { comment_id: value.id, - user_name: value.user_name, + user_name: value.campsite_user_id, content: value.content, parent_id: value.parent_id, created_at: value.created_at, diff --git a/jupiter/src/service/reviewer_service.rs b/jupiter/src/service/reviewer_service.rs index 5d054f3f1..e96017923 100644 --- a/jupiter/src/service/reviewer_service.rs +++ b/jupiter/src/service/reviewer_service.rs @@ -1,6 +1,9 @@ //! Service for managing system required reviewers based on Cedar policy files. -use std::{collections::HashSet, path::PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; use common::errors::MegaError; use saturn::reviewer_parser::aggregate_reviewers; @@ -29,6 +32,7 @@ fn to_policy_match_path(file_path: &str) -> String { } /// Aggregate reviewers from policy contents for all changed files. +/// Returns github logins from Cedar policies. fn collect_reviewers( policy_contents: &[(PathBuf, String)], changed_files: &[String], @@ -53,6 +57,31 @@ fn collect_reviewers( all_reviewers } +/// Resolve Cedar github logins to `(campsite_user_id, github_login)`. +/// +/// When `login_to_id` has an entry, use the mapped campsite id. +/// When empty / missing (pre-resolver / pre-backfill), fall back to storing the +/// github login in `campsite_user_id` — consistent with migrated rows that still +/// hold handles until `scripts/backfill-github-login-identity.sh` rewrites them. +fn resolve_reviewers( + github_logins: &[String], + login_to_id: &HashMap, +) -> Vec<(String, Option)> { + let mut out = Vec::new(); + for login in github_logins { + if let Some(id) = login_to_id.get(login) { + out.push((id.clone(), Some(login.clone()))); + } else { + tracing::warn!( + github_login = %login, + "No campsite_user_id mapping for system reviewer; using github_login as transitional campsite_user_id" + ); + out.push((login.clone(), Some(login.clone()))); + } + } + out +} + #[derive(Clone)] pub struct ReviewerService { pub reviewer_storage: ClReviewerStorage, @@ -72,20 +101,26 @@ impl ReviewerService { /// Assign system required reviewers based on Cedar policies. /// - /// Iterates through changed files and aggregates reviewers from matching policies. - /// Returns list of assigned reviewer usernames. + /// `login_to_id` maps github login → campsite_user_id. Pass an empty map to + /// use the transitional (login, Some(login)) fallback until member sync exists. + /// + /// Returns list of assigned campsite_user_id values (or transitional logins). pub async fn assign_system_reviewers( &self, cl_link: &str, policy_contents: &[(PathBuf, String)], changed_files: &[String], + login_to_id: &HashMap, ) -> Result, MegaError> { - let all_reviewers = collect_reviewers(policy_contents, changed_files); + let github_logins = collect_reviewers(policy_contents, changed_files); - if all_reviewers.is_empty() { + if github_logins.is_empty() { return Ok(vec![]); } + let resolved = resolve_reviewers(&github_logins, login_to_id); + let all_ids: Vec = resolved.iter().map(|(id, _)| id.clone()).collect(); + // Get existing reviewers to avoid duplicates let existing_reviewers: Vec = self .reviewer_storage @@ -93,14 +128,13 @@ impl ReviewerService { .await .unwrap_or_default() .into_iter() - .map(|r| r.username) + .map(|r| r.campsite_user_id) .collect(); // Filter out already existing reviewers - let new_reviewers: Vec = all_reviewers - .iter() - .filter(|r| !existing_reviewers.contains(r)) - .cloned() + let new_reviewers: Vec<(String, Option)> = resolved + .into_iter() + .filter(|(id, _)| !existing_reviewers.contains(id)) .collect(); if !new_reviewers.is_empty() { @@ -111,10 +145,10 @@ impl ReviewerService { // Mark all as system required self.reviewer_storage - .update_system_required_reviewers(cl_link, &all_reviewers, true) + .update_system_required_reviewers(cl_link, &all_ids, true) .await?; - Ok(all_reviewers) + Ok(all_ids) } /// Sync system required reviewers when policy files change. @@ -125,6 +159,7 @@ impl ReviewerService { cl_link: &str, policy_contents: &[(PathBuf, String)], changed_files: &[String], + login_to_id: &HashMap, ) -> Result<(), MegaError> { // 1. Get and remove all current system_required reviewers let current_system: Vec = self @@ -133,7 +168,7 @@ impl ReviewerService { .await? .into_iter() .filter(|r| r.system_required) - .map(|r| r.username) + .map(|r| r.campsite_user_id) .collect(); if !current_system.is_empty() { @@ -143,15 +178,17 @@ impl ReviewerService { } // 2. Aggregate reviewers from hierarchical policies for all changed files - let new_reviewers = collect_reviewers(policy_contents, changed_files); + let github_logins = collect_reviewers(policy_contents, changed_files); + let resolved = resolve_reviewers(&github_logins, login_to_id); + let new_ids: Vec = resolved.iter().map(|(id, _)| id.clone()).collect(); // 3. Add new system reviewers - if !new_reviewers.is_empty() { + if !resolved.is_empty() { self.reviewer_storage - .add_reviewers(cl_link, new_reviewers.clone()) + .add_reviewers(cl_link, resolved) .await?; self.reviewer_storage - .update_system_required_reviewers(cl_link, &new_reviewers, true) + .update_system_required_reviewers(cl_link, &new_ids, true) .await?; } @@ -180,6 +217,10 @@ mod tests { ) } + fn empty_map() -> HashMap { + HashMap::new() + } + // --- Tests --- /// Pattern Merge: Different patterns from root and child policies are merged. @@ -212,7 +253,7 @@ mod tests { ]; let assigned = service - .assign_system_reviewers(cl_link, &policies, &changed_files) + .assign_system_reviewers(cl_link, &policies, &changed_files, &empty_map()) .await .unwrap(); @@ -257,7 +298,7 @@ mod tests { let changed_files = vec!["servicea/core/logic.rs".to_string()]; let assigned = service - .assign_system_reviewers(cl_link, &policies, &changed_files) + .assign_system_reviewers(cl_link, &policies, &changed_files, &empty_map()) .await .unwrap(); @@ -307,7 +348,7 @@ mod tests { ]; let assigned = service - .assign_system_reviewers(cl_link, &policies, &changed_files) + .assign_system_reviewers(cl_link, &policies, &changed_files, &empty_map()) .await .unwrap(); @@ -339,7 +380,10 @@ mod tests { service .reviewer_storage - .add_reviewers(cl_link, vec!["manual_user".to_string()]) + .add_reviewers( + cl_link, + vec![("manual_user".to_string(), Some("manual_user".to_string()))], + ) .await .unwrap(); @@ -350,7 +394,7 @@ mod tests { let changed_files = vec!["servicea/core/logic.rs".to_string()]; service - .sync_system_reviewers(cl_link, &policies, &changed_files) + .sync_system_reviewers(cl_link, &policies, &changed_files, &empty_map()) .await .unwrap(); @@ -361,7 +405,9 @@ mod tests { .unwrap(); // Verify manual reviewer is preserved with system_required = false - let manual = reviewers.iter().find(|r| r.username == "manual_user"); + let manual = reviewers + .iter() + .find(|r| r.campsite_user_id == "manual_user"); assert!(manual.is_some(), "Manual reviewer should be preserved"); assert!( !manual.unwrap().system_required, @@ -369,7 +415,9 @@ mod tests { ); // Verify system reviewer is added with system_required = true - let system = reviewers.iter().find(|r| r.username == "system_user"); + let system = reviewers + .iter() + .find(|r| r.campsite_user_id == "system_user"); assert!(system.is_some(), "System reviewer should be added"); assert!( system.unwrap().system_required, diff --git a/jupiter/src/service/webhook_service.rs b/jupiter/src/service/webhook_service.rs index d4f1d6e88..ceee86105 100644 --- a/jupiter/src/service/webhook_service.rs +++ b/jupiter/src/service/webhook_service.rs @@ -72,7 +72,7 @@ impl From<&mega_cl::Model> for ClPayload { link: model.link.clone(), title: model.title.clone(), author: AuthorPayload { - name: model.username.clone(), + name: model.campsite_user_id.clone(), }, status: merge_status_to_str(&model.status).to_string(), path: model.path.clone(), @@ -629,7 +629,7 @@ mod tests { to_hash: "def".to_string(), created_at: DateTime::from_timestamp(0, 0).unwrap().naive_utc(), updated_at: DateTime::from_timestamp(0, 0).unwrap().naive_utc(), - username: "alice".to_string(), + campsite_user_id: "alice".to_string(), }; let payload = ClPayload::from(&model); diff --git a/jupiter/src/storage/cl_reviewer_storage.rs b/jupiter/src/storage/cl_reviewer_storage.rs index cac59af2d..645ccc969 100644 --- a/jupiter/src/storage/cl_reviewer_storage.rs +++ b/jupiter/src/storage/cl_reviewer_storage.rs @@ -19,13 +19,19 @@ impl Deref for ClReviewerStorage { } impl ClReviewerStorage { - pub fn new_reviewer(&self, cl_link: &str, username: &str) -> mega_cl_reviewer::Model { + pub fn new_reviewer( + &self, + cl_link: &str, + campsite_user_id: &str, + github_login: Option, + ) -> mega_cl_reviewer::Model { let now = chrono::Utc::now().naive_utc(); mega_cl_reviewer::Model { id: generate_id(), cl_link: cl_link.to_string(), approved: false, - username: username.to_string(), + campsite_user_id: campsite_user_id.to_string(), + github_login, created_at: now, updated_at: now, system_required: false, @@ -35,14 +41,14 @@ impl ClReviewerStorage { pub async fn add_reviewers( &self, cl_link: &str, - reviewers: Vec, + reviewers: Vec<(String, Option)>, ) -> Result<(), MegaError> { - for reviewer in reviewers { - let new_reviewer = self.new_reviewer(cl_link, &reviewer); + for (campsite_user_id, github_login) in reviewers { + let new_reviewer = self.new_reviewer(cl_link, &campsite_user_id, github_login); let a_model: mega_cl_reviewer::ActiveModel = new_reviewer.into_active_model(); a_model.insert(self.get_connection()).await.map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("reviewer {}", reviewer.clone())) + MegaError::Other(format!("reviewer {}", campsite_user_id.clone())) })?; } Ok(()) @@ -51,20 +57,22 @@ impl ClReviewerStorage { pub async fn update_system_required_reviewers( &self, cl_link: &str, - reviewers: &[String], + campsite_user_ids: &[String], system_required: bool, ) -> Result<(), MegaError> { - for reviewer in reviewers { + for campsite_user_id in campsite_user_ids { let mut rev: mega_cl_reviewer::ActiveModel = mega_cl_reviewer::Entity::find() .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) - .filter(mega_cl_reviewer::Column::Username.eq(reviewer)) + .filter(mega_cl_reviewer::Column::CampsiteUserId.eq(campsite_user_id)) .one(self.get_connection()) .await .map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("fail to find reviewer {}", reviewer)) + MegaError::Other(format!("fail to find reviewer {}", campsite_user_id)) + })? + .ok_or_else(|| { + MegaError::Other(format!("reviewer {} not found", campsite_user_id)) })? - .ok_or_else(|| MegaError::Other(format!("reviewer {} not found", reviewer)))? .into_active_model(); rev.system_required = Set(system_required); @@ -73,7 +81,7 @@ impl ClReviewerStorage { tracing::error!("{}", e); MegaError::Other(format!( "fail to update system required for reviewer {}", - reviewer + campsite_user_id )) })?; } @@ -83,20 +91,20 @@ impl ClReviewerStorage { pub async fn remove_reviewers( &self, cl_link: &str, - reviewers: &[String], + campsite_user_ids: &[String], ) -> Result<(), MegaError> { - if reviewers.is_empty() { + if campsite_user_ids.is_empty() { return Ok(()); } mega_cl_reviewer::Entity::delete_many() .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) - .filter(mega_cl_reviewer::Column::Username.is_in(reviewers.to_vec())) + .filter(mega_cl_reviewer::Column::CampsiteUserId.is_in(campsite_user_ids.to_vec())) .filter(mega_cl_reviewer::Column::SystemRequired.eq(false)) .exec(self.get_connection()) .await .map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("fail to remove reviewers: {:?}", reviewers)) + MegaError::Other(format!("fail to remove reviewers: {:?}", campsite_user_ids)) })?; Ok(()) } @@ -104,28 +112,35 @@ impl ClReviewerStorage { pub async fn remove_system_reviewers( &self, cl_link: &str, - reviewers: &[String], + campsite_user_ids: &[String], ) -> Result<(), MegaError> { - if reviewers.is_empty() { + if campsite_user_ids.is_empty() { return Ok(()); } mega_cl_reviewer::Entity::delete_many() .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) - .filter(mega_cl_reviewer::Column::Username.is_in(reviewers.to_vec())) + .filter(mega_cl_reviewer::Column::CampsiteUserId.is_in(campsite_user_ids.to_vec())) .filter(mega_cl_reviewer::Column::SystemRequired.eq(true)) .exec(self.get_connection()) .await .map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("fail to remove system reviewers: {:?}", reviewers)) + MegaError::Other(format!( + "fail to remove system reviewers: {:?}", + campsite_user_ids + )) })?; Ok(()) } - pub async fn is_reviewer(&self, cl_link: &str, username: &str) -> Result { + pub async fn is_reviewer( + &self, + cl_link: &str, + campsite_user_id: &str, + ) -> Result { let is_reviewer = mega_cl_reviewer::Entity::find() .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) - .filter(mega_cl_reviewer::Column::Username.eq(username)) + .filter(mega_cl_reviewer::Column::CampsiteUserId.eq(campsite_user_id)) .one(self.get_connection()) .await .map_err(|e| { @@ -137,6 +152,25 @@ impl ClReviewerStorage { Ok(is_reviewer) } + pub async fn find_by_github_login( + &self, + cl_link: &str, + github_login: &str, + ) -> Result, MegaError> { + mega_cl_reviewer::Entity::find() + .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) + .filter(mega_cl_reviewer::Column::GithubLogin.eq(github_login)) + .one(self.get_connection()) + .await + .map_err(|e| { + tracing::error!("{}", e); + MegaError::Other(format!( + "fail to find reviewer by github_login {}", + github_login + )) + }) + } + pub async fn list_reviewers( &self, cl_link: &str, @@ -155,26 +189,26 @@ impl ClReviewerStorage { pub async fn reviewer_change_state( &self, cl_link: &str, - reviewer_username: &str, + campsite_user_id: &str, approved: bool, ) -> Result<(), MegaError> { let mut rev: mega_cl_reviewer::ActiveModel = mega_cl_reviewer::Entity::find() .filter(mega_cl_reviewer::Column::ClLink.eq(cl_link)) - .filter(mega_cl_reviewer::Column::Username.eq(reviewer_username)) + .filter(mega_cl_reviewer::Column::CampsiteUserId.eq(campsite_user_id)) .one(self.get_connection()) .await .map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("fail to find reviewer {}", reviewer_username)) + MegaError::Other(format!("fail to find reviewer {}", campsite_user_id)) })? - .ok_or_else(|| MegaError::Other(format!("reviewer {} not found", reviewer_username)))? + .ok_or_else(|| MegaError::Other(format!("reviewer {} not found", campsite_user_id)))? .into_active_model(); rev.approved = Set(approved); rev.updated_at = Set(chrono::Utc::now().naive_utc()); rev.update(self.get_connection()).await.map_err(|e| { tracing::error!("{}", e); - MegaError::Other(format!("fail to update reviewer {}", reviewer_username)) + MegaError::Other(format!("fail to update reviewer {}", campsite_user_id)) })?; Ok(()) diff --git a/jupiter/src/storage/cl_storage.rs b/jupiter/src/storage/cl_storage.rs index 8769c8410..3bb12d392 100644 --- a/jupiter/src/storage/cl_storage.rs +++ b/jupiter/src/storage/cl_storage.rs @@ -44,7 +44,7 @@ impl ClStorage { ) -> Result, MegaError> { let model = mega_cl::Entity::find() .filter(mega_cl::Column::Path.eq(path)) - .filter(mega_cl::Column::Username.eq(username)) + .filter(mega_cl::Column::CampsiteUserId.eq(username)) .filter(mega_cl::Column::Status.eq(MergeStatusEnum::Open)) .one(self.get_connection()) .await @@ -105,7 +105,7 @@ impl ClStorage { ) .filter(mega_cl::Column::Status.is_in(status)) .apply_if(params.author, |q, author| { - q.filter(mega_cl::Column::Username.eq(author)) + q.filter(mega_cl::Column::CampsiteUserId.eq(author)) }) .filter(cond) .distinct(); @@ -217,7 +217,7 @@ impl ClStorage { let assignee = mega_cl::Entity::find() .filter(mega_cl::Column::Link.eq(link)) .find_with_related(item_assignees::Entity) - .filter(item_assignees::Column::AssignneeId.eq(username)) + .filter(item_assignees::Column::CampsiteUserId.eq(username)) .all(self.get_connection()) .await?; if assignee.is_empty() { diff --git a/jupiter/src/storage/cla_storage.rs b/jupiter/src/storage/cla_storage.rs index 395cb72fd..b40ef18aa 100644 --- a/jupiter/src/storage/cla_storage.rs +++ b/jupiter/src/storage/cla_storage.rs @@ -45,7 +45,7 @@ impl ClaStorage { ) -> Result { let now = chrono::Utc::now().naive_utc(); let model = cla_sign_status::ActiveModel { - username: Set(username.to_string()), + campsite_user_id: Set(username.to_string()), cla_signed: Set(false), cla_signed_at: Set(None), created_at: Set(now), @@ -55,7 +55,7 @@ impl ClaStorage { Self::handle_record_not_inserted( cla_sign_status::Entity::insert(model) .on_conflict( - OnConflict::column(cla_sign_status::Column::Username) + OnConflict::column(cla_sign_status::Column::CampsiteUserId) .do_nothing() .to_owned(), ) @@ -80,7 +80,7 @@ impl ClaStorage { let now = chrono::Utc::now().naive_utc(); let active_model = cla_sign_status::ActiveModel { - username: Set(username.to_string()), + campsite_user_id: Set(username.to_string()), cla_signed: Set(true), cla_signed_at: Set(Some(now)), created_at: Set(now), @@ -90,7 +90,7 @@ impl ClaStorage { Self::handle_record_not_inserted( cla_sign_status::Entity::insert(active_model) .on_conflict( - OnConflict::column(cla_sign_status::Column::Username) + OnConflict::column(cla_sign_status::Column::CampsiteUserId) .do_nothing() .to_owned(), ) @@ -102,7 +102,7 @@ impl ClaStorage { .col_expr(cla_sign_status::Column::ClaSigned, Expr::value(true)) .col_expr(cla_sign_status::Column::ClaSignedAt, Expr::value(now)) .col_expr(cla_sign_status::Column::UpdatedAt, Expr::value(now)) - .filter(cla_sign_status::Column::Username.eq(username)) + .filter(cla_sign_status::Column::CampsiteUserId.eq(username)) .filter(cla_sign_status::Column::ClaSigned.eq(false)) .exec(self.get_connection()) .await?; @@ -129,8 +129,8 @@ impl ClaStorage { let signed_users: Vec = cla_sign_status::Entity::find() .select_only() - .column(cla_sign_status::Column::Username) - .filter(cla_sign_status::Column::Username.is_in(usernames.iter().cloned())) + .column(cla_sign_status::Column::CampsiteUserId) + .filter(cla_sign_status::Column::CampsiteUserId.is_in(usernames.iter().cloned())) .filter(cla_sign_status::Column::ClaSigned.eq(true)) .into_tuple::() .all(self.get_connection()) diff --git a/jupiter/src/storage/conversation_storage.rs b/jupiter/src/storage/conversation_storage.rs index fdb877704..f8a0e2c3a 100644 --- a/jupiter/src/storage/conversation_storage.rs +++ b/jupiter/src/storage/conversation_storage.rs @@ -103,7 +103,7 @@ impl ConversationStorage { ) -> Result<(), MegaError> { let _ = reactions::Entity::delete_many() .filter(reactions::Column::PublicId.eq(pub_reaction_id)) - .filter(reactions::Column::Username.eq(username)) + .filter(reactions::Column::CampsiteUserId.eq(username)) .exec(self.get_connection()) .await?; Ok(()) diff --git a/jupiter/src/storage/data_backfill_storage.rs b/jupiter/src/storage/data_backfill_storage.rs new file mode 100644 index 000000000..971f63294 --- /dev/null +++ b/jupiter/src/storage/data_backfill_storage.rs @@ -0,0 +1,248 @@ +use std::ops::Deref; + +use callisto::data_backfill_ledger; +use chrono::Utc; +use common::errors::MegaError; +use sea_orm::{ActiveModelTrait, ConnectionTrait, EntityTrait, Set, TransactionTrait}; + +use crate::storage::base_storage::{BaseStorage, StorageConnector}; + +pub const BACKFILL_ACTOR_CAMPSITE_USER_ID_V1: &str = "actor_campsite_user_id_v1"; + +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_RUNNING: &str = "running"; +pub const STATUS_COMPLETED: &str = "completed"; +pub const STATUS_FAILED: &str = "failed"; + +/// Stale `running` rows older than this may be reclaimed after a crashed replica. +const STALE_RUNNING_MINUTES: i64 = 15; + +#[derive(Clone, Debug)] +pub struct MemberIdentityMapping { + pub campsite_user_id: String, + pub username: String, + pub github_login: Option, +} + +#[derive(Clone, Debug)] +pub struct DataBackfillStorage { + pub base: BaseStorage, +} + +impl Deref for DataBackfillStorage { + type Target = BaseStorage; + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl DataBackfillStorage { + pub async fn get(&self, name: &str) -> Result, MegaError> { + Ok(data_backfill_ledger::Entity::find_by_id(name.to_string()) + .one(self.get_connection()) + .await?) + } + + /// Try to claim the backfill for this process. Returns `true` if this replica + /// should run the work. Reclaims stale `running` and retries `failed`/`pending`. + pub async fn try_claim(&self, name: &str) -> Result { + let conn = self.get_connection(); + let now = Utc::now().naive_utc(); + let stale_before = (now - chrono::Duration::minutes(STALE_RUNNING_MINUTES)) + .format("%Y-%m-%d %H:%M:%S%.f") + .to_string(); + let now_s = now.format("%Y-%m-%d %H:%M:%S%.f").to_string(); + let name_esc = esc(name); + + // Ensure row exists (migration seeds it; defensive for older DBs). + let _ = conn + .execute_unprepared(&format!( + r#" + INSERT INTO data_backfill_ledger (name, status, created_at, updated_at) + VALUES ('{name_esc}', '{STATUS_PENDING}', '{now_s}', '{now_s}') + ON CONFLICT (name) DO NOTHING + "# + )) + .await?; + + let result = conn + .execute_unprepared(&format!( + r#" + UPDATE data_backfill_ledger + SET status = '{STATUS_RUNNING}', error = NULL, updated_at = '{now_s}' + WHERE name = '{name_esc}' + AND ( + status IN ('{STATUS_PENDING}', '{STATUS_FAILED}') + OR (status = '{STATUS_RUNNING}' AND updated_at < '{stale_before}') + ) + "# + )) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_completed(&self, name: &str) -> Result<(), MegaError> { + let now = Utc::now().naive_utc(); + if let Some(model) = self.get(name).await? { + let mut am: data_backfill_ledger::ActiveModel = model.into(); + am.status = Set(STATUS_COMPLETED.to_string()); + am.error = Set(None); + am.updated_at = Set(now); + am.update(self.get_connection()).await?; + } + Ok(()) + } + + pub async fn mark_failed(&self, name: &str, error: &str) -> Result<(), MegaError> { + let now = Utc::now().naive_utc(); + if let Some(model) = self.get(name).await? { + let mut am: data_backfill_ledger::ActiveModel = model.into(); + am.status = Set(STATUS_FAILED.to_string()); + am.error = Set(Some(error.chars().take(2000).collect())); + am.updated_at = Set(now); + am.update(self.get_connection()).await?; + } + Ok(()) + } + + /// Apply handle → campsite_user_id mappings (idempotent UPDATEs). + pub async fn apply_member_identity_mappings( + &self, + mappings: &[MemberIdentityMapping], + ) -> Result { + let conn = self.get_connection(); + let mut affected = 0u64; + + let txn = conn.begin().await?; + + for m in mappings { + let id = m.campsite_user_id.trim(); + if id.is_empty() { + continue; + } + let github = m + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(login) = github { + affected += exec_unprepared( + &txn, + &format!( + "UPDATE access_token SET github_login = '{}' WHERE campsite_user_id = '{}' AND (github_login IS NULL OR github_login = '')", + esc(login), + esc(id) + ), + ) + .await?; + affected += exec_unprepared( + &txn, + &format!( + "UPDATE mega_cl_reviewer SET github_login = '{}' WHERE campsite_user_id = '{}' AND (github_login IS NULL OR github_login = '')", + esc(login), + esc(id) + ), + ) + .await?; + } + + for handle in handles_for(m) { + if handle == id { + continue; + } + let h = esc(&handle); + let i = esc(id); + let login_sql = github.map(esc).unwrap_or_default(); + + for table in [ + "mega_cl", + "mega_issue", + "mega_conversation", + "reactions", + "item_assignees", + "mega_code_review_comment", + "access_token", + "ssh_keys", + "cla_sign_status", + "user_notification_settings", + "user_notification_preferences", + "email_jobs", + "mega_group_member", + "user_approval_status", + ] { + affected += exec_unprepared( + &txn, + &format!( + "UPDATE {table} SET campsite_user_id = '{i}' WHERE campsite_user_id = '{h}'" + ), + ) + .await?; + } + + affected += exec_unprepared( + &txn, + &format!( + r#" + DELETE FROM mega_cl_reviewer AS old_row + WHERE old_row.campsite_user_id = '{h}' + AND EXISTS ( + SELECT 1 FROM mega_cl_reviewer AS new_row + WHERE new_row.cl_link = old_row.cl_link + AND new_row.campsite_user_id = '{i}' + ) + "# + ), + ) + .await?; + + affected += exec_unprepared( + &txn, + &format!( + r#" + UPDATE mega_cl_reviewer + SET campsite_user_id = '{i}' + , github_login = COALESCE(NULLIF(github_login, ''), NULLIF('{login_sql}', ''), github_login) + WHERE campsite_user_id = '{h}' + OR github_login = '{h}' + "# + ), + ) + .await?; + } + } + + txn.commit().await?; + Ok(affected) + } +} + +fn handles_for(m: &MemberIdentityMapping) -> Vec { + let mut out = Vec::new(); + let username = m.username.trim(); + if !username.is_empty() { + out.push(username.to_string()); + } + if let Some(g) = m + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + && !out.iter().any(|h| h == g) + { + out.push(g.to_string()); + } + out +} + +fn esc(s: &str) -> String { + s.replace('\'', "''") +} + +async fn exec_unprepared(conn: &C, sql: &str) -> Result { + let result = conn + .execute_unprepared(sql) + .await + .map_err(MegaError::from)?; + Ok(result.rows_affected()) +} diff --git a/jupiter/src/storage/group_storage.rs b/jupiter/src/storage/group_storage.rs index c311ff0a0..9f8885893 100644 --- a/jupiter/src/storage/group_storage.rs +++ b/jupiter/src/storage/group_storage.rs @@ -162,14 +162,14 @@ impl GroupStorage { .map(|username| mega_group_member::ActiveModel { id: Set(generate_id()), group_id: Set(group_id), - username: Set(username.clone()), + campsite_user_id: Set(username.clone()), joined_at: Set(now), }) .collect::>(); let on_conflict = OnConflict::columns([ mega_group_member::Column::GroupId, - mega_group_member::Column::Username, + mega_group_member::Column::CampsiteUserId, ]) .do_nothing() .to_owned(); @@ -190,7 +190,7 @@ impl GroupStorage { Ok(mega_group_member::Entity::find() .filter(mega_group_member::Column::GroupId.eq(group_id)) - .filter(mega_group_member::Column::Username.is_in(usernames)) + .filter(mega_group_member::Column::CampsiteUserId.is_in(usernames)) .order_by_asc(mega_group_member::Column::JoinedAt) .all(self.get_connection()) .await?) @@ -203,7 +203,7 @@ impl GroupStorage { ) -> Result { let result = mega_group_member::Entity::delete_many() .filter(mega_group_member::Column::GroupId.eq(group_id)) - .filter(mega_group_member::Column::Username.eq(username)) + .filter(mega_group_member::Column::CampsiteUserId.eq(username)) .exec(self.get_connection()) .await?; @@ -229,7 +229,7 @@ impl GroupStorage { Ok(mega_group_member::Entity::find() .select_only() .column(mega_group_member::Column::GroupId) - .filter(mega_group_member::Column::Username.eq(username)) + .filter(mega_group_member::Column::CampsiteUserId.eq(username)) .into_tuple::() .all(self.get_connection()) .await?) diff --git a/jupiter/src/storage/issue_storage.rs b/jupiter/src/storage/issue_storage.rs index 249e7f274..9e058e032 100644 --- a/jupiter/src/storage/issue_storage.rs +++ b/jupiter/src/storage/issue_storage.rs @@ -56,7 +56,7 @@ impl IssueStorage { ) .filter(mega_issue::Column::Status.eq(params.status)) .apply_if(params.author, |q, author| { - q.filter(mega_issue::Column::Author.eq(author)) + q.filter(mega_issue::Column::CampsiteUserId.eq(author)) }) .filter(cond) .distinct() @@ -332,20 +332,20 @@ impl IssueStorage { if !to_remove.is_empty() { item_assignees::Entity::delete_many() .filter(item_assignees::Column::ItemId.eq(item_id)) - .filter(item_assignees::Column::AssignneeId.is_in(to_remove.clone())) + .filter(item_assignees::Column::CampsiteUserId.is_in(to_remove.clone())) .exec(&txn) .await?; } if !to_add.is_empty() { let mut new_item = Vec::new(); - for assignnee_id in to_add.clone() { + for campsite_user_id in to_add.clone() { new_item.push( item_assignees::Model { created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), item_id, - assignnee_id, + campsite_user_id, item_type: item_type.clone(), } .into_active_model(), diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 975fe5870..5729c62f6 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -11,6 +11,7 @@ pub mod code_review_comment_storage; pub mod code_review_thread_storage; pub mod commit_binding_storage; pub mod conversation_storage; +pub mod data_backfill_storage; pub mod dynamic_sidebar_storage; pub mod git_db_storage; pub mod gpg_storage; @@ -56,6 +57,7 @@ use crate::{ code_review_thread_storage::CodeReviewThreadStorage, commit_binding_storage::CommitBindingStorage, conversation_storage::ConversationStorage, + data_backfill_storage::DataBackfillStorage, dynamic_sidebar_storage::DynamicSidebarStorage, git_db_storage::GitDbStorage, gpg_storage::GpgStorage, @@ -87,6 +89,7 @@ pub struct AppService { pub cl_storage: ClStorage, pub issue_storage: IssueStorage, pub conversation_storage: ConversationStorage, + pub data_backfill_storage: DataBackfillStorage, pub note_storage: NoteStorage, pub commit_binding_storage: CommitBindingStorage, pub reviewer_storage: ClReviewerStorage, @@ -122,6 +125,7 @@ impl AppService { cl_storage: ClStorage { base: mock.clone() }, issue_storage: IssueStorage { base: mock.clone() }, conversation_storage: ConversationStorage { base: mock.clone() }, + data_backfill_storage: DataBackfillStorage { base: mock.clone() }, note_storage: NoteStorage { base: mock.clone() }, commit_binding_storage: CommitBindingStorage { base: mock.clone() }, reviewer_storage: ClReviewerStorage { base: mock.clone() }, @@ -175,6 +179,7 @@ impl Storage { let issue_storage = IssueStorage { base: base.clone() }; let vault_storage = VaultStorage { base: base.clone() }; let conversation_storage = ConversationStorage { base: base.clone() }; + let data_backfill_storage = DataBackfillStorage { base: base.clone() }; let object_store = ObjectStorageFactory::build(&config.object_storage).await?; let lfs_service = LfsService { lfs_storage: lfs_db_storage.clone(), @@ -244,6 +249,7 @@ impl Storage { cl_storage: cl_storage.clone(), issue_storage, conversation_storage, + data_backfill_storage, note_storage, commit_binding_storage, reviewer_storage, @@ -421,6 +427,10 @@ impl Storage { self.app_service.conversation_storage.clone() } + pub fn data_backfill_storage(&self) -> DataBackfillStorage { + self.app_service.data_backfill_storage.clone() + } + pub fn note_storage(&self) -> NoteStorage { self.app_service.note_storage.clone() } diff --git a/jupiter/src/storage/notification_storage.rs b/jupiter/src/storage/notification_storage.rs index bfefd1050..450b3add9 100644 --- a/jupiter/src/storage/notification_storage.rs +++ b/jupiter/src/storage/notification_storage.rs @@ -47,7 +47,7 @@ impl NotificationStorage { username: &str, ) -> Result, sea_orm::DbErr> { user_notification_settings::Entity::find() - .filter(user_notification_settings::Column::Username.eq(username)) + .filter(user_notification_settings::Column::CampsiteUserId.eq(username)) .one(self.db()) .await } @@ -66,7 +66,7 @@ impl NotificationStorage { model.update(self.db()).await?; } else { user_notification_settings::ActiveModel { - username: Set(username.to_string()), + campsite_user_id: Set(username.to_string()), email: Set(email.to_string()), enabled: Set(true), delivery_mode: Set("realtime".to_string()), @@ -87,7 +87,7 @@ impl NotificationStorage { event_type_code: &str, ) -> Result, sea_orm::DbErr> { user_notification_preferences::Entity::find() - .filter(user_notification_preferences::Column::Username.eq(username)) + .filter(user_notification_preferences::Column::CampsiteUserId.eq(username)) .filter(user_notification_preferences::Column::EventTypeCode.eq(event_type_code)) .one(self.db()) .await @@ -108,7 +108,7 @@ impl NotificationStorage { model.update(self.db()).await?; } else { user_notification_preferences::ActiveModel { - username: Set(username.to_string()), + campsite_user_id: Set(username.to_string()), event_type_code: Set(event_type_code.to_string()), enabled: Set(enabled), created_at: Set(now), @@ -126,7 +126,7 @@ impl NotificationStorage { username: &str, ) -> Result, sea_orm::DbErr> { user_notification_preferences::Entity::find() - .filter(user_notification_preferences::Column::Username.eq(username)) + .filter(user_notification_preferences::Column::CampsiteUserId.eq(username)) .all(self.db()) .await } @@ -204,7 +204,7 @@ impl NotificationStorage { email_jobs::ActiveModel { id: Default::default(), - username: Set(username.to_string()), + campsite_user_id: Set(username.to_string()), to_email: Set(to_email.to_string()), event_type_code: Set(event_type_code.to_string()), subject: Set(subject.to_string()), diff --git a/jupiter/src/storage/stg_common/mod.rs b/jupiter/src/storage/stg_common/mod.rs index e02b664ad..1e733a524 100644 --- a/jupiter/src/storage/stg_common/mod.rs +++ b/jupiter/src/storage/stg_common/mod.rs @@ -66,7 +66,10 @@ where for (model, assignees) in item_assignees { let id = T::get_id(&model); - let assignees = assignees.iter().map(|m| m.assignnee_id.clone()).collect(); + let assignees = assignees + .iter() + .map(|m| m.campsite_user_id.clone()) + .collect(); if let Some(entry) = result.get_mut(&id) { entry.assignees = assignees; } else { @@ -103,7 +106,7 @@ mod tests { created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), closed_at: None, - author: String::from("benjamin_747"), + campsite_user_id: String::from("benjamin_747"), }; let label = label::Model { @@ -117,7 +120,7 @@ mod tests { let assignee = item_assignees::Model { item_id: 1, - assignnee_id: "alice".to_string(), + campsite_user_id: "alice".to_string(), created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), item_type: String::from("issue"), @@ -130,7 +133,7 @@ mod tests { comment: None, created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), - username: String::from("benjamin_747"), + campsite_user_id: String::from("benjamin_747"), resolved: None, }; diff --git a/jupiter/src/storage/stg_common/query_build.rs b/jupiter/src/storage/stg_common/query_build.rs index 5772cff25..f0207d457 100644 --- a/jupiter/src/storage/stg_common/query_build.rs +++ b/jupiter/src/storage/stg_common/query_build.rs @@ -13,7 +13,7 @@ pub fn filter_by_labels(cond: Condition, labels: Option>) -> Condition pub fn filter_by_assignees(cond: Condition, assignees: Option>) -> Condition { if let Some(value) = assignees { - cond.add(item_assignees::Column::AssignneeId.is_in(value)) + cond.add(item_assignees::Column::CampsiteUserId.is_in(value)) } else { cond } diff --git a/jupiter/src/storage/user_approval_storage.rs b/jupiter/src/storage/user_approval_storage.rs index 30ba1032f..8d0fa8065 100644 --- a/jupiter/src/storage/user_approval_storage.rs +++ b/jupiter/src/storage/user_approval_storage.rs @@ -43,10 +43,10 @@ impl UserApprovalStorage { pub async fn get( &self, - username: &str, + campsite_user_id: &str, ) -> Result, MegaError> { Ok( - user_approval_status::Entity::find_by_id(username.to_string()) + user_approval_status::Entity::find_by_id(campsite_user_id.to_string()) .one(self.get_connection()) .await?, ) @@ -55,13 +55,12 @@ impl UserApprovalStorage { /// Create a pending row if missing; refresh profile fields if already present. pub async fn get_or_create( &self, - username: &str, profile: UserApprovalProfile, ) -> Result { let now = chrono::Utc::now().naive_utc(); + let campsite_user_id = profile.campsite_user_id.clone(); let model = user_approval_status::ActiveModel { - username: Set(username.to_string()), - campsite_user_id: Set(profile.campsite_user_id.clone()), + campsite_user_id: Set(campsite_user_id.clone()), display_name: Set(profile.display_name.clone()), email: Set(profile.email.clone()), status: Set(APPROVAL_STATUS_PENDING.to_string()), @@ -74,7 +73,7 @@ impl UserApprovalStorage { Self::handle_record_not_inserted( user_approval_status::Entity::insert(model) .on_conflict( - OnConflict::column(user_approval_status::Column::Username) + OnConflict::column(user_approval_status::Column::CampsiteUserId) .do_nothing() .to_owned(), ) @@ -84,10 +83,6 @@ impl UserApprovalStorage { // Keep profile fields fresh for list display user_approval_status::Entity::update_many() - .col_expr( - user_approval_status::Column::CampsiteUserId, - Expr::value(profile.campsite_user_id), - ) .col_expr( user_approval_status::Column::DisplayName, Expr::value(profile.display_name), @@ -97,11 +92,11 @@ impl UserApprovalStorage { Expr::value(profile.email), ) .col_expr(user_approval_status::Column::UpdatedAt, Expr::value(now)) - .filter(user_approval_status::Column::Username.eq(username)) + .filter(user_approval_status::Column::CampsiteUserId.eq(&campsite_user_id)) .exec(self.get_connection()) .await?; - self.get(username) + self.get(&campsite_user_id) .await? .ok_or_else(|| MegaError::Other("Failed to get or create user approval status".into())) } @@ -125,7 +120,7 @@ impl UserApprovalStorage { pub async fn set_status( &self, - username: &str, + campsite_user_id: &str, status: &str, reviewed_by: &str, ) -> Result { @@ -140,11 +135,10 @@ impl UserApprovalStorage { let now = chrono::Utc::now().naive_utc(); - // Ensure row exists so approve/reject of unknown usernames still works for listed users - let existing = self.get(username).await?; + let existing = self.get(campsite_user_id).await?; if existing.is_none() { return Err(MegaError::Other(format!( - "User approval record not found for `{username}`" + "User approval record not found for `{campsite_user_id}`" ))); } @@ -156,11 +150,11 @@ impl UserApprovalStorage { ) .col_expr(user_approval_status::Column::ReviewedAt, Expr::value(now)) .col_expr(user_approval_status::Column::UpdatedAt, Expr::value(now)) - .filter(user_approval_status::Column::Username.eq(username)) + .filter(user_approval_status::Column::CampsiteUserId.eq(campsite_user_id)) .exec(self.get_connection()) .await?; - self.get(username) + self.get(campsite_user_id) .await? .ok_or_else(|| MegaError::Other("Failed to update user approval status".into())) } diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs index 6e0c9ba7c..2c5b6cfae 100644 --- a/jupiter/src/storage/user_storage.rs +++ b/jupiter/src/storage/user_storage.rs @@ -29,14 +29,14 @@ impl UserStorage { pub async fn save_ssh_key( &self, - username: String, + campsite_user_id: String, title: &str, ssh_key: &str, finger: &str, ) -> Result<(), MegaError> { let model = ssh_keys::Model { id: generate_id(), - username, + campsite_user_id, title: title.to_owned(), ssh_key: ssh_key.to_owned(), finger: finger.to_owned(), @@ -47,18 +47,21 @@ impl UserStorage { Ok(()) } - pub async fn list_user_ssh(&self, username: String) -> Result, MegaError> { + pub async fn list_user_ssh( + &self, + campsite_user_id: String, + ) -> Result, MegaError> { let res: Vec = ssh_keys::Entity::find() - .filter(ssh_keys::Column::Username.eq(username)) + .filter(ssh_keys::Column::CampsiteUserId.eq(campsite_user_id)) .all(self.get_connection()) .await?; Ok(res) } - pub async fn delete_ssh_key(&self, username: String, id: i64) -> Result<(), MegaError> { + pub async fn delete_ssh_key(&self, campsite_user_id: String, id: i64) -> Result<(), MegaError> { let res = ssh_keys::Entity::find() .filter(ssh_keys::Column::Id.eq(id)) - .filter(ssh_keys::Column::Username.eq(username)) + .filter(ssh_keys::Column::CampsiteUserId.eq(campsite_user_id)) .one(self.get_connection()) .await?; if let Some(model) = res { @@ -78,11 +81,16 @@ impl UserStorage { Ok(res) } - pub async fn generate_token(&self, username: String) -> Result { + pub async fn generate_token( + &self, + campsite_user_id: String, + github_login: Option, + ) -> Result { let token_str = Uuid::new_v4().to_string(); let model = access_token::Model { id: generate_id(), - username, + campsite_user_id, + github_login, token: token_str.clone(), created_at: chrono::Utc::now().naive_utc(), }; @@ -91,10 +99,10 @@ impl UserStorage { Ok(token_str.to_owned()) } - pub async fn delete_token(&self, username: String, id: i64) -> Result<(), MegaError> { + pub async fn delete_token(&self, campsite_user_id: String, id: i64) -> Result<(), MegaError> { let res = access_token::Entity::find() .filter(access_token::Column::Id.eq(id)) - .filter(access_token::Column::Username.eq(username)) + .filter(access_token::Column::CampsiteUserId.eq(campsite_user_id)) .one(self.get_connection()) .await?; if let Some(model) = res { @@ -105,18 +113,22 @@ impl UserStorage { pub async fn list_token( &self, - username: String, + campsite_user_id: String, ) -> Result, MegaError> { let res = access_token::Entity::find() - .filter(access_token::Column::Username.eq(username)) + .filter(access_token::Column::CampsiteUserId.eq(campsite_user_id)) .all(self.get_connection()) .await?; Ok(res) } - pub async fn check_token(&self, username: &str, token: &str) -> Result { + pub async fn check_token( + &self, + campsite_user_id: &str, + token: &str, + ) -> Result { let res = access_token::Entity::find() - .filter(access_token::Column::Username.eq(username)) + .filter(access_token::Column::CampsiteUserId.eq(campsite_user_id)) .filter(access_token::Column::Token.eq(token)) .one(self.get_connection()) .await?; @@ -126,13 +138,20 @@ impl UserStorage { } } - pub async fn find_user_by_token(&self, token: &str) -> Result, MegaError> { + /// Returns `(campsite_user_id, github_login)` for a valid access token. + pub async fn find_user_by_token( + &self, + token: &str, + ) -> Result)>, MegaError> { let res = access_token::Entity::find() .filter(access_token::Column::Token.eq(token)) .one(self.get_connection()) .await?; match res { - Some(token_model) => Ok(Some(token_model.username)), + Some(token_model) => Ok(Some(( + token_model.campsite_user_id, + token_model.github_login, + ))), None => Ok(None), } } diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index 6bb74037b..c8db56752 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -5,7 +5,7 @@ use std::{ use common::config::Config; use io_orbit::factory::MegaObjectStorageWrapper; -#[cfg(feature = "migrate")] +#[cfg(any(test, feature = "migrate"))] use jupiter_migrate::apply_migrations; use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use tracing::log; @@ -32,6 +32,7 @@ use crate::{ code_review_thread_storage::CodeReviewThreadStorage, commit_binding_storage::CommitBindingStorage, conversation_storage::ConversationStorage, + data_backfill_storage::DataBackfillStorage, dynamic_sidebar_storage::DynamicSidebarStorage, git_db_storage::GitDbStorage, gpg_storage::GpgStorage, @@ -84,6 +85,7 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { issue_storage: IssueStorage { base: base.clone() }, vault_storage: VaultStorage { base: base.clone() }, conversation_storage: ConversationStorage { base: base.clone() }, + data_backfill_storage: DataBackfillStorage { base: base.clone() }, note_storage: NoteStorage { base: base.clone() }, commit_binding_storage: CommitBindingStorage { base: base.clone() }, reviewer_storage: ClReviewerStorage { base: base.clone() }, @@ -99,7 +101,9 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { audit_storage: AuditStorage { base: base.clone() }, }; - #[cfg(feature = "migrate")] + // Unit tests always migrate (jupiter-migrate is a dev-dependency). + // Production callers enable the `migrate` feature explicitly. + #[cfg(any(test, feature = "migrate"))] apply_migrations(&connection, true).await.unwrap(); let webhook_service = WebhookService::mock(svc.webhook_storage.clone()); diff --git a/mono/README.md b/mono/README.md index 1febc2678..07315348f 100644 --- a/mono/README.md +++ b/mono/README.md @@ -17,6 +17,8 @@ cargo run --bin mono -- service multi http ssh Config: `--config path/to/config.toml` or `MEGA_CONFIG` env var. +When neither is set, mono loads from the current working directory: `config/config.local.toml` if it exists (gitignored local override), otherwise `config/config.toml`. See [config/config.local.toml.example](../config/config.local.toml.example). + ## Layout | Path | Role | diff --git a/mono/src/api/api_common/comment.rs b/mono/src/api/api_common/comment.rs index 1c87ac7e0..682879cbf 100644 --- a/mono/src/api/api_common/comment.rs +++ b/mono/src/api/api_common/comment.rs @@ -4,7 +4,10 @@ use api_model::common::CommonResult; use axum::{Json, extract::State}; use regex::Regex; -use crate::api::{MonoApiServiceState, error::ApiError, oauth::model::LoginUser}; +use crate::api::{ + MonoApiServiceState, api_common::identity::collaboration_actor, error::ApiError, + oauth::model::LoginUser, +}; pub fn parse_data_id(comment: &str) -> HashSet { let data_id = Regex::new(r#"data-id="([A-Za-z0-9]+)""#).unwrap(); @@ -21,13 +24,13 @@ pub async fn check_comment_ref( comment: &str, source_link: &str, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let links = parse_data_id(comment); - let username = user.username; for ref_link in links { state .services() .conversation() - .add_issue_mention_reference(source_link, &ref_link, &username) + .add_issue_mention_reference(source_link, &ref_link, actor) .await?; } diff --git a/mono/src/api/api_common/group_permission.rs b/mono/src/api/api_common/group_permission.rs index a5287336b..3be8a6441 100644 --- a/mono/src/api/api_common/group_permission.rs +++ b/mono/src/api/api_common/group_permission.rs @@ -11,16 +11,12 @@ pub async fn ensure_admin(state: &MonoApiServiceState, user: &LoginUser) -> Resu let admin = state.services().admin(); let cedar_id = user.cedar_user_id(); - // Prefer GitHub login (cedar_user_id). Also accept Campsite username while - // `.mega_cedar.json` is still migrating from username → github_login. - if admin.check_is_admin(cedar_id).await? - || (cedar_id != user.username.as_str() && admin.check_is_admin(&user.username).await?) - { + if admin.check_is_admin(cedar_id).await? { return Ok(()); } tracing::warn!( - actor = %user.username, + campsite_user_id = %user.campsite_user_id, cedar_user_id = %cedar_id, github_login = ?user.github_login, "admin check failed: access forbidden" diff --git a/mono/src/api/api_common/identity.rs b/mono/src/api/api_common/identity.rs new file mode 100644 index 000000000..6fe163b5c --- /dev/null +++ b/mono/src/api/api_common/identity.rs @@ -0,0 +1,16 @@ +use anyhow::anyhow; +use http::StatusCode; + +use crate::api::{error::ApiError, oauth::model::LoginUser}; + +/// Persistent actor id for CL/Issue/account writes: Campsite public user id. +pub fn collaboration_actor(user: &LoginUser) -> Result<&str, ApiError> { + user.require_campsite_user_id() + .map_err(|msg| ApiError::with_status(StatusCode::FORBIDDEN, anyhow!(msg))) +} + +/// GitHub login for Cedar / display / reviewer.github_login dual column. +pub fn collaboration_github_login(user: &LoginUser) -> Result<&str, ApiError> { + user.require_github_login() + .map_err(|msg| ApiError::with_status(StatusCode::FORBIDDEN, anyhow!(msg))) +} diff --git a/mono/src/api/api_common/label_assignee.rs b/mono/src/api/api_common/label_assignee.rs index f0e31055a..08d6706be 100644 --- a/mono/src/api/api_common/label_assignee.rs +++ b/mono/src/api/api_common/label_assignee.rs @@ -2,7 +2,10 @@ use api_model::common::CommonResult; use axum::{Json, extract::State}; use ceres::model::{change_list::AssigneeUpdatePayload, label::LabelUpdatePayload}; -use crate::api::{MonoApiServiceState, error::ApiError, oauth::model::LoginUser}; +use crate::api::{ + MonoApiServiceState, api_common::identity::collaboration_actor, error::ApiError, + oauth::model::LoginUser, +}; pub async fn label_update( user: LoginUser, @@ -10,6 +13,7 @@ pub async fn label_update( payload: LabelUpdatePayload, item_type: String, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let LabelUpdatePayload { label_ids, link, @@ -19,7 +23,7 @@ pub async fn label_update( state .services() .issue() - .update_item_labels(&user.username, item_id, &item_type, label_ids, &link) + .update_item_labels(actor, item_id, &item_type, label_ids, &link) .await?; Ok(Json(CommonResult::success(None))) @@ -31,6 +35,7 @@ pub async fn assignees_update( payload: AssigneeUpdatePayload, item_type: String, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let AssigneeUpdatePayload { assignees, link, @@ -40,7 +45,7 @@ pub async fn assignees_update( state .services() .issue() - .update_item_assignees(&user.username, item_id, &item_type, assignees, &link) + .update_item_assignees(actor, item_id, &item_type, assignees, &link) .await?; Ok(Json(CommonResult::success(None))) diff --git a/mono/src/api/api_common/mod.rs b/mono/src/api/api_common/mod.rs index 7b6ef5f90..9775606a7 100644 --- a/mono/src/api/api_common/mod.rs +++ b/mono/src/api/api_common/mod.rs @@ -1,3 +1,4 @@ pub mod comment; pub mod group_permission; +pub mod identity; pub mod label_assignee; diff --git a/mono/src/api/oauth/mod.rs b/mono/src/api/oauth/mod.rs index f59bca428..8bbdba923 100644 --- a/mono/src/api/oauth/mod.rs +++ b/mono/src/api/oauth/mod.rs @@ -61,11 +61,13 @@ pub async fn login_user_from_mono_access_token( user_storage: &UserStorage, token: &str, ) -> Result, MegaError> { - let Some(username) = user_storage.find_user_by_token(token).await? else { + let Some((campsite_user_id, github_login)) = user_storage.find_user_by_token(token).await? + else { return Ok(None); }; Ok(Some(LoginUser { - username, + campsite_user_id, + github_login: github_login.filter(|s| !s.trim().is_empty()), ..Default::default() })) } diff --git a/mono/src/api/oauth/model.rs b/mono/src/api/oauth/model.rs index 0d720322c..0ec994888 100644 --- a/mono/src/api/oauth/model.rs +++ b/mono/src/api/oauth/model.rs @@ -13,7 +13,6 @@ pub struct CampsiteUserJson { impl From for LoginUser { fn from(value: CampsiteUserJson) -> Self { Self { - username: value.username, email: value.email.unwrap_or_default(), avatar_url: value.avatar_url, campsite_user_id: value.id, @@ -49,7 +48,6 @@ impl From for LoginUser { fn from(value: TinyshipAuthUserJson) -> Self { Self { campsite_user_id: value.id, - username: value.name, email: value.email.unwrap_or_default(), avatar_url: value.image.unwrap_or_default(), github_login: None, @@ -60,7 +58,6 @@ impl From for LoginUser { #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct LoginUser { pub campsite_user_id: String, - pub username: String, /// GitHub login when Campsite authenticated via GitHub; used as Cedar User euid. #[serde(default, skip_serializing_if = "Option::is_none")] pub github_login: Option, @@ -75,6 +72,25 @@ impl LoginUser { .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) - .unwrap_or(self.username.as_str()) + .unwrap_or("") + } + + /// Campsite public user id required for mono persistence. + pub fn require_campsite_user_id(&self) -> Result<&str, &'static str> { + let id = self.campsite_user_id.trim(); + if id.is_empty() { + Err("Campsite user id required. Please re-login.") + } else { + Ok(id) + } + } + + /// GitHub login required for Cedar / display / reviewer.github_login. + pub fn require_github_login(&self) -> Result<&str, &'static str> { + self.github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or("GitHub login required. Please re-login with GitHub.") } } diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index 4f637a535..d059cce31 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -26,8 +26,11 @@ use ceres::model::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_common::group_permission::ensure_admin, api_doc::USER_TAG, - error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, + api_common::{group_permission::ensure_admin, identity::collaboration_actor}, + api_doc::USER_TAG, + error::ApiError, + oauth::model::LoginUser, }; /// Build the admin router. @@ -62,27 +65,14 @@ async fn is_admin_me( ) -> Result>, ApiError> { let admin = state.services().admin(); let cedar_id = user.cedar_user_id(); - // Prefer GitHub login; also accept Campsite username during migration. // On config/load failure (e.g. missing `.mega_cedar.json`), treat as non-admin // so AccountApprovalGuard can still honor an approved user_approval_status. let is_admin = match admin.check_is_admin(cedar_id).await { - Ok(true) => true, - Ok(false) if cedar_id != user.username.as_str() => admin - .check_is_admin(&user.username) - .await - .unwrap_or_else(|e| { - tracing::warn!( - error = %e, - actor = %user.username, - "admin check failed; treating as non-admin" - ); - false - }), - Ok(false) => false, + Ok(v) => v, Err(e) => { tracing::warn!( error = %e, - actor = %user.username, + campsite_user_id = %user.campsite_user_id, cedar_user_id = %cedar_id, "admin check failed; treating as non-admin" ); @@ -215,7 +205,7 @@ async fn list_user_approvals( post, path = "/user-approvals/{username}/approve", params( - ("username" = String, Path, description = "Username to approve") + ("username" = String, Path, description = "Campsite user id to approve") ), responses( (status = 200, body = CommonResult), @@ -230,11 +220,13 @@ async fn approve_user( Path(username): Path, ) -> Result>, ApiError> { 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 model = state .services() .user() - .approve_user(&username, &user.username) + .approve_user(&username, reviewed_by) .await?; Ok(Json(CommonResult::success(Some( @@ -247,7 +239,7 @@ async fn approve_user( post, path = "/user-approvals/{username}/reject", params( - ("username" = String, Path, description = "Username to reject") + ("username" = String, Path, description = "Campsite user id to reject") ), responses( (status = 200, body = CommonResult), @@ -262,11 +254,12 @@ async fn reject_user( Path(username): Path, ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; + let reviewed_by = collaboration_actor(&user)?; let model = state .services() .user() - .reject_user(&username, &user.username) + .reject_user(&username, reviewed_by) .await?; Ok(Json(CommonResult::success(Some( diff --git a/mono/src/api/router/buck_router.rs b/mono/src/api/router/buck_router.rs index 82b6e9f54..f776151b6 100644 --- a/mono/src/api/router/buck_router.rs +++ b/mono/src/api/router/buck_router.rs @@ -13,7 +13,8 @@ use ceres::model::buck::*; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::BUCK_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::BUCK_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -48,9 +49,10 @@ async fn create_session( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let service_resp = state .git() - .create_buck_session(&user.username, &payload.path) + .create_buck_session(actor, &payload.path) .await?; let response = SessionResponse { @@ -88,9 +90,10 @@ async fn upload_manifest( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let response = state .git() - .process_buck_manifest(&user.username, &cl_link, payload) + .process_buck_manifest(actor, &cl_link, payload) .await .map_err(ApiError::from)?; @@ -122,6 +125,7 @@ async fn upload_file( headers: HeaderMap, req: Request, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; use axum::body::to_bytes; use percent_encoding::percent_decode_str; @@ -152,7 +156,7 @@ async fn upload_file( "Buck upload rate limited: cl_link={}, file_size={}, user={}, error={}", cl_link, file_size, - user.username, + actor, e ); ApiError::from(e) @@ -163,7 +167,7 @@ async fn upload_file( cl_link, file_size, _large_file_permit.is_some(), - user.username + actor ); // Validate Content-Type (must be present and application/octet-stream) @@ -201,7 +205,7 @@ async fn upload_file( let svc_resp = state .git() .upload_buck_file( - &user.username, + actor, &cl_link, &file_path, file_size, @@ -249,10 +253,11 @@ async fn complete_upload( state: State, payload: Option>, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let payload = payload.map(|p| p.0).unwrap_or(CompletePayload {}); let response = state .git() - .complete_buck_upload(&user.username, &cl_link, payload) + .complete_buck_upload(actor, &cl_link, payload) .await .map_err(ApiError::from)?; diff --git a/mono/src/api/router/build_trigger_router.rs b/mono/src/api/router/build_trigger_router.rs index d976e9432..ce61138e3 100644 --- a/mono/src/api/router/build_trigger_router.rs +++ b/mono/src/api/router/build_trigger_router.rs @@ -14,7 +14,8 @@ use ceres::application::build_trigger::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::BUILD_TRIGGER_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::BUILD_TRIGGER_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -49,9 +50,10 @@ async fn create_trigger( state: State, Json(req): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let service = state.services().build_trigger(); let response = service - .create_manual_trigger(req.repo_path, req.ref_name, req.params, user.username) + .create_manual_trigger(req.repo_path, req.ref_name, req.params, actor.to_string()) .await?; Ok(Json(CommonResult::success(Some(response)))) } @@ -143,7 +145,8 @@ async fn retry_trigger( state: State, Path(id): Path, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let service = state.services().build_trigger(); - let response = service.retry_trigger(id, user.username).await?; + let response = service.retry_trigger(id, actor.to_string()).await?; Ok(Json(CommonResult::success(Some(response)))) } diff --git a/mono/src/api/router/cl_router.rs b/mono/src/api/router/cl_router.rs index 3a6230cb2..97cefcf2d 100644 --- a/mono/src/api/router/cl_router.rs +++ b/mono/src/api/router/cl_router.rs @@ -16,7 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ MonoApiServiceState, - api_common::{self}, + api_common::{self, identity::collaboration_actor}, api_doc::CL_TAG, error::ApiError, oauth::model::LoginUser, @@ -63,11 +63,8 @@ async fn reopen_cl( Path(link): Path, state: State, ) -> Result>, ApiError> { - state - .services() - .cl() - .reopen_cl(&link, &user.username) - .await?; + let actor = collaboration_actor(&user)?; + state.services().cl().reopen_cl(&link, actor).await?; Ok(Json(CommonResult::success(None))) } @@ -88,11 +85,8 @@ async fn close_cl( Path(link): Path, state: State, ) -> Result>, ApiError> { - state - .services() - .cl() - .close_cl(&link, &user.username) - .await?; + let actor = collaboration_actor(&user)?; + state.services().cl().close_cl(&link, actor).await?; Ok(Json(CommonResult::success(None))) } @@ -113,11 +107,8 @@ async fn merge( Path(link): Path, state: State, ) -> Result>, ApiError> { - state - .services() - .cl() - .merge_open_cl(&user.username, &link) - .await?; + let actor = collaboration_actor(&user)?; + state.services().cl().merge_open_cl(actor, &link).await?; Ok(Json(CommonResult::success(None))) } @@ -183,10 +174,11 @@ async fn cl_detail( Path(link): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let cl_details = state .services() .cl() - .get_cl_details(&link, user.username) + .get_cl_details(&link, actor.to_string()) .await?; Ok(Json(CommonResult::success(Some(cl_details)))) } @@ -314,10 +306,11 @@ async fn update_branch( Path(link): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let new_head = state .services() .cl() - .update_branch_with_webhook(&user.username, &link) + .update_branch_with_webhook(actor, &link) .await?; Ok(Json(CommonResult::success(Some(new_head)))) } @@ -361,10 +354,11 @@ async fn save_comment( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .cl() - .save_cl_comment(&link, &user.username, &payload.content) + .save_cl_comment(&link, actor, &payload.content) .await?; api_common::comment::check_comment_ref(user, state, &payload.content, &link).await } @@ -451,10 +445,11 @@ async fn update_cl_status( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .cl() - .update_cl_status(&link, &user.username, &payload) + .update_cl_status(&link, actor, &payload) .await?; Ok(Json(CommonResult::success(None))) } diff --git a/mono/src/api/router/code_review_router.rs b/mono/src/api/router/code_review_router.rs index 1c818c79e..8a31394df 100644 --- a/mono/src/api/router/code_review_router.rs +++ b/mono/src/api/router/code_review_router.rs @@ -10,7 +10,8 @@ use ceres::model::code_review::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::CODE_REVIEW_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::CODE_REVIEW_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -71,10 +72,11 @@ async fn initialize_code_review_comment( state: State, Json(paload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let thread = state .services() .code_review() - .create_code_review_comment(&link, user.username, paload) + .create_code_review_comment(&link, actor.to_string(), paload) .await?; Ok(Json(CommonResult::success(Some(thread)))) @@ -98,10 +100,11 @@ async fn reply_code_review_comment( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let comment = state .services() .code_review() - .reply_code_review_comment(thread_id, user.username, payload) + .reply_code_review_comment(thread_id, actor.to_string(), payload) .await?; Ok(Json(CommonResult::success(Some(comment)))) @@ -125,10 +128,11 @@ async fn update_code_review_comment( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let comment = state .services() .code_review() - .update_code_review_comment(comment_id, &user.username, payload) + .update_code_review_comment(comment_id, actor, payload) .await?; Ok(Json(CommonResult::success(Some(comment)))) @@ -226,10 +230,11 @@ async fn delete_code_review_comment( Path(comment_id): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .code_review() - .delete_code_review_comment(comment_id, &user.username) + .delete_code_review_comment(comment_id, actor) .await?; Ok(Json(CommonResult::success(None))) diff --git a/mono/src/api/router/conv_router.rs b/mono/src/api/router/conv_router.rs index d5ccee3c9..adc5a9215 100644 --- a/mono/src/api/router/conv_router.rs +++ b/mono/src/api/router/conv_router.rs @@ -7,7 +7,8 @@ use ceres::model::conversation::{ContentPayload, ReactionRequest}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::CONV_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::CONV_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -40,6 +41,7 @@ async fn comment_reactions( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .conversation() @@ -47,7 +49,7 @@ async fn comment_reactions( Some(payload.content), comment_id, &payload.comment_type, - &user.username, + actor, ) .await?; Ok(Json(CommonResult::success(None))) @@ -70,10 +72,11 @@ async fn delete_comment_reaction( Path(id): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .conversation() - .delete_comment_reaction(&id, &user.username) + .delete_comment_reaction(&id, actor) .await?; Ok(Json(CommonResult::success(None))) } diff --git a/mono/src/api/router/group_router.rs b/mono/src/api/router/group_router.rs index ab6e36688..9c3abd577 100644 --- a/mono/src/api/router/group_router.rs +++ b/mono/src/api/router/group_router.rs @@ -128,7 +128,7 @@ async fn get_group( .await? .ok_or_else(|| { tracing::warn!( - actor = %user.username, + campsite_user_id = %user.campsite_user_id, group_id, "group.get failed: group not found" ); @@ -248,7 +248,7 @@ async fn add_group_members( path = "/groups/{group_id}/members/{username}", params( ("group_id" = i64, Path, description = "Group ID"), - ("username" = String, Path, description = "Username") + ("username" = String, Path, description = "Campsite user id of the member") ), responses( (status = 200, body = CommonResult), @@ -264,6 +264,7 @@ async fn remove_group_member( Path((group_id, username)): Path<(i64, String)>, ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; + // Path `username` is campsite_user_id. let removed = state .services() .admin() @@ -467,7 +468,7 @@ async fn delete_resource_permissions( get, path = "/users/{username}/groups", params( - ("username" = String, Path, description = "Username") + ("username" = String, Path, description = "Campsite user id") ), responses( (status = 200, body = CommonResult), @@ -483,6 +484,7 @@ async fn get_user_groups( ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; + // Path `username` is campsite_user_id. let groups = state.services().admin().get_user_groups(&username).await?; let groups = groups.into_iter().map(Into::into).collect(); @@ -496,7 +498,7 @@ async fn get_user_groups( get, path = "/users/{username}/permissions/{resource_type}/{resource_id}", params( - ("username" = String, Path, description = "Username"), + ("username" = String, Path, description = "Campsite user id"), ("resource_type" = String, Path, description = "Resource type, currently only `note`"), ("resource_id" = String, Path, description = "Resource ID") ), @@ -518,6 +520,7 @@ async fn get_user_effective_permission( let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; + // Path `username` is campsite_user_id. let effective = state .services() .admin() diff --git a/mono/src/api/router/issue_router.rs b/mono/src/api/router/issue_router.rs index 624644ef4..874a68b8b 100644 --- a/mono/src/api/router/issue_router.rs +++ b/mono/src/api/router/issue_router.rs @@ -12,7 +12,8 @@ use ceres::model::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_common, api_doc::ISSUE_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common, api_common::identity::collaboration_actor, api_doc::ISSUE_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -74,10 +75,11 @@ async fn issue_detail( Path(link): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let issue_details = state .services() .issue() - .get_issue_details(&link, user.username) + .get_issue_details(&link, actor.to_string()) .await?; Ok(Json(CommonResult::success(Some(issue_details)))) } @@ -97,20 +99,16 @@ async fn new_issue( state: State, Json(json): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let res = state .services() .issue() - .save_issue(&user.username, &json.title) + .save_issue(actor, &json.title) .await?; state .services() .conversation() - .add_conversation( - &res.link, - &user.username, - Some(json.description), - ConvType::Comment, - ) + .add_conversation(&res.link, actor, Some(json.description), ConvType::Comment) .await?; Ok(Json(CommonResult::success(None))) } @@ -132,14 +130,15 @@ async fn close_issue( Path(link): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state.services().issue().close_issue(&link).await?; state .services() .conversation() .add_conversation( &link, - &user.username, - Some(format!("{} closed this", user.username)), + actor, + Some(format!("{} closed this", actor)), ConvType::Closed, ) .await?; @@ -163,14 +162,15 @@ async fn reopen_issue( Path(link): Path, state: State, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state.services().issue().reopen_issue(&link).await?; state .services() .conversation() .add_conversation( &link, - &user.username, - Some(format!("{} reopen this", user.username)), + actor, + Some(format!("{} reopen this", actor)), ConvType::Closed, ) .await?; @@ -196,12 +196,13 @@ async fn save_comment( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; state .services() .conversation() .add_conversation( &link, - &user.username, + actor, Some(payload.content.clone()), ConvType::Comment, ) diff --git a/mono/src/api/router/permission_router.rs b/mono/src/api/router/permission_router.rs index 0756e094d..5023273bb 100644 --- a/mono/src/api/router/permission_router.rs +++ b/mono/src/api/router/permission_router.rs @@ -8,8 +8,9 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ MonoApiServiceState, - api_common::group_permission::{ - build_user_effective_permission_response, resolve_resource_context, + api_common::{ + group_permission::{build_user_effective_permission_response, resolve_resource_context}, + identity::collaboration_actor, }, api_doc::GROUP_PERMISSION_TAG, error::ApiError, @@ -43,7 +44,7 @@ async fn get_my_permission( State(state): State, Path((resource_type, resource_id)): Path<(String, String)>, ) -> Result>, ApiError> { - let actor = user.username; + let actor = collaboration_actor(&user)?.to_string(); let (resource_type_value, normalized_id) = resolve_resource_context(&state, &resource_type, &resource_id).await?; diff --git a/mono/src/api/router/repo_router.rs b/mono/src/api/router/repo_router.rs index 79ab48e0d..fef9fdbb9 100644 --- a/mono/src/api/router/repo_router.rs +++ b/mono/src/api/router/repo_router.rs @@ -8,7 +8,8 @@ use ceres::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::REPO_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::REPO_TAG, + error::ApiError, oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -35,6 +36,7 @@ async fn clone_third_party_repo( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; let path = MonoServiceLogic::validate_github_sync_path(&payload.path)?; let path = PathBuf::from(path); state @@ -45,7 +47,7 @@ async fn clone_third_party_repo( &payload.owner, &payload.repo, path, - &user.username, + actor, ) .await?; diff --git a/mono/src/api/router/reviewer_router.rs b/mono/src/api/router/reviewer_router.rs index 38e8477b7..fe67b0d64 100644 --- a/mono/src/api/router/reviewer_router.rs +++ b/mono/src/api/router/reviewer_router.rs @@ -13,7 +13,10 @@ use ceres::model::{ use common::errors::MegaError; use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::api::{MonoApiServiceState, api_doc::CL_TAG, error::ApiError, oauth::model::LoginUser}; +use crate::api::{ + MonoApiServiceState, api_common::identity::collaboration_actor, api_doc::CL_TAG, + error::ApiError, oauth::model::LoginUser, +}; const ERR_CL_NOT_READY_FOR_REVIEW: &str = "CL is not ready for review"; @@ -47,18 +50,27 @@ async fn add_reviewers( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; + + // Frontend sends campsite_user_ids in reviewer_usernames; github_login is unknown here. + let reviewers: Vec<(String, Option)> = payload + .reviewer_usernames + .iter() + .cloned() + .map(|id| (id, None)) + .collect(); + state .services() .reviewer() - .add_reviewers(&link, payload.reviewer_usernames.clone()) + .add_reviewers(&link, reviewers) .await?; - // Audit log tracing::info!( "[Audit] event=reviewer_added cl_link={} reviewers={:?} actor={}", link, payload.reviewer_usernames, - user.username + actor ); for reviewer in payload.reviewer_usernames { @@ -67,11 +79,8 @@ async fn add_reviewers( .conversation() .add_conversation( &link, - &user.username, - Some(format!( - "{} assigned a new reviewer {}", - user.username, reviewer - )), + actor, + Some(format!("{} assigned a new reviewer {}", actor, reviewer)), ConvType::Comment, ) .await?; @@ -98,18 +107,19 @@ async fn remove_reviewers( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; + state .services() .reviewer() .remove_reviewers(&link, &payload.reviewer_usernames) .await?; - // Audit log tracing::info!( "[Audit] event=reviewer_removed cl_link={} reviewers={:?} actor={}", link, payload.reviewer_usernames, - user.username + actor ); for reviewer in &payload.reviewer_usernames { @@ -118,8 +128,8 @@ async fn remove_reviewers( .conversation() .add_conversation( &link, - &user.username, - Some(format!("{} removed reviewer {}", user.username, reviewer)), + actor, + Some(format!("{} removed reviewer {}", actor, reviewer)), ConvType::Comment, ) .await?; @@ -167,6 +177,8 @@ async fn reviewer_approve( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let actor = collaboration_actor(&user)?; + if state .services() .conversation() @@ -182,7 +194,7 @@ async fn reviewer_approve( state .services() .reviewer() - .reviewer_change_state(&link, &user.username, payload.approved) + .reviewer_change_state(&link, actor, payload.approved) .await?; state @@ -190,8 +202,8 @@ async fn reviewer_approve( .conversation() .add_conversation( &link, - &user.username, - Some(format!("{} approved the CL", user.username)), + actor, + Some(format!("{} approved the CL", actor)), ConvType::Approve, ) .await?; @@ -219,13 +231,15 @@ async fn review_resolve( Path(link): Path, Json(payload): Json, ) -> Result>, ApiError> { - let res = state + let actor = collaboration_actor(&user)?; + + let is_reviewer = state .services() .reviewer() - .is_reviewer(&link, &user.username) + .is_reviewer(&link, actor) .await?; - if !res { + if !is_reviewer { return Err(ApiError::from(MegaError::Other( "Only reviewer can resolve the review comments".to_string(), ))); @@ -242,8 +256,8 @@ async fn review_resolve( .conversation() .add_conversation( &link, - &user.username, - Some(format!("{} resolved a review", user.username)), + actor, + Some(format!("{} resolved a review", actor)), ConvType::Comment, ) .await?; diff --git a/mono/src/api/router/user_router.rs b/mono/src/api/router/user_router.rs index 3e18294ea..3cd7c5627 100644 --- a/mono/src/api/router/user_router.rs +++ b/mono/src/api/router/user_router.rs @@ -18,7 +18,11 @@ use russh::keys::{HashAlg, parse_public_key_base64}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - MonoApiServiceState, api_doc::USER_TAG, error::ApiError, oauth::model::LoginUser, + MonoApiServiceState, + api_common::identity::{collaboration_actor, collaboration_github_login}, + api_doc::USER_TAG, + error::ApiError, + oauth::model::LoginUser, }; pub fn routers() -> OpenApiRouter { @@ -65,6 +69,7 @@ async fn add_key( state: State, Json(json): Json, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); let ssh_parts: Vec<&str> = json.ssh_key.split_whitespace().collect(); let key = parse_public_key_base64( ssh_parts @@ -83,7 +88,7 @@ async fn add_key( .services() .user() .save_ssh_key( - user.username, + campsite_user_id, &title, &json.ssh_key, &key.fingerprint(HashAlg::Sha256).to_string(), @@ -109,10 +114,11 @@ async fn remove_key( state: State, Path(key_id): Path, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); state .services() .user() - .delete_ssh_key(user.username, key_id) + .delete_ssh_key(campsite_user_id, key_id) .await?; Ok(Json(CommonResult::success(None))) } @@ -130,10 +136,11 @@ async fn list_key( user: LoginUser, state: State, ) -> Result>>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); let res = state .services() .user() - .list_user_ssh_keys(user.username) + .list_user_ssh_keys(campsite_user_id) .await?; Ok(Json(CommonResult::success(Some(res)))) } @@ -151,10 +158,12 @@ async fn generate_token( user: LoginUser, state: State, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); + let github_login = collaboration_github_login(&user)?.to_string(); let res = state .services() .user() - .generate_user_token(user.username) + .generate_user_token(campsite_user_id, Some(github_login)) .await?; Ok(Json(CommonResult::success(Some(res)))) } @@ -176,10 +185,11 @@ async fn remove_token( state: State, Path(key_id): Path, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); state .services() .user() - .delete_user_token(user.username, key_id) + .delete_user_token(campsite_user_id, key_id) .await?; Ok(Json(CommonResult::success(None))) } @@ -197,10 +207,11 @@ async fn list_token( user: LoginUser, state: State, ) -> Result>>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?.to_string(); let data = state .services() .user() - .list_user_tokens(user.username) + .list_user_tokens(campsite_user_id) .await?; Ok(Json(CommonResult::success(Some(data)))) } @@ -236,10 +247,11 @@ async fn get_notification_config( user: LoginUser, state: State, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?; let config = state .services() .user() - .get_user_notification_config(&user.username, &user.email) + .get_user_notification_config(campsite_user_id, &user.email) .await?; Ok(Json(CommonResult::success(Some(config)))) @@ -258,10 +270,11 @@ async fn update_notification_config( state: State, Json(payload): Json, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?; state .services() .user() - .update_user_notification_config(&user.username, &user.email, payload) + .update_user_notification_config(campsite_user_id, &user.email, payload) .await?; Ok(Json(CommonResult::success(None))) @@ -289,14 +302,16 @@ async fn get_cla_sign_status( user: LoginUser, state: State, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?; let (cla_signed, cla_signed_at) = state .services() .user() - .get_or_init_cla_sign_status(&user.username) + .get_or_init_cla_sign_status(campsite_user_id) .await?; let res = ClaSignStatusRes { - username: user.username, + // API compat: username mirrors campsite_user_id after column drop. + username: campsite_user_id.to_string(), cla_signed, cla_signed_at: cla_signed_at.map(|dt| dt.and_utc().timestamp()), }; @@ -316,14 +331,15 @@ async fn change_sign_status( user: LoginUser, state: State, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?; let (cla_signed, cla_signed_at) = state .services() .user() - .change_cla_sign_status(&user.username) + .change_cla_sign_status(campsite_user_id) .await?; let res = ClaSignStatusRes { - username: user.username, + username: campsite_user_id.to_string(), cla_signed, cla_signed_at: cla_signed_at.map(|dt| dt.and_utc().timestamp()), }; @@ -386,15 +402,17 @@ async fn get_user_approval_status( user: LoginUser, state: State, ) -> Result>, ApiError> { + let campsite_user_id = collaboration_actor(&user)?; + let display_name = user + .github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(campsite_user_id); let model = state .services() .user() - .get_or_init_user_approval_status( - &user.username, - &user.campsite_user_id, - &user.username, - &user.email, - ) + .get_or_init_user_approval_status(campsite_user_id, display_name, &user.email) .await?; Ok(Json(CommonResult::success(Some( diff --git a/mono/src/backfill/actor_identity.rs b/mono/src/backfill/actor_identity.rs new file mode 100644 index 000000000..7ae4f5651 --- /dev/null +++ b/mono/src/backfill/actor_identity.rs @@ -0,0 +1,140 @@ +//! Automatic handle → campsite_user_id backfill on mono startup. +//! +//! 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`]. + +use std::time::Instant; + +use jupiter::storage::{ + Storage, + data_backfill_storage::{BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, MemberIdentityMapping}, +}; +use serde::Deserialize; +use tracing::{error, info, warn}; + +const ORG_SLUG: &str = "mega"; +const SECRET_HEADER: &str = "X-Mega-Internal-Secret"; + +#[derive(Debug, Deserialize)] +struct CampsiteMemberIdentity { + campsite_user_id: String, + #[serde(default)] + username: String, + #[serde(default)] + github_login: Option, +} + +/// Spawn non-blocking backfill after Storage is ready. Failures are logged; +/// ledger stays `failed`/`pending` so the next boot retries. +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)"); + } + }); +} + +async fn run_actor_identity_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"); + return Ok(()); + } + + let ledger = storage.data_backfill_storage(); + if let Some(row) = ledger.get(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1).await? + && row.status == "completed" + { + info!( + backfill = BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, + "actor identity backfill already completed; skip" + ); + return Ok(()); + } + + if !ledger.try_claim(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1).await? { + info!( + backfill = BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, + "actor identity backfill claimed by another replica or already done; skip" + ); + return Ok(()); + } + + let started = Instant::now(); + match fetch_and_apply(&storage, &config.oauth.campsite_api_domain, secret).await { + Ok((mappings, affected)) => { + ledger + .mark_completed(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1) + .await?; + info!( + backfill = BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, + mappings, + rows_affected = affected, + elapsed_ms = started.elapsed().as_millis() as u64, + "actor identity backfill completed" + ); + Ok(()) + } + Err(e) => { + let msg = format!("{e:#}"); + let _ = ledger + .mark_failed(BACKFILL_ACTOR_CAMPSITE_USER_ID_V1, &msg) + .await; + Err(e) + } + } +} + +async fn fetch_and_apply( + storage: &Storage, + api_base: &str, + secret: &str, +) -> anyhow::Result<(usize, u64)> { + let url = format!( + "{}/v1/organizations/{}/internal/member_identities", + api_base.trim_end_matches('/'), + ORG_SLUG + ); + + let client = reqwest::Client::builder().no_proxy().build()?; + let resp = client + .get(&url) + .header(SECRET_HEADER, secret) + .send() + .await + .map_err(|e| anyhow::anyhow!("campsite member_identities request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("campsite member_identities HTTP {status}: {body}"); + } + + let identities: Vec = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("parse member_identities JSON: {e}"))?; + + let mappings: Vec = identities + .into_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 + .github_login + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + }) + .collect(); + + let count = mappings.len(); + let affected = storage + .data_backfill_storage() + .apply_member_identity_mappings(&mappings) + .await?; + Ok((count, affected)) +} diff --git a/mono/src/backfill/mod.rs b/mono/src/backfill/mod.rs new file mode 100644 index 000000000..a485db414 --- /dev/null +++ b/mono/src/backfill/mod.rs @@ -0,0 +1,5 @@ +//! Application-level data backfills run on mono boot (after schema migrations). + +mod actor_identity; + +pub use actor_identity::spawn_actor_identity_backfill; diff --git a/mono/src/git_protocol/http.rs b/mono/src/git_protocol/http.rs index 864cd6339..3af76bbec 100644 --- a/mono/src/git_protocol/http.rs +++ b/mono/src/git_protocol/http.rs @@ -139,7 +139,21 @@ async fn git_receive_pack_auth( return Ok(false); }; - let username = user.username; + let Ok(campsite_user_id) = user.require_campsite_user_id() else { + tracing::warn!( + "git receive-pack rejected: access token missing campsite_user_id; regenerate token" + ); + return Ok(false); + }; + if user.require_github_login().is_err() { + tracing::warn!( + campsite_user_id = %campsite_user_id, + "git receive-pack rejected: access token missing github_login; regenerate token after GitHub login" + ); + return Ok(false); + } + // Push actor persisted on CLs is campsite_user_id (not github_login). + let username = campsite_user_id.to_string(); pack_protocol.auth.username = Some(username.clone()); pack_protocol.auth.authenticated_user = Some(PushUserInfo { username }); Ok(true) diff --git a/mono/src/lib.rs b/mono/src/lib.rs index 49bca4f50..ea6871b82 100644 --- a/mono/src/lib.rs +++ b/mono/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod backfill; pub mod bootstrap; pub mod cli; mod commands; diff --git a/mono/src/server/http_server.rs b/mono/src/server/http_server.rs index 24d4eced0..10f5f60b8 100644 --- a/mono/src/server/http_server.rs +++ b/mono/src/server/http_server.rs @@ -206,6 +206,9 @@ async fn shutdown_signal(token: CancellationToken) { pub async fn start_http(ctx: AppContext, options: CommonHttpOptions) { let CommonHttpOptions { host, port } = options.clone(); + // Automatic identity backfill (handle → campsite_user_id); non-blocking. + crate::backfill::spawn_actor_identity_backfill(ctx.storage.clone()); + let middleware = tower::util::MapRequestLayer::new(rewrite_lfs_request_uri::); let shutdown_token = CancellationToken::new(); diff --git a/moon/apps/web/components/ClBox/MergeBox.tsx b/moon/apps/web/components/ClBox/MergeBox.tsx index 8e380836b..c257de363 100644 --- a/moon/apps/web/components/ClBox/MergeBox.tsx +++ b/moon/apps/web/components/ClBox/MergeBox.tsx @@ -10,6 +10,7 @@ import { useGetMergeBox } from '@/components/ClBox/hooks/useGetMergeBox' import { useGetClReviewers } from '@/hooks/CL/useGetClReviewers' import { usePostClReviewerApprove } from '@/hooks/CL/usePostClReviewerApprove' import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' +import { megaUserHandlesMatch } from '@/utils/megaUser' import { legacyApiClient } from '@/utils/queryClient' import { ChecksSection } from './ChecksSection' @@ -29,13 +30,14 @@ export const MergeBox = React.memo<{ prId: string; status?: string; author?: str const queryClient = useQueryClient() const { reviewers, isLoading: isReviewerLoading } = useGetClReviewers(id) - const required: number = useMemo(() => reviewers.length, [reviewers]) + // At least one approval is enough when reviewers are assigned. + const required: number = useMemo(() => (reviewers.length > 0 ? 1 : 0), [reviewers]) const actual: number = useMemo(() => reviewers.filter((i) => i.approved).length, [reviewers]) const isAllReviewerApproved: boolean = useMemo(() => actual >= required, [actual, required]) let isNowUserApprove: boolean | undefined = undefined const { data } = useGetCurrentUser() - const find_user = reviewers.find((i) => i.username === data?.username) + const find_user = reviewers.find((i) => megaUserHandlesMatch(i.campsite_user_id || i.username, data)) if (find_user) { isNowUserApprove = find_user.approved @@ -66,7 +68,7 @@ export const MergeBox = React.memo<{ prId: string; status?: string; author?: str const claCondition = additionalChecks.find((c) => c.type === CheckType.ClaSign) const claCheck = claCondition ? claCondition.result === ConditionResult.PASSED : true - const isClAuthor = data?.username === author + const isClAuthor = megaUserHandlesMatch(author, data) return (
diff --git a/moon/apps/web/components/ClBox/MergeSection.tsx b/moon/apps/web/components/ClBox/MergeSection.tsx index a77378b97..92c14fd86 100644 --- a/moon/apps/web/components/ClBox/MergeSection.tsx +++ b/moon/apps/web/components/ClBox/MergeSection.tsx @@ -96,14 +96,14 @@ export const MergeSection = React.memo( statusNode = (
- Merging is blocked - Waiting for reviewers approval + Merging is blocked - Waiting for reviewer approval
) } else { statusNode = (
- Ready to merge - All reviewers approved + Ready to merge - Reviewer approved
) } diff --git a/moon/apps/web/components/ClBox/ReviewerSection.tsx b/moon/apps/web/components/ClBox/ReviewerSection.tsx index 7a23aaf4f..78bd43879 100644 --- a/moon/apps/web/components/ClBox/ReviewerSection.tsx +++ b/moon/apps/web/components/ClBox/ReviewerSection.tsx @@ -13,7 +13,7 @@ export function ReviewerSection({ required, actual }: ReviewerSectionProps) {
- All required reviewers have approved + A required reviewer has approved
) diff --git a/moon/apps/web/components/ClView/ApproveItem.tsx b/moon/apps/web/components/ClView/ApproveItem.tsx index 4f917a036..e3ea2cb6a 100644 --- a/moon/apps/web/components/ClView/ApproveItem.tsx +++ b/moon/apps/web/components/ClView/ApproveItem.tsx @@ -4,7 +4,7 @@ import { ConditionalWrap } from '@gitmono/ui' import { ActorAvatar } from '@/components/ActorAvatar' import { BotBadge } from '@/components/BotBadge' import { MemberHovercard } from '@/components/InlinePost/MemberHovercard' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import HandleTime from './components/HandleTime' @@ -16,7 +16,7 @@ interface ApproveItemProps { const ApproveItem = ({ conv }: ApproveItemProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/AssigneeItem.tsx b/moon/apps/web/components/ClView/AssigneeItem.tsx index 61e336d68..f71ced338 100644 --- a/moon/apps/web/components/ClView/AssigneeItem.tsx +++ b/moon/apps/web/components/ClView/AssigneeItem.tsx @@ -1,6 +1,6 @@ import { ConditionalWrap } from '@gitmono/ui/utils' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import { MemberHovercard } from '../InlinePost/MemberHovercard' @@ -13,7 +13,7 @@ import { ReopenItemProps } from './ReopenItem' const AssigneeItem = ({ conv }: ReopenItemProps) => { const match = conv.comment?.match(/\["(.*?)"\]/) ?? '' const comment = conv.comment?.split(' ') ?? [] - const { data: member } = useGetOrganizationMember({ username: conv.username }) + const { data: member } = useMemberByActor(conv.username) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) diff --git a/moon/apps/web/components/ClView/CLComment.tsx b/moon/apps/web/components/ClView/CLComment.tsx index f9e6cc200..93e871975 100644 --- a/moon/apps/web/components/ClView/CLComment.tsx +++ b/moon/apps/web/components/ClView/CLComment.tsx @@ -9,7 +9,7 @@ import { Button, ConditionalWrap, FaceSmilePlusIcon, PicturePlusIcon, UIText } f import { EMPTY_HTML } from '@/atoms/markdown' import { RichTextRenderer } from '@/components/RichTextRenderer' import { usePostComment } from '@/hooks/issues/usePostComment' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { useUploadHelpers } from '@/hooks/useUploadHelpers' import { apiErrorToast } from '@/utils/apiErrorToast' import { megaUserHandle } from '@/utils/megaUser' @@ -39,7 +39,7 @@ interface CommentProps { const Comment = React.memo(({ conv, id, whoamI, editorRef }: CommentProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/CloseItem.tsx b/moon/apps/web/components/ClView/CloseItem.tsx index a62922aae..75df4d16f 100644 --- a/moon/apps/web/components/ClView/CloseItem.tsx +++ b/moon/apps/web/components/ClView/CloseItem.tsx @@ -3,7 +3,7 @@ import { ConditionalWrap } from '@gitmono/ui' import { ActorAvatar } from '@/components/ActorAvatar' import { BotBadge } from '@/components/BotBadge' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import { MemberHovercard } from '../InlinePost/MemberHovercard' @@ -15,7 +15,7 @@ interface CloseItemProps { } const CloseItem = ({ conv }: CloseItemProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/ConversationTab.tsx b/moon/apps/web/components/ClView/ConversationTab.tsx index 0e0352526..985995af5 100644 --- a/moon/apps/web/components/ClView/ConversationTab.tsx +++ b/moon/apps/web/components/ClView/ConversationTab.tsx @@ -213,12 +213,22 @@ export const ConversationTab = React.memo(
{names.map((i) => { const member = memberMap.get(i) - const displayName = member?.user?.github_login?.trim() || member?.user?.username || i const reviewer = - reviewers.find((r) => r.username === i) || - reviewers.find((r) => r.username === member?.user?.username) + reviewers.find((r) => r.campsite_user_id === i || r.username === i) || + reviewers.find( + (r) => + r.campsite_user_id === member?.user?.id || + r.username === member?.user?.username || + r.github_login === member?.user?.github_login + ) + const displayName = + reviewer?.github_login?.trim() || + member?.user?.github_login?.trim() || + member?.user?.username || + reviewer?.username || + i const isApproved = reviewer?.approved ?? false - const deleteUsername = reviewer?.username || member?.user?.username || i + const deleteUsername = reviewer?.campsite_user_id || reviewer?.username || member?.user?.id || i return (
diff --git a/moon/apps/web/components/ClView/EditItem.tsx b/moon/apps/web/components/ClView/EditItem.tsx index 5aee2bfe6..3aec02569 100644 --- a/moon/apps/web/components/ClView/EditItem.tsx +++ b/moon/apps/web/components/ClView/EditItem.tsx @@ -3,7 +3,7 @@ import { ConditionalWrap } from '@gitmono/ui' import { ActorAvatar } from '@/components/ActorAvatar' import { BotBadge } from '@/components/BotBadge' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import { MemberHovercard } from '../InlinePost/MemberHovercard' @@ -16,7 +16,7 @@ interface EditItemProps { const EditItem = ({ conv }: EditItemProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/LabelItem.tsx b/moon/apps/web/components/ClView/LabelItem.tsx index fea9bf016..50a543395 100644 --- a/moon/apps/web/components/ClView/LabelItem.tsx +++ b/moon/apps/web/components/ClView/LabelItem.tsx @@ -4,7 +4,7 @@ import { useQueries } from '@tanstack/react-query' import { ConversationItem } from '@gitmono/types/generated' import { ConditionalWrap } from '@gitmono/ui' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { getFontColor } from '@/utils/getFontColor' import { megaUserHandle } from '@/utils/megaUser' import { legacyApiClient } from '@/utils/queryClient' @@ -19,7 +19,7 @@ interface LabelItemProps { } function LabelItem({ conv }: LabelItemProps) { - const { data: member } = useGetOrganizationMember({ username: conv.username }) + const { data: member } = useMemberByActor(conv.username) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) const comment = conv.comment?.split(' ') ?? [] diff --git a/moon/apps/web/components/ClView/MergedItem.tsx b/moon/apps/web/components/ClView/MergedItem.tsx index 80aa47b87..55b162557 100644 --- a/moon/apps/web/components/ClView/MergedItem.tsx +++ b/moon/apps/web/components/ClView/MergedItem.tsx @@ -4,7 +4,7 @@ import { ConditionalWrap, Link } from '@gitmono/ui' import { ActorAvatar } from '@/components/ActorAvatar' import { BotBadge } from '@/components/BotBadge' import { useScope } from '@/contexts/scope' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import { MemberHovercard } from '../InlinePost/MemberHovercard' @@ -28,7 +28,7 @@ function queueHref(scope: unknown): string | null { const MergedItem = ({ conv }: MergedItemProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const { scope } = useScope() const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/ReopenItem.tsx b/moon/apps/web/components/ClView/ReopenItem.tsx index f342e2b12..3a96c076b 100644 --- a/moon/apps/web/components/ClView/ReopenItem.tsx +++ b/moon/apps/web/components/ClView/ReopenItem.tsx @@ -3,7 +3,7 @@ import { ConditionalWrap } from '@gitmono/ui' import { ActorAvatar } from '@/components/ActorAvatar' import { BotBadge } from '@/components/BotBadge' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' import { MemberHovercard } from '../InlinePost/MemberHovercard' @@ -15,7 +15,7 @@ export interface ReopenItemProps { } const ReopenItem = ({ conv }: ReopenItemProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username diff --git a/moon/apps/web/components/ClView/ReviewComment.tsx b/moon/apps/web/components/ClView/ReviewComment.tsx index bb052b31d..0eb3d9694 100644 --- a/moon/apps/web/components/ClView/ReviewComment.tsx +++ b/moon/apps/web/components/ClView/ReviewComment.tsx @@ -13,7 +13,7 @@ import { RichTextRenderer } from '@/components/RichTextRenderer' import { usePostClReviewResolve } from '@/hooks/CL/usePostClReviewResolve' import { usePostComment } from '@/hooks/issues/usePostComment' import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { useUploadHelpers } from '@/hooks/useUploadHelpers' import { apiErrorToast } from '@/utils/apiErrorToast' import { megaUserHandle, megaUserHandlesMatch } from '@/utils/megaUser' @@ -46,7 +46,7 @@ interface ReviewCommentProps { const ReviewComment = React.memo( ({ reviewers, conv, id, whoamI, editorRef }: ReviewCommentProps) => { const isBot = !!conv.is_bot - const { data: member } = useGetOrganizationMember({ username: conv.username, enabled: !isBot }) + const { data: member } = useMemberByActor(conv.username, !isBot) const { data: currentUser } = useGetCurrentUser() const profileUsername = member?.user.username || conv.username const displayName = megaUserHandle(member?.user, conv.username) || conv.username @@ -67,7 +67,7 @@ const ReviewComment = React.memo( if (!currentUser) { return false } - return reviewers.some((r) => megaUserHandlesMatch(r.username, currentUser)) + return reviewers.some((r) => megaUserHandlesMatch(r.campsite_user_id || r.username, currentUser)) }, [currentUser, reviewers]) useEffect(() => { diff --git a/moon/apps/web/components/ClView/TimelineItems.tsx b/moon/apps/web/components/ClView/TimelineItems.tsx index 2355a7bc6..26dbf2dcf 100644 --- a/moon/apps/web/components/ClView/TimelineItems.tsx +++ b/moon/apps/web/components/ClView/TimelineItems.tsx @@ -94,7 +94,9 @@ const TimelineItems = React.memo<{ let children let isOver = false - const isCurrentReviewer = reviewers.some((r) => r.username === conv.username) + const isCurrentReviewer = reviewers.some( + (r) => r.campsite_user_id === conv.username || r.username === conv.username + ) switch (conv.conv_type) { case 'Comment': diff --git a/moon/apps/web/components/ClView/components/MegaUserLabel.tsx b/moon/apps/web/components/ClView/components/MegaUserLabel.tsx index 2f0649990..2ae190076 100644 --- a/moon/apps/web/components/ClView/components/MegaUserLabel.tsx +++ b/moon/apps/web/components/ClView/components/MegaUserLabel.tsx @@ -1,9 +1,9 @@ -import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useMemberByActor } from '@/hooks/useMemberByActor' import { megaUserHandle } from '@/utils/megaUser' /** Resolves and displays Mega user handle (github_login preferred). */ export function MegaUserLabel({ username, className }: { username?: string | null; className?: string }) { - const { data: member } = useGetOrganizationMember({ username: username || undefined }) + const { data: member } = useMemberByActor(username) const label = megaUserHandle(member?.user, username || '') || 'username not found' return {label} diff --git a/moon/apps/web/components/ClView/index.tsx b/moon/apps/web/components/ClView/index.tsx index eec7a862c..0bdb3d50b 100644 --- a/moon/apps/web/components/ClView/index.tsx +++ b/moon/apps/web/components/ClView/index.tsx @@ -71,7 +71,9 @@ export default function CLView() { const authorHandle = useCallback( (author: string) => { - const member = members.find((m) => m.user.username === author || m.user.github_login === author) + const member = members.find( + (m) => m.user.id === author || m.user.username === author || m.user.github_login === author + ) return megaUserHandle(member?.user, author) }, diff --git a/moon/apps/web/components/ClView/useReviewerSelector.tsx b/moon/apps/web/components/ClView/useReviewerSelector.tsx index 265a87083..db0d9d370 100644 --- a/moon/apps/web/components/ClView/useReviewerSelector.tsx +++ b/moon/apps/web/components/ClView/useReviewerSelector.tsx @@ -5,19 +5,26 @@ import { ReviewerInfo } from '@gitmono/types' import { useAvatars } from '@/components/Issues/utils/sideEffect' -type MegaAvatar = ReturnType[number] & { username?: string } +type MegaAvatar = ReturnType[number] & { + id?: string + username?: string + github_login?: string +} function avatarApiIdentity(item: ItemInput): string | undefined { const mega = item as MegaAvatar - // Prefer Campsite username for API identity; SelectPanel text may be github_login. + // Persist campsite_user_id. - if (typeof mega.username === 'string' && mega.username) return mega.username + if (typeof mega.id === 'string' && mega.id) return mega.id + if (typeof item.id === 'string' && item.id) return item.id + if (typeof mega.github_login === 'string' && mega.github_login) return mega.github_login if (typeof item.text === 'string' && item.text) return item.text + if (typeof mega.username === 'string' && mega.username) return mega.username return undefined } function avatarMatchesHandle(user: MegaAvatar, handle: string) { - return user.text === handle || user.username === handle + return user.id === handle || user.text === handle || user.github_login === handle || user.username === handle } export const useReviewerSelector = ({ @@ -29,7 +36,7 @@ export const useReviewerSelector = ({ reviewRequest: (selected: string[]) => void avatars: ReturnType }) => { - const initialReviewers = useMemo(() => reviewers.map((item) => item.username), [reviewers]) + const initialReviewers = useMemo(() => reviewers.map((item) => item.campsite_user_id || item.username), [reviewers]) const [selectedUsers, setSelectedUsers] = useState([]) const shouldFetch = useRef(false) const [open, setOpen] = useState(false) diff --git a/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx b/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx index 702aa1702..e5292ff8b 100644 --- a/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx +++ b/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx @@ -5,6 +5,7 @@ import toast from 'react-hot-toast' import { LoadingSpinner } from '@gitmono/ui' +import { useAdminCheck } from '@/hooks/admin/useAdminCheck' import { useAdminList } from '@/hooks/admin/useAdminList' import { useGenerateMegaCedar } from '@/hooks/admin/useGenerateMegaCedar' import { useGetSyncMembers } from '@/hooks/useGetSyncMembers' @@ -59,7 +60,17 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled enabled: true }) - const { data: adminListData, isLoading: isAdminListLoading } = useAdminList() + const { data: adminCheck, isLoading: isAdminCheckLoading } = useAdminCheck() + const isMonoAdmin = adminCheck?.data?.is_admin === true + const canUseAdminApis = !isAdminCheckLoading && isMonoAdmin + + const { + data: adminListData, + isLoading: isAdminListLoading, + isError: isAdminListError, + error: adminListError + } = useAdminList({ enabled: canUseAdminApis }) + const generateCedar = useGenerateMegaCedar() const parsedAdmins = useMemo(() => parseAdminsFromCedarContent(fileContent), [fileContent]) @@ -73,11 +84,21 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled return } - if (!isAdminListLoading && adminListData?.data?.admins) { - setSelectedAdmins([...(adminListData.data.admins || [])].sort()) + if (isAdminCheckLoading) return + + if (!canUseAdminApis) { initializedRef.current = true + return + } + + if (isAdminListLoading) return + + if (adminListData?.data?.admins) { + setSelectedAdmins([...(adminListData.data.admins || [])].sort()) } - }, [parsedAdmins, adminListData, isAdminListLoading]) + + initializedRef.current = true + }, [parsedAdmins, adminListData, isAdminListLoading, canUseAdminApis, isAdminCheckLoading]) const regenerateContent = useCallback( async (admins: string[]) => { @@ -86,6 +107,11 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled return } + if (!isMonoAdmin) { + toast.error('Admin access required to regenerate .mega_cedar.json') + return + } + try { const response = await generateCedar.mutateAsync({ admins }) const content = response?.data?.content @@ -99,11 +125,16 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled // apiErrorToast handles the error } }, - [generateCedar, onContentGenerated] + [generateCedar, onContentGenerated, isMonoAdmin] ) const handleToggle = useCallback( (username: string) => { + if (!isMonoAdmin) { + toast.error('Admin access required to regenerate .mega_cedar.json') + return + } + setSelectedAdmins((prev) => { const next = prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username].sort() @@ -116,10 +147,15 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled return next }) }, - [regenerateContent] + [regenerateContent, isMonoAdmin] ) - const isLoading = isMembersLoading || isAdminListLoading + const isLoading = isMembersLoading || isAdminCheckLoading || (canUseAdminApis && isAdminListLoading) + const adminForbidden = + (!isAdminCheckLoading && !isMonoAdmin) || + (isAdminListError && + adminListError instanceof Error && + (adminListError.name === 'ForbiddenError' || /admin access required/i.test(adminListError.message))) return (
@@ -138,6 +174,16 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled )}
+ {adminForbidden && ( +
+

Admin access required

+

+ You can view the current admin list from the file, but regenerating{' '} + .mega_cedar.json requires mono admin permission. +

+
+ )} + {membersError && (

Failed to load organization members

@@ -157,7 +203,7 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled onChange={(e) => setMemberSearchQuery(e.target.value)} className='mb-3 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-hidden' placeholder='Search members by name or username...' - disabled={disabled || !!membersError || generateCedar.isPending} + disabled={disabled || !!membersError || generateCedar.isPending || adminForbidden} />
@@ -179,13 +225,13 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled key={member.user.id} className={`flex cursor-pointer items-center px-3 py-2 transition-colors hover:bg-gray-50 ${ isSelected ? 'bg-blue-50' : '' - } ${disabled || generateCedar.isPending ? 'pointer-events-none opacity-60' : ''}`} + } ${disabled || generateCedar.isPending || adminForbidden ? 'pointer-events-none opacity-60' : ''}`} > handleToggle(cedarId)} - disabled={disabled || generateCedar.isPending} + disabled={disabled || generateCedar.isPending || adminForbidden} className='mr-3 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500' /> - {names.map((i, index) => ( - // eslint-disable-next-line react/no-array-index-key -
- - {i} -
- ))} + {names.map((i, index) => { + const member = memberMap.get(i) + const displayName = member?.user?.github_login?.trim() || member?.user?.username || i + + return ( + // eslint-disable-next-line react/no-array-index-key +
+ + {displayName} +
+ ) + })} ) }} diff --git a/moon/apps/web/components/Issues/IssueNewPage.tsx b/moon/apps/web/components/Issues/IssueNewPage.tsx index 11ce518a5..cd4273e39 100644 --- a/moon/apps/web/components/Issues/IssueNewPage.tsx +++ b/moon/apps/web/components/Issues/IssueNewPage.tsx @@ -21,6 +21,7 @@ import { useUploadHelpers } from '@/hooks/useUploadHelpers' import { apiErrorToast } from '@/utils/apiErrorToast' import { trimHtml } from '@/utils/trimHtml' +import { ActorAvatar } from '../ActorAvatar' import { MemberAvatar } from '../MemberAvatar' import { pickWithReflect } from './utils/pickWithReflectDeep' import { splitFun, useAvatars, useLabelMap, useLabels, useMemberMap } from './utils/sideEffect' @@ -204,13 +205,18 @@ export default function IssueNewPage() { return ( <> - {names.map((i, index) => ( - // eslint-disable-next-line react/no-array-index-key -
- - {i} -
- ))} + {names.map((i, index) => { + const member = memberMap.get(i) + const displayName = member?.user?.github_login?.trim() || member?.user?.username || i + + return ( + // eslint-disable-next-line react/no-array-index-key +
+ + {displayName} +
+ ) + })} ) }} diff --git a/moon/apps/web/components/Issues/utils/sideEffect.tsx b/moon/apps/web/components/Issues/utils/sideEffect.tsx index 5f032c8b1..840d279c5 100644 --- a/moon/apps/web/components/Issues/utils/sideEffect.tsx +++ b/moon/apps/web/components/Issues/utils/sideEffect.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from 'react' import type { SelectPanelItemInput as ItemInput } from '@primer/react' import { useAtom } from 'jotai' -import { LabelItem } from '@gitmono/types' +import { LabelItem, SyncOrganizationMember } from '@gitmono/types' import { MemberAvatar } from '@/components/MemberAvatar' import { useScope } from '@/contexts/scope' @@ -19,9 +19,11 @@ export const useAvatars = ({ preferGithubLogin = false }: { preferGithubLogin?: () => members?.map((i) => ({ groupId: 'end', + // Campsite public id — mono persistence key. + id: i.user.id, text: preferGithubLogin ? megaUserHandle(i.user) : i.user.username, - // Campsite username kept for matching legacy CL/reviewer records. username: i.user.username, + github_login: i.user.github_login?.trim() || undefined, leadingVisual: () => })) || [], [members, preferGithubLogin] @@ -34,13 +36,14 @@ export const splitFun = (el: React.ReactNode): string[] => { .filter((n) => n.length > 0) } -export const useMemberMap = () => { +export const useMemberMap = (): Map => { const { members } = useSyncedMembers() return useMemo(() => { - const map = new Map() + const map = new Map() members?.forEach((i) => { + map.set(i.user.id, i) map.set(i.user.username, i) const github = i.user.github_login?.trim() @@ -91,18 +94,26 @@ export const useLabelMap = () => { // assignees逻辑 -type MegaAvatarItem = ReturnType[number] & { username?: string } +type MegaAvatarItem = ReturnType[number] & { + id?: string + username?: string + github_login?: string +} function avatarApiIdentity(item: ItemInput): string | undefined { const mega = item as MegaAvatarItem + // Persist campsite_user_id. - if (typeof mega.username === 'string' && mega.username) return mega.username + if (typeof mega.id === 'string' && mega.id) return mega.id + if (typeof item.id === 'string' && item.id) return item.id + if (typeof mega.github_login === 'string' && mega.github_login) return mega.github_login if (typeof item.text === 'string' && item.text) return item.text + if (typeof mega.username === 'string' && mega.username) return mega.username return undefined } function avatarMatchesStoredHandle(avatar: MegaAvatarItem, handle: string) { - return avatar.text === handle || avatar.username === handle + return avatar.id === handle || avatar.text === handle || avatar.github_login === handle || avatar.username === handle } export const useAssigneesSelector = ({ diff --git a/moon/apps/web/components/Setting/PersonalToken.tsx b/moon/apps/web/components/Setting/PersonalToken.tsx index 61ddc34d2..4bbb564ad 100644 --- a/moon/apps/web/components/Setting/PersonalToken.tsx +++ b/moon/apps/web/components/Setting/PersonalToken.tsx @@ -10,6 +10,7 @@ import { useDeleteTokenById } from '@/hooks/useDeleteTokenById' import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' import { useGetTokenList } from '@/hooks/useGetTokenList' import { usePostTokenGenerate } from '@/hooks/usePostTokenGenerate' +import { megaUserHandle } from '@/utils/megaUser' import { legacyApiClient } from '@/utils/queryClient' const TokenItem = ({ item }: { item: ListToken }) => { @@ -130,8 +131,8 @@ const PersonalToken = () => {

Your new token has been generated.

- Username: - + GitHub login: +
Token: diff --git a/moon/apps/web/hooks/admin/useAdminList.ts b/moon/apps/web/hooks/admin/useAdminList.ts index 81f0461da..24e227673 100644 --- a/moon/apps/web/hooks/admin/useAdminList.ts +++ b/moon/apps/web/hooks/admin/useAdminList.ts @@ -4,11 +4,14 @@ import type { GetApiAdminListData, RequestParams } from '@gitmono/types' import { legacyApiClient } from '@/utils/queryClient' -export function useAdminList(params?: RequestParams) { +export function useAdminList(params?: RequestParams & { enabled?: boolean }) { + const { enabled = true, ...requestParams } = params ?? {} + return useQuery({ - queryKey: [...legacyApiClient.v1.getApiAdminList().requestKey(), params], - queryFn: () => legacyApiClient.v1.getApiAdminList().request(params), + queryKey: [...legacyApiClient.v1.getApiAdminList().requestKey(), requestParams], + queryFn: () => legacyApiClient.v1.getApiAdminList().request(requestParams), staleTime: 0, - retry: false + retry: false, + enabled }) } diff --git a/moon/apps/web/hooks/useMemberByActor.ts b/moon/apps/web/hooks/useMemberByActor.ts new file mode 100644 index 000000000..90ec7e146 --- /dev/null +++ b/moon/apps/web/hooks/useMemberByActor.ts @@ -0,0 +1,14 @@ +import { SyncOrganizationMember } from '@gitmono/types' + +import { useMemberMap } from '@/components/Issues/utils/sideEffect' + +/** + * Resolve an org member from mono actor identity (campsite_user_id), + * Campsite username, or github_login via the synced members map. + */ +export function useMemberByActor(actor?: string | null, enabled = true) { + const memberMap = useMemberMap() + const data = enabled && actor ? (memberMap.get(actor) as SyncOrganizationMember | undefined) : undefined + + return { data } +} diff --git a/moon/apps/web/next-env.d.ts b/moon/apps/web/next-env.d.ts index 655c58fcf..b75ab5c22 100644 --- a/moon/apps/web/next-env.d.ts +++ b/moon/apps/web/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/dev/types/routes.d.ts' +import './.next/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/moon/apps/web/utils/apiErrorToast.ts b/moon/apps/web/utils/apiErrorToast.ts index f6f13d05c..3416fbbc1 100644 --- a/moon/apps/web/utils/apiErrorToast.ts +++ b/moon/apps/web/utils/apiErrorToast.ts @@ -2,10 +2,27 @@ import toast from 'react-hot-toast' import { ApiError, ApiErrorTypes } from '@gitmono/types' +function toastMessage(error: Error): string { + const raw = error.message?.trim() + + if (raw) return raw + + if (error instanceof ApiError) { + if (error.name === ApiErrorTypes.ForbiddenError) { + return 'Access forbidden' + } + if (error.name === ApiErrorTypes.AuthenticationError) { + return 'Please sign in again' + } + } + + return 'Something went wrong' +} + export function apiErrorToast(error: Error) { // never toast when there are connection stability errors if (error instanceof ApiError && error.name === ApiErrorTypes.ConnectionError) { return } - toast.error(error.message) + toast.error(toastMessage(error)) } diff --git a/moon/apps/web/utils/megaUser.ts b/moon/apps/web/utils/megaUser.ts index 3631b7532..c69ebb61a 100644 --- a/moon/apps/web/utils/megaUser.ts +++ b/moon/apps/web/utils/megaUser.ts @@ -12,14 +12,13 @@ export function megaUserHandle( return fallback } -/** True when a stored CL/reviewer handle matches this user (username or github_login). */ +/** True when a stored campsite_user_id matches this user. */ export function megaUserHandlesMatch( stored: string | null | undefined, - user?: { username?: string | null; github_login?: string | null } | null + user?: { id?: string | null; username?: string | null; github_login?: string | null } | null ): boolean { if (!stored || !user) return false - if (stored === user.username) return true - const github = user.github_login?.trim() - - return !!github && stored === github + if (user.id && stored === user.id) return true + // Transitional: pre-backfill rows may still hold github_login / username strings. + return stored === megaUserHandle(user) || (!!user.username && stored === user.username) } diff --git a/moon/apps/web/utils/queryClient.ts b/moon/apps/web/utils/queryClient.ts index 2b71f3d9f..38a816128 100644 --- a/moon/apps/web/utils/queryClient.ts +++ b/moon/apps/web/utils/queryClient.ts @@ -159,13 +159,52 @@ export const ssrApiClient = new Api({ } }) +/** + * Mono returns CommonResult `{ err_message, ... }` on errors, while the shared + * HttpClient reads Campsite-style `{ message, code }`. Remap so ApiError.message + * (and toasts) surface the real reason — e.g. 403 "Admin access required". + */ +async function monoFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init) + + if (response.ok || response.status === 204) { + return response + } + + const contentType = response.headers.get('content-type') || '' + + if (!contentType.includes('application/json')) { + return response + } + + try { + const data = await response.json() + const message = + (typeof data?.message === 'string' && data.message) || + (typeof data?.err_message === 'string' && data.err_message) || + 'Something went wrong' + const code = + (typeof data?.code === 'string' && data.code) || + (response.status === 403 ? 'forbidden' : response.status === 401 ? 'unauthorized' : '') + + return new Response(JSON.stringify({ ...data, message, code }), { + status: response.status, + statusText: response.statusText, + headers: { 'Content-Type': 'application/json' } + }) + } catch { + return response + } +} + export const legacyApiClient = new Api({ baseUrl: MONO_API_URL, baseApiParams: { credentials: 'include', headers: { 'Content-Type': 'application/json' }, format: 'json' - } + }, + customFetch: monoFetch }) export const orionApiClient = new Api({ baseUrl: ORION_API_URL, diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index 7aafbdba3..90d9fceff 100644 --- a/moon/packages/types/generated.ts +++ b/moon/packages/types/generated.ts @@ -3225,6 +3225,7 @@ export type FigmaKeyPair = { } export type AddMembersRequest = { + /** Campsite public user ids (field name kept for API compat). */ usernames: string[] } @@ -3922,6 +3923,7 @@ export type CommonResultCommonPageGroupMemberResponse = { id: number /** @format int64 */ joined_at: number + /** Campsite public user id (field name kept for API compat). */ username: string }[] /** @@ -4744,6 +4746,7 @@ export type CommonResultVecGroupMemberResponse = { id: number /** @format int64 */ joined_at: number + /** Campsite public user id (field name kept for API compat). */ username: string }[] err_message: string @@ -5248,6 +5251,7 @@ export type GroupMemberResponse = { id: number /** @format int64 */ joined_at: number + /** Campsite public user id (field name kept for API compat). */ username: string } @@ -5795,7 +5799,10 @@ export type ResponseObject = { export type ReviewerInfo = { approved: boolean + campsite_user_id: string + github_login?: string | null system_required: boolean + /** Display name: github_login if present, else campsite_user_id (API compat). */ username: string } @@ -8719,7 +8726,7 @@ export type DeleteApiAdminGroupsMembersByUsernameParams = { * @format int64 */ groupId: number - /** Username */ + /** Campsite user id of the member */ username: string } @@ -8779,28 +8786,28 @@ export type GetApiAdminUserApprovalsParams = { export type GetApiAdminUserApprovalsData = CommonResultUserApprovalListRes export type PostApiAdminUserApprovalsApproveParams = { - /** Username to approve */ + /** Campsite user id to approve */ username: string } export type PostApiAdminUserApprovalsApproveData = CommonResultUserApprovalStatusRes export type PostApiAdminUserApprovalsRejectParams = { - /** Username to reject */ + /** Campsite user id to reject */ username: string } export type PostApiAdminUserApprovalsRejectData = CommonResultUserApprovalStatusRes export type GetApiAdminUsersGroupsParams = { - /** Username */ + /** Campsite user id */ username: string } export type GetApiAdminUsersGroupsData = CommonResultUserGroupsResponse export type GetApiAdminUsersPermissionsByResourceIdParams = { - /** Username */ + /** Campsite user id */ username: string /** Resource type, currently only `note` */ resourceType: string From 9fc974f241e4eaf837b34446063d421ac951f488 Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Tue, 4 Aug 2026 16:00:59 +0800 Subject: [PATCH 3/3] fix clippy --- .../m20260804_120000_actor_to_campsite_user_id.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs b/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs index dcfcde846..380fe159e 100644 --- a/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs +++ b/jupiter-migrate/src/migration/m20260804_120000_actor_to_campsite_user_id.rs @@ -84,10 +84,8 @@ async fn up_postgres(manager: &SchemaManager<'_>) -> Result<(), DbErr> { .await?; // --- access_token --- - db.execute_unprepared( - r#"ALTER TABLE access_token RENAME COLUMN username TO campsite_user_id"#, - ) - .await?; + db.execute_unprepared(r#"ALTER TABLE access_token RENAME COLUMN username TO campsite_user_id"#) + .await?; // --- ssh_keys --- db.execute_unprepared(r#"ALTER TABLE ssh_keys RENAME COLUMN username TO campsite_user_id"#) @@ -143,7 +141,9 @@ async fn up_sqlite(manager: &SchemaManager<'_>) -> Result<(), DbErr> { .alter_table( Table::alter() .table(Alias::new("access_token")) - .add_column_if_not_exists(ColumnDef::new(Alias::new("github_login")).string().null()) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("github_login")).string().null(), + ) .to_owned(), ) .await?; @@ -152,7 +152,9 @@ async fn up_sqlite(manager: &SchemaManager<'_>) -> Result<(), DbErr> { .alter_table( Table::alter() .table(Alias::new("mega_cl_reviewer")) - .add_column_if_not_exists(ColumnDef::new(Alias::new("github_login")).string().null()) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("github_login")).string().null(), + ) .to_owned(), ) .await?;