diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 04f5ebb64..9b51d9e6e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -26,13 +26,18 @@ cargo nextest run -p when suggesting fast local test runs ## Key Components (Modules) -This is a monorepo containing many components. Some key ones include: - -* **`mono` / `ceres` / `jupiter` / `moon`**: These are various services and libraries within the monorepo, primarily written in Rust and TypeScript. -* **`orion`**: Build orchestration and workspace management. -* **`saturn`**: Policy and permission management. - -**Note**: `scorpio` (FUSE filesystem) has been moved to its own repository at [scorpiofs](https://github.com/web3infra-foundation/scorpiofs). +| Crate | Role | +|-------|------| +| `mono` | Server binary (HTTP REST, Git HTTP/SSH, Swagger) | +| `ceres` | Domain library: transport, application, HTTP DTOs | +| `jupiter` / `callisto` | Storage and SeaORM entities | +| `jupiter-migrate` | Database migrations | +| `orion` / `orion-server` / `orion-scheduler` | Build orchestration | +| `saturn` | Cedar policy | +| `vault` | Crypto and secrets | +| `moon` | Frontend (TypeScript) | + +See [docs/architecture.md](docs/architecture.md) for the full workspace map. ## Coding style & quality diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index a6912cd79..3c7a0a474 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -85,6 +85,45 @@ jobs: with: shared-key: base-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} cache-on-failure: true + - name: Enforce DTO boundary (mono must not import jupiter::model) + run: | + if rg -q 'jupiter::model' mono/; then + echo "mono must not depend on jupiter::model; use ceres::model and MonoApiService facades" + rg 'jupiter::model' mono/ + exit 1 + fi + - name: Enforce module dependency boundaries + run: | + if rg -q 'use callisto::' mono/src/; then + echo "mono/src must not import callisto directly; use ceres::model and MonoApiService facades" + rg 'use callisto::' mono/src/ + exit 1 + fi + if rg -q 'jupiter::service::' mono/src/; then + echo "mono/src must not import jupiter::service directly; use MonoApiService facades" + rg 'jupiter::service::' mono/src/ + exit 1 + fi + if rg -q 'api_service::mono::MonoApiService' ceres/src/transport/; then + echo "ceres transport must not depend on MonoApiService" + rg 'api_service::mono::MonoApiService' ceres/src/transport/ + exit 1 + fi + if rg -q 'use crate::(pack|transport)' ceres/src/application/; then + echo "ceres application must not import transport/pack directly" + rg 'use crate::(pack|transport)' ceres/src/application/ + exit 1 + fi + if rg --glob '!lfs_router.rs' -q '\.storage\.|_stg\(\)' mono/src/api/router/; then + echo "mono routers must use MonoApiService facades, not storage" + rg --glob '!lfs_router.rs' '\.storage\.|_stg\(\)' mono/src/api/router/ + exit 1 + fi + if rg -q 'ceres::(api_service|pack|protocol|build_trigger|code_edit)' mono/; then + echo "mono must use ceres::application::* and ceres::transport::* instead of legacy re-exports" + rg 'ceres::(api_service|pack|protocol|build_trigger|code_edit)' mono/ + exit 1 + fi - name: Run cargo clippy run: | sccache --start-server || true diff --git a/Cargo.lock b/Cargo.lock index 216fbeb1e..0b166986e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1265,6 +1265,7 @@ dependencies = [ "async-recursion", "async-trait", "axum", + "axum-core", "bytes", "callisto", "chrono", @@ -1274,6 +1275,7 @@ dependencies = [ "hex", "io-orbit", "jupiter", + "jupiter-migrate", "orion-client", "pgp", "rand 0.10.1", @@ -1495,8 +1497,6 @@ name = "common" version = "0.1.0" dependencies = [ "anyhow", - "api-model", - "axum", "cedar-policy", "clap", "config", @@ -1615,15 +1615,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "context" -version = "0.1.0" -dependencies = [ - "common", - "jupiter", - "vault", -] - [[package]] name = "convert_case" version = "0.4.0" @@ -4225,6 +4216,7 @@ dependencies = [ "idgenerator", "indexmap 2.14.0", "io-orbit", + "jupiter-migrate", "pgp", "rand 0.10.1", "redis", @@ -4235,7 +4227,6 @@ dependencies = [ "rustls", "saturn", "sea-orm", - "sea-orm-migration", "serde", "serde_json", "sha1 0.11.0", @@ -4247,6 +4238,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "jupiter-migrate" +version = "0.1.0" +dependencies = [ + "callisto", + "chrono", + "common", + "sea-orm", + "sea-orm-migration", + "serde_json", + "tempfile", + "tracing", +] + [[package]] name = "k256" version = "0.13.4" @@ -4961,13 +4966,11 @@ dependencies = [ "axum-extra", "base64", "bytes", - "callisto", "cedar-policy", "ceres", "chrono", "clap", "common", - "context", "ctrlc", "ed25519-dalek 2.2.0", "futures", @@ -4975,6 +4978,7 @@ dependencies = [ "http", "jemallocator", "jupiter", + "jupiter-migrate", "lettre", "mimalloc", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 91ae43b01..392f822cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,10 @@ members = [ "ceres", "clients/orion-client", "common", - "context", "io-orbit", "jupiter", "jupiter/callisto", + "jupiter-migrate", "mono", "orion", "orion-scheduler", @@ -19,7 +19,6 @@ members = [ "vault", ] default-members = ["mono", "orion", "orion-server", "orion-scheduler"] -exclude = ["tools/artifacts-compose-e2e"] resolver = "3" @@ -29,6 +28,7 @@ io-orbit = { path = "io-orbit" } mono = { path = "mono" } common = { path = "common" } jupiter = { path = "jupiter" } +jupiter-migrate = { path = "jupiter-migrate" } ceres = { path = "ceres" } callisto = { path = "jupiter/callisto" } vault = { path = "vault" } @@ -36,8 +36,6 @@ saturn = { path = "saturn" } orion = { path = "orion" } orion-client = { path = "clients/orion-client" } -context = { path = "context" } - git-internal = "0.7.6" libvault-core = "0.1.0" diff --git a/README.md b/README.md index 57e4a4531..bb506fe1c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ Mega is evolving toward deeper AI-native capabilities: To facilitate a rapid deployment and hands-on experience with the Mega service, the following instructions are derived from the project's [documentation](https://github.com/web3infra-foundation/mega/tree/main/docker). +- **Docker demo (recommended):** [docker/README.md](docker/README.md) +- **Native development:** [docs/development.md](docs/development.md) +- **Architecture:** [docs/architecture.md](docs/architecture.md) + +Related projects: [Libra](https://github.com/web3infra-foundation/libra) (Git-compatible agent client), [ScorpioFS](https://github.com/web3infra-foundation/scorpiofs) (FUSE monorepo mount). + ## Community Discord Channel - https://discord.gg/HMFuu6pJmQ diff --git a/ceres/Cargo.toml b/ceres/Cargo.toml index 4e813713b..2b82a7c62 100644 --- a/ceres/Cargo.toml +++ b/ceres/Cargo.toml @@ -35,7 +35,7 @@ uuid = { workspace = true, features = ["v4"] } reqwest = { workspace = true, features = ["json", "stream"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "process"] } tokio-stream = { workspace = true } -axum = { workspace = true } +axum-core = "0.5.6" async-recursion = { workspace = true } sysinfo = { workspace = true } utoipa = { workspace = true } @@ -43,5 +43,10 @@ regex = { workspace = true } tokio-util = { workspace = true } rkyv = { workspace = true } +[features] +migrate = ["jupiter/migrate"] + [dev-dependencies] +axum = { workspace = true } +jupiter-migrate = { workspace = true } tempfile = { workspace = true } diff --git a/ceres/README.md b/ceres/README.md index a0def4500..86ca78322 100644 --- a/ceres/README.md +++ b/ceres/README.md @@ -8,7 +8,7 @@ Monorepo domain library for Mega: Git transport, REST application logic, and sha ceres/src/ ├── lib.rs ├── bus/ # Transport ↔ application event bus -├── infra/ # Shared infrastructure (GitObjectCache) +├── infra/ # Shared infrastructure (GitObjectCache, pack streams, decode errors) ├── transport/ │ ├── protocol/ # Smart HTTP/SSH Git protocol │ └── pack/ # receive-pack / upload-pack handlers @@ -20,7 +20,9 @@ ceres/src/ ├── diff/, merge_checker/, lfs/ ``` -Legacy paths (`ceres::protocol`, `ceres::pack`, `ceres::api_service`, etc.) remain as re-exports in `lib.rs` for compatibility with `mono`. +`mono` uses `ceres::application::*` and `ceres::transport::*` for domain logic and Git transport. + +`axum-core` is confined to `ceres/infra/pack_decode.rs` for `git-internal` pack decode stream errors until upstream accepts `std::io::Error`. ## Dependency rules @@ -31,6 +33,42 @@ Legacy paths (`ceres::protocol`, `ceres::pack`, `ceres::api_service`, etc.) rema | `bus` | Minimal shared types for events | `transport` / `application` implementations | | `mono` (binary) | Assembles `TransportRuntime` + injects handlers | — | +## Model boundary + +Three DTO layers; keep imports aligned with this table: + +| Layer | Crate / path | Role | Consumers | +|-------|--------------|------|-----------| +| Wire | `api-model` | mono ↔ orion cross-process protocol (buck2, artifacts, shared pagination wrappers) | `mono`, `orion`, `orion-client`, `ceres` (pagination only where needed) | +| HTTP / OpenAPI | `ceres/model` | All mono REST request/response types + `utoipa` schemas | `mono` routers, `ceres` application | +| Storage assembly | `jupiter/model` | Bundles of `callisto` entities from storage/services; no serde/utoipa | `jupiter` storage/service, `ceres` application only | + +Rules: + +- `mono` routers must **not** `use jupiter::model` — map via `ceres::model` and `MonoApiService` facades. +- `mono/src` must **not** `use callisto::` or `jupiter::service::` — storage entities and service calls stay in `ceres` application layer. +- `ceres/src/transport` must **not** reference `MonoApiService` (transport ↔ application boundary). +- `api-model` is **not** mono HTTP schema (except shared wrappers like `CommonPage` / `Pagination`). +- `ceres/model` is the mapping hub: `impl From` and `impl From` live here. +- `application/build_trigger/model` is a ceres subdomain API schema (build triggers); same HTTP rules as `ceres/model`, kept alongside orchestration until a later consolidation. + +## Error type boundaries + +| Layer | Error type | HTTP adapter (mono) | +|-------|------------|---------------------| +| `ceres/application`, `jupiter` | `MegaError` | `ApiError` (`mono/src/api/error.rs`) | +| `ceres/transport`, Git Smart HTTP/SSH | `ProtocolError` | `protocol_error::into_response` | +| Buck upload | `BuckError` (via `MegaError::Buck`) | `ApiError` | +| Git LFS | `GitLFSError` | `map_lfs_error` in `lfs_router` | + +Rules: + +- REST routers and `MonoApiService` use `MegaError` only; `api_handler` resolves import vs mono handlers and returns `MegaError`. +- `ProtocolError` is confined to Git client protocol paths; use `mega_to_protocol_error` at transport boundaries when mapping domain failures. +- Do not use `ProtocolError` in REST handlers. + +Long term: extract `ceres/model` → `mega-api-types` only if a non-mono consumer needs HTTP DTOs without ceres domain code. + ## Git push event flow ```mermaid diff --git a/ceres/src/application/api_service/blame_ops.rs b/ceres/src/application/api_service/blame_ops.rs index ec8b35cec..ffbae1aaa 100644 --- a/ceres/src/application/api_service/blame_ops.rs +++ b/ceres/src/application/api_service/blame_ops.rs @@ -17,7 +17,7 @@ use git_internal::{ }; use crate::{ - api_service::ApiHandler, + application::api_service::ApiHandler, model::blame::{BlameBlock, BlameInfo, BlameQuery, BlameResult, Contributor}, }; @@ -182,7 +182,8 @@ pub async fn get_file_blame( // Resolve starting commit from refs let start_commit = - crate::api_service::commit_ops::resolve_start_commit(handler, ref_name).await?; + crate::application::api_service::commit_ops::resolve_start_commit(handler, ref_name) + .await?; // Get file content and blob hash at start commit let (current_content, start_blob_hash) = diff --git a/ceres/src/application/api_service/blob_ops.rs b/ceres/src/application/api_service/blob_ops.rs index 706b41401..8315a44ce 100644 --- a/ceres/src/application/api_service/blob_ops.rs +++ b/ceres/src/application/api_service/blob_ops.rs @@ -14,7 +14,7 @@ use git_internal::{ use sha1::{Digest, Sha1}; use crate::{ - api_service::{ApiHandler, tree_ops}, + application::api_service::{ApiHandler, tree_ops}, model::git::DiffPreviewPayload, }; diff --git a/ceres/src/application/api_service/commit_ops.rs b/ceres/src/application/api_service/commit_ops.rs index 0d3e5a968..6c6383eb9 100644 --- a/ceres/src/application/api_service/commit_ops.rs +++ b/ceres/src/application/api_service/commit_ops.rs @@ -21,7 +21,7 @@ use jupiter::redis::AsyncCommands; use serde::{Deserialize, Serialize}; use crate::{ - api_service::{ApiHandler, history, tree_ops}, + application::api_service::{ApiHandler, history, tree_ops}, model::{ change_list::{DiffItemSchema, MuiTreeNode}, commit::{CommitFilesChangedPage, CommitSummary, GpgStatus}, diff --git a/ceres/src/application/api_service/history.rs b/ceres/src/application/api_service/history.rs index c13beaf88..f0b294de5 100644 --- a/ceres/src/application/api_service/history.rs +++ b/ceres/src/application/api_service/history.rs @@ -13,7 +13,7 @@ use git_internal::{ }, }; -use crate::api_service::ApiHandler; +use crate::application::api_service::ApiHandler; const MAX_ITERATIONS: usize = 10_000; @@ -67,7 +67,8 @@ pub async fn item_to_commit_map( ) -> Result>, GitError> { // Resolve the starting commit let start_commit_arc = - crate::api_service::commit_ops::resolve_start_commit(handler, reference).await?; + crate::application::api_service::commit_ops::resolve_start_commit(handler, reference) + .await?; // Get the tree at the specified path from the resolved start commit let start_tree = handler diff --git a/ceres/src/application/api_service/import_api_service.rs b/ceres/src/application/api_service/import_api_service.rs index 5aab9d28f..22ee7dbb5 100644 --- a/ceres/src/application/api_service/import_api_service.rs +++ b/ceres/src/application/api_service/import_api_service.rs @@ -24,7 +24,7 @@ use git_internal::{ use jupiter::{storage::Storage, utils::converter::FromGitModel}; use crate::{ - api_service::{ + application::api_service::{ ApiHandler, cache::GitObjectCache, history, @@ -38,7 +38,7 @@ use crate::{ git::{CreateEntryInfo, CreateEntryResult, EditFilePayload, EditFileResult}, tag::TagInfo, }, - protocol::repo::Repo, + transport::protocol::repo::Repo, }; #[derive(Clone)] @@ -185,18 +185,18 @@ impl ApiHandler for ImportApiService { .get_tag_by_repo_and_name(self.repo.repo_id, &name) .await { - Ok(Some(_)) => return Err(tag_already_exists(&name)), + Ok(Some(_)) => return Err(tag_already_exists(&name).into()), Ok(None) => {} Err(e) => { tracing::error!("DB error while checking git_tag existence: {}", e); - return Err(db_error()); + return Err(db_error().into()); } } if let Ok(refs) = git_storage.get_ref(self.repo.repo_id).await && refs.iter().any(|r| r.ref_name == full_ref) { - return Err(tag_already_exists(&name)); + return Err(tag_already_exists(&name).into()); } if is_annotated_tag(&message) { @@ -536,12 +536,12 @@ impl ImportApiService { match git_storage.get_commit_by_hash(self.repo.repo_id, t).await { Ok(c) => { if c.is_none() { - return Err(tag_ops::commit_not_found(t)); + return Err(tag_ops::commit_not_found(t).into()); } } Err(e) => { tracing::error!("DB error while fetching commit by hash: {}", e); - return Err(db_error()); + return Err(db_error().into()); } } } diff --git a/ceres/src/application/api_service/mod.rs b/ceres/src/application/api_service/mod.rs index 844793cf7..cf0c4956f 100644 --- a/ceres/src/application/api_service/mod.rs +++ b/ceres/src/application/api_service/mod.rs @@ -18,7 +18,7 @@ use git_internal::{ use jupiter::storage::Storage; use crate::{ - api_service::cache::GitObjectCache, + application::api_service::cache::GitObjectCache, model::{ blame::{BlameQuery, BlameResult}, change_list::MuiTreeNode, @@ -38,13 +38,12 @@ pub mod commit_ops; pub mod history; pub mod import_api_service; pub mod mono; -pub mod state; pub mod tag_ops; pub mod tree_ops; pub use mono::{ - ADMIN_FILE, EffectiveResourcePermission, MonoApiService, MonoServiceLogic, RefUpdate, - TreeUpdateResult, cl_merge, + ADMIN_FILE, EffectiveResourcePermission, MonoApiService, MonoAppServices, MonoServiceLogic, + RefUpdate, TreeUpdateResult, cl_merge, }; #[async_trait] diff --git a/ceres/src/application/api_service/mono/admin/bot.rs b/ceres/src/application/api_service/mono/admin/bot.rs index e2c828461..284d57fd3 100644 --- a/ceres/src/application/api_service/mono/admin/bot.rs +++ b/ceres/src/application/api_service/mono/admin/bot.rs @@ -1,20 +1,133 @@ use callisto::sea_orm_active_enums::{ InstallationBotStatusEnum, PermissionEnum, PermissionScopeEnum, }; +use chrono::Utc; use common::errors::MegaError; +use jupiter::sea_orm::prelude::DateTimeWithTimeZone; -use crate::api_service::mono::MonoApiService; +use crate::{ + application::api_service::mono::MonoApiService, + model::bots::{ + BotRes, ChangeInstallationStatus, CreateBotTokenResponse, InstallBotReq, + InstallationTargetType, ListBotTokenItem, + }, +}; impl MonoApiService { + pub async fn get_bot_by_id( + &self, + bot_id: i64, + ) -> Result, MegaError> { + self.storage.bots_storage().get_bot_by_id(bot_id).await + } + + pub async fn install_bot(&self, bot_id: i64, req: InstallBotReq) -> Result { + let bot = self + .storage + .bots_storage() + .install_bot( + bot_id, + req.target_type.into(), + req.target_id, + req.installed_by, + ) + .await?; + Ok(bot.into()) + } + + pub async fn list_installed_bots(&self, bot_id: i64) -> Result, MegaError> { + Ok(self + .storage + .bots_storage() + .get_installed_bot_by_id(bot_id) + .await? + .into_iter() + .map(|m| m.into()) + .collect()) + } + + pub async fn change_bot_installation_status( + &self, + bot_id: i64, + installation_id: i64, + payload: ChangeInstallationStatus, + ) -> Result { + let model = self + .storage + .bots_storage() + .change_installed_bot_status( + bot_id, + payload.target_type.into(), + installation_id, + payload.status.into(), + ) + .await?; + Ok(model.into()) + } + + pub async fn uninstall_bot( + &self, + bot_id: i64, + target_type: InstallationTargetType, + installation_id: i64, + ) -> Result<(), MegaError> { + self.storage + .bots_storage() + .uninstall_bot(bot_id, target_type.into(), installation_id) + .await + } + + pub async fn generate_bot_token( + &self, + bot_id: i64, + token_name: &str, + expires_at: Option, + ) -> Result { + let (model, token_plain) = self + .storage + .bots_storage() + .generate_bot_token(bot_id, token_name, expires_at) + .await?; + Ok(CreateBotTokenResponse { + id: model.id, + token_name: model.token_name, + expires_at: model.expires_at.map(|dt| dt.with_timezone(&Utc)), + token_plain, + }) + } + + pub async fn list_bot_tokens(&self, bot_id: i64) -> Result, MegaError> { + Ok(self + .storage + .bots_storage() + .list_bot_tokens(bot_id) + .await? + .into_iter() + .map(|t| ListBotTokenItem { + id: t.id, + token_name: t.token_name, + expires_at: t.expires_at.map(|dt| dt.with_timezone(&Utc)), + revoked: t.revoked, + created_at: t.created_at.with_timezone(&Utc), + }) + .collect()) + } + + pub async fn revoke_bot_token(&self, bot_id: i64, token_id: i64) -> Result<(), MegaError> { + self.storage + .bots_storage() + .revoke_bot_token(bot_id, token_id) + .await + } + + pub async fn revoke_all_bot_tokens(&self, bot_id: i64) -> Result<(), MegaError> { + self.storage + .bots_storage() + .revoke_bot_tokens_by_bot(bot_id) + .await + } + /// Check whether a bot has sufficient permission on a given resource. - /// - /// The decision is based on: - /// - Bot status (must be enabled). - /// - Whether the bot has at least one enabled installation record. - /// - The bot-level `permission_scope` compared against `required_permission`. - /// - /// Installation scope is currently checked in an aggregated way (any enabled installation - /// is sufficient) and can be refined later to be resource-type aware. pub async fn check_bot_permission( &self, bot_id: i64, @@ -24,7 +137,6 @@ impl MonoApiService { ) -> Result { let bots_storage = self.storage.bots_storage(); - // 1. Load bot and ensure it is enabled. let bot = match bots_storage.get_bot_by_id(bot_id).await? { Some(b) => b, None => return Ok(false), @@ -34,7 +146,6 @@ impl MonoApiService { return Ok(false); } - // 2. Ensure the bot has at least one enabled installation. let installations = bots_storage.get_installed_bot_by_id(bot_id).await?; let has_enabled_installation = installations .iter() @@ -44,7 +155,6 @@ impl MonoApiService { return Ok(false); } - // 3. Compare bot-level permission scope with the required permission. Ok(scope_satisfies_permission( &bot.permission_scope, &required_permission, diff --git a/ceres/src/application/api_service/mono/admin/group.rs b/ceres/src/application/api_service/mono/admin/group.rs index 1b2ef9a58..0d66bfd91 100644 --- a/ceres/src/application/api_service/mono/admin/group.rs +++ b/ceres/src/application/api_service/mono/admin/group.rs @@ -4,11 +4,70 @@ use callisto::{ sea_orm_active_enums::{PermissionEnum, ResourceTypeEnum}, }; use common::errors::MegaError; -use jupiter::model::group_dto::{ - CreateGroupPayload, DeleteGroupStats, ResourcePermissionBinding, UpdateGroupPayload, +use jupiter::model::group_dto::{DeleteGroupStats, ResourcePermissionBinding}; + +use crate::{ + application::api_service::mono::MonoApiService, + model::group::{ + CreateGroupRequest, PermissionBindingRequest, PermissionValue, ResourceTypeValue, + UpdateGroupRequest, + }, }; -use crate::{api_service::mono::MonoApiService, model::group::ResourceTypeValue}; +fn normalize_optional_description(description: Option) -> Option { + description + .map(|item| item.trim().to_string()) + .and_then(|item| if item.is_empty() { None } else { Some(item) }) +} + +fn validate_group_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(MegaError::bad_request("Group name must not be empty")); + } + if name.len() > 255 { + return Err(MegaError::bad_request( + "Group name must not exceed 255 characters", + )); + } + Ok(name.to_string()) +} + +fn validate_pagination(pagination: &Pagination) -> Result<(), MegaError> { + if pagination.page == 0 { + return Err(MegaError::bad_request("pagination.page must be >= 1")); + } + if pagination.per_page == 0 { + return Err(MegaError::bad_request("pagination.per_page must be >= 1")); + } + Ok(()) +} + +fn create_group_payload(req: CreateGroupRequest) -> jupiter::model::group_dto::CreateGroupPayload { + jupiter::model::group_dto::CreateGroupPayload { + name: req.name, + description: req.description, + } +} + +fn update_group_payload(req: UpdateGroupRequest) -> jupiter::model::group_dto::UpdateGroupPayload { + jupiter::model::group_dto::UpdateGroupPayload { + name: req.name, + description: req.description, + } +} + +fn permission_bindings( + permissions: Vec, +) -> Vec { + permissions + .into_iter() + .map(|item| ResourcePermissionBinding { + group_id: item.group_id, + permission: item.permission.into(), + }) + .collect() +} #[derive(Debug, Clone)] pub struct EffectiveResourcePermission { @@ -19,15 +78,25 @@ pub struct EffectiveResourcePermission { impl MonoApiService { pub async fn create_group( &self, - payload: CreateGroupPayload, + payload: CreateGroupRequest, ) -> Result { - self.storage.group_storage().create_group(payload).await + let name = validate_group_name(&payload.name)?; + let description = normalize_optional_description(payload.description); + + self.storage + .group_storage() + .create_group(create_group_payload(CreateGroupRequest { + name, + description, + })) + .await } pub async fn list_groups( &self, page: Pagination, ) -> Result<(Vec, u64), MegaError> { + validate_pagination(&page)?; self.storage.group_storage().list_groups(page).await } @@ -41,11 +110,16 @@ impl MonoApiService { pub async fn update_group( &self, group_id: i64, - payload: UpdateGroupPayload, + payload: UpdateGroupRequest, ) -> Result { + let name = validate_group_name(&payload.name)?; + let description = normalize_optional_description(payload.description); self.storage .group_storage() - .update_group(group_id, payload) + .update_group( + group_id, + update_group_payload(UpdateGroupRequest { name, description }), + ) .await } @@ -71,6 +145,9 @@ impl MonoApiService { group_id: i64, usernames: Vec, ) -> Result, MegaError> { + if usernames.is_empty() { + return Err(MegaError::bad_request("usernames must not be empty")); + } let group_storage = self.storage.group_storage(); group_storage.add_group_members(group_id, &usernames).await } @@ -95,6 +172,7 @@ impl MonoApiService { group_id: i64, page: Pagination, ) -> Result<(Vec, u64), MegaError> { + validate_pagination(&page)?; let group_storage = self.storage.group_storage(); if group_storage.get_group_by_id(group_id).await?.is_none() { return Err(MegaError::NotFound(format!( @@ -109,11 +187,12 @@ impl MonoApiService { &self, resource_type: ResourceTypeEnum, resource_id: &str, - permissions: Vec, + permissions: Vec, ) -> Result, MegaError> { + let bindings = permission_bindings(permissions); self.storage .group_storage() - .replace_resource_permissions(resource_type, resource_id, &permissions) + .replace_resource_permissions(resource_type, resource_id, &bindings) .await } @@ -132,11 +211,12 @@ impl MonoApiService { &self, resource_type: ResourceTypeEnum, resource_id: &str, - permissions: Vec, + permissions: Vec, ) -> Result, MegaError> { + let bindings = permission_bindings(permissions); self.storage .group_storage() - .upsert_resource_permissions(resource_type, resource_id, &permissions) + .upsert_resource_permissions(resource_type, resource_id, &bindings) .await } @@ -240,17 +320,13 @@ impl MonoApiService { &self, resource_type: &str, resource_id: &str, - ) -> Result<(ResourceTypeEnum, ResourceTypeValue, String), MegaError> { + ) -> Result<(ResourceTypeValue, String), MegaError> { let resource_type_value = ResourceTypeValue::try_from(resource_type) .map_err(|err| MegaError::Other(err.to_string()))?; let validated_resource_id = self .resolve_resource_id(resource_type_value, resource_id) .await?; - Ok(( - resource_type_value.into(), - resource_type_value, - validated_resource_id, - )) + Ok((resource_type_value, validated_resource_id)) } /// Reserved for future business-route authorization integration. @@ -274,6 +350,22 @@ impl MonoApiService { None => false, }) } + + pub async fn check_resource_permission_value( + &self, + username: &str, + resource_type: ResourceTypeValue, + resource_id: &str, + required_permission: PermissionValue, + ) -> Result { + self.check_resource_permission( + username, + resource_type.into(), + resource_id, + required_permission.into(), + ) + .await + } } fn permission_level(permission: &PermissionEnum) -> u8 { @@ -287,3 +379,52 @@ fn permission_level(permission: &PermissionEnum) -> u8 { fn permission_satisfies(current: &PermissionEnum, required: &PermissionEnum) -> bool { permission_level(current) >= permission_level(required) } + +#[cfg(test)] +mod tests { + use callisto::sea_orm_active_enums::PermissionEnum; + + use super::{create_group_payload, permission_bindings, update_group_payload}; + use crate::model::group::{ + CreateGroupRequest, PermissionBindingRequest, PermissionValue, UpdateGroupRequest, + }; + + #[test] + fn create_group_payload_maps_request_fields() { + let payload = create_group_payload(CreateGroupRequest { + name: "admins".to_string(), + description: Some("core".to_string()), + }); + assert_eq!(payload.name, "admins"); + assert_eq!(payload.description.as_deref(), Some("core")); + } + + #[test] + fn update_group_payload_maps_request_fields() { + let payload = update_group_payload(UpdateGroupRequest { + name: "ops".to_string(), + description: None, + }); + assert_eq!(payload.name, "ops"); + assert!(payload.description.is_none()); + } + + #[test] + fn permission_bindings_maps_permissions_and_preserves_order() { + let bindings = permission_bindings(vec![ + PermissionBindingRequest { + group_id: 1, + permission: PermissionValue::Write, + }, + PermissionBindingRequest { + group_id: 2, + permission: PermissionValue::Read, + }, + ]); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].group_id, 1); + assert_eq!(bindings[0].permission, PermissionEnum::Write); + assert_eq!(bindings[1].group_id, 2); + assert_eq!(bindings[1].permission, PermissionEnum::Read); + } +} diff --git a/ceres/src/application/api_service/mono/admin/permissions.rs b/ceres/src/application/api_service/mono/admin/permissions.rs index 8e4210f24..aa84c7d3f 100644 --- a/ceres/src/application/api_service/mono/admin/permissions.rs +++ b/ceres/src/application/api_service/mono/admin/permissions.rs @@ -13,7 +13,7 @@ use common::errors::MegaError; use git_internal::internal::object::tree::Tree; use jupiter::{redis::AsyncCommands, utils::converter::FromMegaModel}; -use crate::api_service::mono::MonoApiService; +use crate::application::api_service::mono::MonoApiService; /// Cache TTL for admin list (10 minutes). pub const ADMIN_CACHE_TTL: u64 = 600; diff --git a/ceres/src/application/api_service/mono/app_services.rs b/ceres/src/application/api_service/mono/app_services.rs new file mode 100644 index 000000000..60b5e88fe --- /dev/null +++ b/ceres/src/application/api_service/mono/app_services.rs @@ -0,0 +1,61 @@ +//! Domain-scoped accessors for [`MonoApiService`] (gradual split entry point). + +use std::sync::Arc; + +use jupiter::storage::Storage; + +use super::service::MonoApiService; +use crate::infra::TransportContext; + +/// Bundles monorepo application services for injection into HTTP handlers. +#[derive(Clone)] +pub struct MonoAppServices { + inner: MonoApiService, +} + +impl MonoAppServices { + pub fn new( + storage: Storage, + git_object_cache: Arc, + ) -> Self { + Self { + inner: MonoApiService::new(TransportContext::new(storage, git_object_cache)), + } + } + + pub fn monorepo(&self) -> &MonoApiService { + &self.inner + } + + pub fn cl(&self) -> &MonoApiService { + &self.inner + } + + pub fn issue(&self) -> &MonoApiService { + &self.inner + } + + pub fn conversation(&self) -> &MonoApiService { + &self.inner + } + + pub fn admin(&self) -> &MonoApiService { + &self.inner + } + + pub fn user(&self) -> &MonoApiService { + &self.inner + } +} + +impl From for MonoApiService { + fn from(services: MonoAppServices) -> Self { + services.inner + } +} + +impl From<&MonoAppServices> for MonoApiService { + fn from(services: &MonoAppServices) -> Self { + services.inner.clone() + } +} diff --git a/ceres/src/application/api_service/mono/buck/upload.rs b/ceres/src/application/api_service/mono/buck/upload.rs index 1a8ebba14..d55811f51 100644 --- a/ceres/src/application/api_service/mono/buck/upload.rs +++ b/ceres/src/application/api_service/mono/buck/upload.rs @@ -15,11 +15,13 @@ use jupiter::{ use orion_client::OrionBuildClient; use crate::{ - api_service::{ - buck_tree_builder::BuckCommitBuilder, - mono::{MonoApiService, MonoServiceLogic}, + application::{ + api_service::{ + buck_tree_builder::BuckCommitBuilder, + mono::{MonoApiService, MonoServiceLogic}, + }, + build_trigger::{BuildTriggerService, TriggerContext}, }, - build_trigger::{BuildTriggerService, TriggerContext}, model::buck::{ CompletePayload, CompleteResponse, DEFAULT_MODE, FileChange, FileToUpload as ApiFileToUpload, ManifestPayload, ManifestResponse, @@ -60,6 +62,10 @@ impl MonoApiService { username: &str, path: &str, ) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(MegaError::bad_request("Path cannot be empty")); + } let normalized_path = MonoServiceLogic::normalize_repo_path(path)?; let refs = self .storage @@ -124,7 +130,7 @@ impl MonoApiService { // Get content hashes (raw SHA-1) and blob IDs let (existing_file_hashes, existing_blob_ids_map) = - crate::api_service::blob_ops::get_files_content_hashes_with_blob_ids( + crate::application::api_service::blob_ops::get_files_content_hashes_with_blob_ids( self, &manifest_paths, session.from_hash.as_deref(), diff --git a/ceres/src/application/api_service/mono/cl/branch.rs b/ceres/src/application/api_service/mono/cl/branch.rs index 4e69b4506..70fa1fffa 100644 --- a/ceres/src/application/api_service/mono/cl/branch.rs +++ b/ceres/src/application/api_service/mono/cl/branch.rs @@ -20,11 +20,13 @@ use jupiter::utils::converter::FromMegaModel; use tracing::debug; use crate::{ - api_service::mono::{ - MonoApiService, - types::{ApplyChangeContext, RefUpdate, TreeUpdateResult}, + application::{ + api_service::mono::{ + MonoApiService, + types::{ApplyChangeContext, RefUpdate, TreeUpdateResult}, + }, + code_edit::utils as edit_utils, }, - code_edit::utils as edit_utils, model::change_list::{ClDiffFile, UpdateBranchStatusRes}, }; @@ -463,8 +465,10 @@ impl MonoApiService { let main_ref = match self.storage.mono_storage().get_main_ref(&cl.path).await? { Some(r) => r, - None if crate::api_service::mono::cl_merge::path_lacks_main_ref(self, &cl.path) - .await? => + None if crate::application::api_service::mono::cl_merge::path_lacks_main_ref( + self, &cl.path, + ) + .await? => { return Ok(UpdateBranchStatusRes { base_commit: cl.from_hash.clone(), diff --git a/ceres/src/application/api_service/mono/cl/diff.rs b/ceres/src/application/api_service/mono/cl/diff.rs index 800d8c26d..fbd000f65 100644 --- a/ceres/src/application/api_service/mono/cl/diff.rs +++ b/ceres/src/application/api_service/mono/cl/diff.rs @@ -14,7 +14,7 @@ use git_internal::{ use jupiter::utils::converter::FromMegaModel; use crate::{ - api_service::{ApiHandler, mono::MonoApiService}, + application::api_service::{ApiHandler, mono::MonoApiService}, diff::tree_diff, model::change_list::{ClDiffFile, ClFilesChangedItemSchema}, }; @@ -546,7 +546,7 @@ mod tests { use git_internal::{DiffItem, hash::ObjectHash}; use super::collect_page_blobs; - use crate::{api_service::mono::MonoApiService, model::change_list::ClDiffFile}; + use crate::{application::api_service::mono::MonoApiService, model::change_list::ClDiffFile}; #[test] fn test_paging_calculation_basic() { diff --git a/ceres/src/application/api_service/mono/cl/lifecycle.rs b/ceres/src/application/api_service/mono/cl/lifecycle.rs index c1d66ce6d..0a1ec6cdc 100644 --- a/ceres/src/application/api_service/mono/cl/lifecycle.rs +++ b/ceres/src/application/api_service/mono/cl/lifecycle.rs @@ -7,8 +7,10 @@ use common::errors::MegaError; use jupiter::model::cl_dto::CLDetails; use crate::{ - api_service::mono::MonoApiService, - application::webhook::{WebhookEvent, dispatch_cl_webhook}, + application::{ + api_service::mono::MonoApiService, + webhook::{WebhookEvent, dispatch_cl_webhook}, + }, model::change_list::{CLDetailRes, Condition, MergeBoxRes, UpdateClStatusPayload}, }; @@ -24,7 +26,7 @@ impl MonoApiService { let (cl, labels) = cl_storage .get_cl_labels(link) .await? - .ok_or_else(|| MegaError::Other("CL not found".to_string()))?; + .ok_or_else(|| MegaError::NotFound("CL not found".to_string()))?; let conversations = conversation_storage .get_comments_with_reactions(link) @@ -50,7 +52,7 @@ impl MonoApiService { let model = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; if model.status != MergeStatusEnum::Closed { return Ok(()); @@ -79,7 +81,7 @@ impl MonoApiService { let model = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; if !matches!(model.status, MergeStatusEnum::Open | MergeStatusEnum::Draft) { return Ok(()); @@ -108,10 +110,10 @@ impl MonoApiService { let model = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; if model.status == MergeStatusEnum::Draft { - return Err(MegaError::Other("CL is not ready for review".to_owned())); + return Err(MegaError::bad_request("CL is not ready for review")); } if model.status == MergeStatusEnum::Open { @@ -128,10 +130,10 @@ impl MonoApiService { let model = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("CL Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; if model.status != MergeStatusEnum::Open { - return Err(MegaError::Other(format!( + return Err(MegaError::bad_request(format!( "CL is not in Open status, current status: {:?}", model.status ))); @@ -149,7 +151,7 @@ impl MonoApiService { let cl = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("CL Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; let res = match cl.status { MergeStatusEnum::Open => { @@ -220,14 +222,14 @@ impl MonoApiService { let model = cl_storage .get_cl(link) .await? - .ok_or(MegaError::Other("Not Found".to_string()))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {link}")))?; let new_status = match payload.status.to_lowercase().as_str() { "draft" => MergeStatusEnum::Draft, "open" => MergeStatusEnum::Open, _ => { - return Err(MegaError::Other( - "Invalid status. Only 'draft' and 'open' are supported".to_string(), + return Err(MegaError::bad_request( + "Invalid status. Only 'draft' and 'open' are supported", )); } }; @@ -268,8 +270,8 @@ impl MonoApiService { } } _ => { - return Err(MegaError::Other( - "Invalid status transition. Only Draft ↔ Open is allowed".to_string(), + return Err(MegaError::bad_request( + "Invalid status transition. Only Draft ↔ Open is allowed", )); } } diff --git a/ceres/src/application/api_service/mono/cl/merge.rs b/ceres/src/application/api_service/mono/cl/merge.rs index 649435f18..8c9d54f44 100644 --- a/ceres/src/application/api_service/mono/cl/merge.rs +++ b/ceres/src/application/api_service/mono/cl/merge.rs @@ -17,11 +17,13 @@ use orion_client::OrionBuildClient; use tracing::debug; use crate::{ - api_service::{ - ApiHandler, - mono::{MonoApiService, logic::MonoServiceLogic, types::TreeUpdateResult}, + application::{ + api_service::{ + ApiHandler, + mono::{MonoApiService, logic::MonoServiceLogic, types::TreeUpdateResult}, + }, + code_edit::on_edit::OneditCodeEdit, }, - code_edit::on_edit::OneditCodeEdit, merge_checker::CheckerRegistry, }; @@ -113,7 +115,7 @@ impl MonoApiService { Ok(()) } pub async fn merge_cl(&self, username: &str, mut cl: mega_cl::Model) -> Result<(), GitError> { - crate::api_service::mono::cl_merge::prepare_cl_path_for_merge(self, &mut cl) + crate::application::api_service::mono::cl_merge::prepare_cl_path_for_merge(self, &mut cl) .await .map_err(|e| GitError::CustomError(e.to_string()))?; @@ -147,9 +149,10 @@ impl MonoApiService { ) -> Result<(), GitError> { let storage = self.storage.mono_storage(); - let strategy = crate::api_service::mono::cl_merge::resolve_merge_strategy(self, &cl) - .await - .map_err(|e| GitError::CustomError(e.to_string()))?; + let strategy = + crate::application::api_service::mono::cl_merge::resolve_merge_strategy(self, &cl) + .await + .map_err(|e| GitError::CustomError(e.to_string()))?; tracing::info!( cl_link = %cl.link, cl_path = %cl.path, @@ -165,14 +168,14 @@ impl MonoApiService { let parent = path.parent().ok_or_else(|| { GitError::CustomError(format!("Invalid CL path: {}", normalized_path)) })?; - if crate::api_service::mono::cl_merge::needs_path_tree_attach(self, &path) + if crate::application::api_service::mono::cl_merge::needs_path_tree_attach(self, &path) .await .map_err(|e| GitError::CustomError(e.to_string()))? { self.attach_project_path_to_monorepo_root(&normalized_path) .await .map_err(|e| GitError::CustomError(e.to_string()))?; - crate::api_service::mono::cl_merge::sync_path_prefix_main_refs( + crate::application::api_service::mono::cl_merge::sync_path_prefix_main_refs( self, &normalized_path, ) @@ -184,8 +187,10 @@ impl MonoApiService { }; let leaf_tree_id = - crate::api_service::mono::cl_merge::resolve_merge_leaf_tree_id(self, &cl, strategy) - .await?; + crate::application::api_service::mono::cl_merge::resolve_merge_leaf_tree_id( + self, &cl, strategy, + ) + .await?; let result = MonoServiceLogic::build_result_by_chain(path, update_chain, leaf_tree_id)?; self.apply_update_result(&result, "cl merge generated commit", Some(cl.link.as_str())) .await?; @@ -214,7 +219,7 @@ impl MonoApiService { if let Ok(files) = self.get_sorted_changed_file_list(&cl.link, None).await { let admin_file_modified = files.iter().any(|file| { let normalized = file.replace('\\', "/"); - normalized.ends_with(crate::api_service::mono::ADMIN_FILE) + normalized.ends_with(crate::application::api_service::mono::ADMIN_FILE) }); if admin_file_modified { self.invalidate_admin_cache().await; diff --git a/ceres/src/application/api_service/mono/cl/merge_strategy.rs b/ceres/src/application/api_service/mono/cl/merge_strategy.rs index 1970faba9..fd94b4f32 100644 --- a/ceres/src/application/api_service/mono/cl/merge_strategy.rs +++ b/ceres/src/application/api_service/mono/cl/merge_strategy.rs @@ -7,7 +7,7 @@ use common::{errors::MegaError, utils::ZERO_ID}; use git_internal::{errors::GitError, internal::object::commit::Commit}; use jupiter::utils::converter::FromMegaModel; -use crate::api_service::{mono::MonoApiService, tree_ops}; +use crate::application::api_service::{mono::MonoApiService, tree_ops}; /// How a CL should be applied onto monorepo main. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -73,7 +73,9 @@ pub async fn sync_path_prefix_main_refs( path: &str, ) -> Result<(), MegaError> { for prefix in path_prefixes(path) { - let hash = crate::code_edit::utils::create_repo_commit(&service.storage, &prefix).await?; + let hash = + crate::application::code_edit::utils::create_repo_commit(&service.storage, &prefix) + .await?; if hash == ZERO_ID { return Err(MegaError::Other(format!( "Failed to sync main ref for prefix {prefix}" @@ -106,7 +108,8 @@ pub async fn bootstrap_monorepo_path( service.attach_project_path_to_monorepo_root(path).await?; sync_path_prefix_main_refs(service, path).await?; - let baseline_hash = crate::code_edit::utils::create_repo_commit(&service.storage, path).await?; + let baseline_hash = + crate::application::code_edit::utils::create_repo_commit(&service.storage, path).await?; if baseline_hash == ZERO_ID { return Err(MegaError::Other(format!( "Failed to create main ref baseline for {path}" @@ -258,7 +261,7 @@ pub async fn resolve_merge_leaf_tree_id( #[cfg(test)] mod tests { - use super::path_prefixes; + use super::{ClMergeStrategy, path_prefixes}; #[test] fn path_prefixes_returns_strict_prefixes() { @@ -266,4 +269,10 @@ mod tests { assert_eq!(path_prefixes("/project"), Vec::::new()); assert_eq!(path_prefixes("/"), Vec::::new()); } + + #[test] + fn cl_merge_strategy_as_str() { + assert_eq!(ClMergeStrategy::FileDiff.as_str(), "file_diff"); + assert_eq!(ClMergeStrategy::SubtreeReplace.as_str(), "subtree_replace"); + } } diff --git a/ceres/src/application/api_service/mono/cl/queue.rs b/ceres/src/application/api_service/mono/cl/queue.rs index 16c467ed9..33a2253aa 100644 --- a/ceres/src/application/api_service/mono/cl/queue.rs +++ b/ceres/src/application/api_service/mono/cl/queue.rs @@ -7,7 +7,7 @@ use common::errors::MegaError; use tracing; use crate::{ - api_service::mono::MonoApiService, + application::api_service::mono::MonoApiService, model::merge_queue::{ AddToQueueResponse, QueueItem, QueueListResponse, QueueStatsResponse, QueueStatus, QueueStatusResponse, diff --git a/ceres/src/application/api_service/mono/cl_list.rs b/ceres/src/application/api_service/mono/cl_list.rs new file mode 100644 index 000000000..1acbf442a --- /dev/null +++ b/ceres/src/application/api_service/mono/cl_list.rs @@ -0,0 +1,31 @@ +use api_model::common::{CommonPage, Pagination}; +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::{change_list::ListPayload, issue::ItemRes}; + +impl MonoApiService { + pub async fn get_cl_list( + &self, + filter: ListPayload, + pagination: Pagination, + ) -> Result, MegaError> { + let (items, total) = self + .storage + .cl_storage() + .get_cl_list(filter.into(), pagination) + .await?; + Ok(CommonPage { + items: items.into_iter().map(|m| m.into()).collect(), + total, + }) + } + + pub async fn get_cl_model(&self, link: &str) -> Result { + self.storage + .cl_storage() + .get_cl(link) + .await? + .ok_or_else(|| MegaError::NotFound(format!("CL {link} not found"))) + } +} diff --git a/ceres/src/application/api_service/mono/cla.rs b/ceres/src/application/api_service/mono/cla.rs index 4d7d0d737..d6bf8b67f 100644 --- a/ceres/src/application/api_service/mono/cla.rs +++ b/ceres/src/application/api_service/mono/cla.rs @@ -5,7 +5,7 @@ use common::errors::MegaError; use futures::{StreamExt, stream}; use io_orbit::object_storage::{ObjectKey, ObjectMeta, ObjectNamespace}; -use crate::{api_service::mono::MonoApiService, merge_checker::CheckerRegistry}; +use crate::{application::api_service::mono::MonoApiService, merge_checker::CheckerRegistry}; const CLA_CONTENT_OBJECT_KEY: &str = "cla/content/current.txt"; diff --git a/ceres/src/application/api_service/mono/code_review.rs b/ceres/src/application/api_service/mono/code_review.rs new file mode 100644 index 000000000..910e8df4e --- /dev/null +++ b/ceres/src/application/api_service/mono/code_review.rs @@ -0,0 +1,149 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::code_review::{ + CodeReviewResponse, CommentReplyRequest, CommentReviewResponse, InitializeCommentRequest, + ThreadReviewResponse, ThreadStatusResponse, UpdateCommentRequest, +}; + +impl MonoApiService { + pub async fn get_code_review_comments( + &self, + link: &str, + ) -> Result { + let comments = self + .storage + .code_review_service + .get_all_comments_by_link(link) + .await?; + Ok(comments.into()) + } + + pub async fn create_code_review_comment( + &self, + link: &str, + username: String, + payload: InitializeCommentRequest, + ) -> Result { + let thread = self + .storage + .code_review_service + .create_inline_comment( + link, + &payload.file_path, + payload.diff_side.into(), + &payload.anchor_commit_sha, + payload.original_line_number, + &payload.normalized_content, + &payload.context_before, + &payload.context_after, + username, + payload.content, + ) + .await?; + Ok(thread.into()) + } + + pub async fn reply_code_review_comment( + &self, + thread_id: i64, + username: String, + payload: CommentReplyRequest, + ) -> Result { + let comment = self + .storage + .code_review_service + .reply_to_comment( + thread_id, + payload.parent_comment_id, + username, + payload.content, + ) + .await?; + Ok(comment.into()) + } + + pub async fn update_code_review_comment( + &self, + comment_id: i64, + username: &str, + payload: UpdateCommentRequest, + ) -> Result { + let comment = self + .storage + .code_review_comment_storage() + .find_comment_by_id(comment_id) + .await? + .ok_or_else(|| MegaError::NotFound("Comment not found".to_string()))?; + + if comment.user_name != username { + return Err(MegaError::Other( + "Cannot update others' comments".to_string(), + )); + } + + let updated = self + .storage + .code_review_service + .update_comment(comment_id, payload.content) + .await?; + Ok(updated.into()) + } + + pub async fn resolve_code_review_thread( + &self, + thread_id: i64, + ) -> Result { + let thread = self + .storage + .code_review_service + .resolve_thread(thread_id) + .await?; + Ok(thread.into()) + } + + pub async fn reopen_code_review_thread( + &self, + thread_id: i64, + ) -> Result { + let thread = self + .storage + .code_review_service + .reopen_thread(thread_id) + .await?; + Ok(thread.into()) + } + + pub async fn delete_code_review_thread(&self, thread_id: i64) -> Result<(), MegaError> { + self.storage + .code_review_service + .delete_thread(thread_id) + .await?; + Ok(()) + } + + pub async fn delete_code_review_comment( + &self, + comment_id: i64, + username: &str, + ) -> Result<(), MegaError> { + let comment = self + .storage + .code_review_comment_storage() + .find_comment_by_id(comment_id) + .await? + .ok_or_else(|| MegaError::NotFound("Comment not found".to_string()))?; + + if comment.user_name != username { + return Err(MegaError::Other( + "Cannot update others' comments".to_string(), + )); + } + + self.storage + .code_review_service + .delete_comment(comment_id) + .await?; + Ok(()) + } +} diff --git a/ceres/src/application/api_service/mono/commit.rs b/ceres/src/application/api_service/mono/commit.rs new file mode 100644 index 000000000..ea334db7e --- /dev/null +++ b/ceres/src/application/api_service/mono/commit.rs @@ -0,0 +1,99 @@ +use std::path::{Path as StdPath, PathBuf}; + +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::commit::CommitBindingResponse; + +impl MonoApiService { + pub async fn upsert_commit_binding( + &self, + sha: &str, + username: Option, + is_anonymous: bool, + ) -> Result { + let final_username = if is_anonymous { + None + } else { + username.and_then(|u| { + let t = u.trim(); + if t.is_empty() || t.eq_ignore_ascii_case("anonymous") { + None + } else { + Some(t.to_string()) + } + }) + }; + + self.storage + .commit_binding_storage() + .upsert_binding(sha, final_username.clone(), final_username.is_none()) + .await?; + + Ok(CommitBindingResponse { + username: final_username, + }) + } + + pub fn import_dir(&self) -> PathBuf { + self.storage.config().monorepo.import_dir.clone() + } + + pub async fn find_git_repo_like_path( + &self, + path: &str, + ) -> Result, MegaError> { + self.storage + .git_db_storage() + .find_git_repo_like_path(path) + .await + } + + pub async fn resolve_target_commit_id( + &self, + path_context: Option<&str>, + target_opt: Option<&str>, + ) -> Result { + if let Some(t) = target_opt + && t != "HEAD" + && !t.is_empty() + { + return Ok(t.to_string()); + } + + let import_dir = self.import_dir(); + if let Some(path) = path_context { + let std_path = StdPath::new(path); + if std_path.starts_with(&import_dir) && std_path != StdPath::new(&import_dir) { + if let Some(repo_model) = self.find_git_repo_like_path(path).await? { + let git = self.storage.git_db_storage(); + if let Ok(Some(r)) = git.get_default_ref(repo_model.id).await { + return Ok(r.ref_git_id); + } + if let Ok(refs) = git.get_ref(repo_model.id).await + && let Some(r) = refs.into_iter().next() + { + return Ok(r.ref_git_id); + } + return Ok("HEAD".to_string()); + } + } else { + let mono = self.storage.mono_storage(); + let resolved_path = path_context.unwrap_or("/"); + if let Ok(Some(r)) = mono.get_main_ref(resolved_path).await { + return Ok(r.ref_commit_hash); + } + if let Ok(Some(root_ref)) = mono.get_main_ref("/").await { + return Ok(root_ref.ref_commit_hash); + } + return Ok("HEAD".to_string()); + } + } + + let mono = self.storage.mono_storage(); + if let Ok(Some(root_ref)) = mono.get_main_ref("/").await { + return Ok(root_ref.ref_commit_hash); + } + Ok("HEAD".to_string()) + } +} diff --git a/ceres/src/application/api_service/mono/conversation.rs b/ceres/src/application/api_service/mono/conversation.rs new file mode 100644 index 000000000..b1629d8b3 --- /dev/null +++ b/ceres/src/application/api_service/mono/conversation.rs @@ -0,0 +1,107 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::{ + change_list::MergeStatus, + conversation::{ConvType, ReferenceType}, +}; + +impl MonoApiService { + pub async fn add_conversation( + &self, + link: &str, + username: &str, + comment: Option, + conv_type: ConvType, + ) -> Result { + self.storage + .conversation_storage() + .add_conversation(link, username, comment, conv_type.into()) + .await + } + + pub async fn add_issue_mention_reference( + &self, + source_link: &str, + ref_link: &str, + username: &str, + ) -> Result<(), MegaError> { + self.storage + .issue_storage() + .add_reference(source_link, ref_link, ReferenceType::Mention.into()) + .await?; + self.add_conversation( + ref_link, + username, + Some(format!("{username} mentioned this on")), + ConvType::Mention, + ) + .await?; + Ok(()) + } + + pub async fn cl_merge_status(&self, link: &str) -> Result { + let model = self + .storage + .cl_storage() + .get_cl(link) + .await? + .ok_or_else(|| MegaError::NotFound(format!("CL {link} not found")))?; + Ok(model.status.into()) + } + + pub async fn add_comment_reaction( + &self, + content: Option, + comment_id: i64, + comment_type: &str, + username: &str, + ) -> Result<(), MegaError> { + self.storage + .conversation_storage() + .add_reactions(content, comment_id, comment_type, username) + .await?; + Ok(()) + } + + pub async fn delete_comment_reaction( + &self, + reaction_id: &str, + username: &str, + ) -> Result<(), MegaError> { + self.storage + .conversation_storage() + .delete_reaction(reaction_id, username) + .await + } + + pub async fn remove_conversation(&self, comment_id: i64) -> Result<(), MegaError> { + self.storage + .conversation_storage() + .remove_conversation(comment_id) + .await + } + + pub async fn update_comment( + &self, + comment_id: i64, + content: Option, + ) -> Result<(), MegaError> { + self.storage + .conversation_storage() + .update_comment(comment_id, content) + .await + } + + pub async fn change_review_state( + &self, + link: &str, + conversation_id: &i64, + resolved: bool, + ) -> Result<(), MegaError> { + self.storage + .conversation_storage() + .change_review_state(link, conversation_id, resolved) + .await + } +} diff --git a/ceres/src/application/api_service/mono/dynamic_sidebar.rs b/ceres/src/application/api_service/mono/dynamic_sidebar.rs new file mode 100644 index 000000000..338d4b0a3 --- /dev/null +++ b/ceres/src/application/api_service/mono/dynamic_sidebar.rs @@ -0,0 +1,71 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::dynamic_sidebar::{SidebarMenuListRes, SidebarRes, SidebarSyncPayload}; + +impl MonoApiService { + pub async fn list_sidebars(&self) -> Result { + Ok(self + .storage + .dynamic_sidebar_storage() + .get_sidebars() + .await? + .into_iter() + .map(|m| m.into()) + .collect()) + } + + pub async fn new_sidebar( + &self, + public_id: String, + label: String, + href: String, + visible: bool, + order_index: i32, + ) -> Result { + let res = self + .storage + .dynamic_sidebar_storage() + .new_sidebar(public_id, label, href, visible, order_index) + .await?; + Ok(res.into()) + } + + pub async fn update_sidebar( + &self, + id: i32, + public_id: Option, + label: Option, + href: Option, + visible: Option, + order_index: Option, + ) -> Result { + let res = self + .storage + .dynamic_sidebar_storage() + .update_sidebar(id, public_id, label, href, visible, order_index) + .await?; + Ok(res.into()) + } + + pub async fn sync_sidebars( + &self, + payloads: Vec, + ) -> Result, MegaError> { + let res = self + .storage + .dynamic_sidebar_storage() + .sync_sidebar(payloads.into_iter().map(|item| item.into()).collect()) + .await?; + Ok(res.into_iter().map(|item| item.into()).collect()) + } + + pub async fn delete_sidebar(&self, id: i32) -> Result { + let res = self + .storage + .dynamic_sidebar_storage() + .delete_sidebar(id) + .await?; + Ok(res.into()) + } +} diff --git a/ceres/src/application/api_service/mono/edit/entry.rs b/ceres/src/application/api_service/mono/edit/entry.rs index 15cd6aff6..a7ba6ca78 100644 --- a/ceres/src/application/api_service/mono/edit/entry.rs +++ b/ceres/src/application/api_service/mono/edit/entry.rs @@ -25,16 +25,18 @@ use jupiter::{ }; use crate::{ - api_service::{ - ApiHandler, - mono::{ - MonoApiService, - logic::{MonoServiceLogic, path_not_exist_re}, - types::{CreateEntryUpdate, TreeUpdateResult}, + application::{ + api_service::{ + ApiHandler, + mono::{ + MonoApiService, + logic::{MonoServiceLogic, path_not_exist_re}, + types::{CreateEntryUpdate, TreeUpdateResult}, + }, + tree_ops, }, - tree_ops, + code_edit::{on_edit::OneditCodeEdit, utils as edit_utils}, }, - code_edit::{on_edit::OneditCodeEdit, utils as edit_utils}, model::git::{CreateEntryInfo, CreateEntryResult, EditFilePayload, EditFileResult}, }; @@ -610,7 +612,7 @@ mod tests { use git_internal::hash::ObjectHash; - use crate::api_service::mono::{ + use crate::application::api_service::mono::{ MonoApiService, types::{RefUpdate, TreeUpdateResult}, }; diff --git a/ceres/src/application/api_service/mono/gpg.rs b/ceres/src/application/api_service/mono/gpg.rs new file mode 100644 index 000000000..585b4cc51 --- /dev/null +++ b/ceres/src/application/api_service/mono/gpg.rs @@ -0,0 +1,33 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::gpg::GpgKey; + +impl MonoApiService { + pub async fn add_gpg_key(&self, user_id: String, gpg_content: String) -> Result<(), MegaError> { + self.storage + .gpg_storage() + .add_gpg_key(user_id, gpg_content) + .await + } + + pub async fn remove_gpg_key(&self, user_id: String, key_id: String) -> Result<(), MegaError> { + self.storage + .gpg_storage() + .remove_gpg_key(user_id, key_id) + .await + } + + pub async fn list_user_gpg_keys(&self, user_id: String) -> Result, MegaError> { + let raw_keys = self + .storage + .gpg_storage() + .list_user_gpg(user_id.clone()) + .await; + Ok(raw_keys + .into_iter() + .flatten() + .map(|k| GpgKey::from_stored(user_id.clone(), k)) + .collect()) + } +} diff --git a/ceres/src/application/api_service/mono/issue.rs b/ceres/src/application/api_service/mono/issue.rs new file mode 100644 index 000000000..84be0e070 --- /dev/null +++ b/ceres/src/application/api_service/mono/issue.rs @@ -0,0 +1,104 @@ +use api_model::common::Pagination; +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::{ + change_list::ListPayload, + issue::{IssueDetailRes, IssueSuggestions, ItemRes}, + label::LabelItem, +}; + +impl MonoApiService { + pub async fn get_issue_details( + &self, + link: &str, + username: String, + ) -> Result { + let details = self + .storage + .issue_service + .get_issue_details(link, username) + .await?; + Ok(details.into()) + } + + pub async fn get_issue_suggestions( + &self, + query: &str, + ) -> Result, MegaError> { + let (issues, cls) = self.storage.issue_service.get_suggestions(query).await?; + let mut res: Vec = issues.into_iter().map(|m| m.into()).collect(); + let mut mr_list: Vec = cls.into_iter().map(|m| m.into()).collect(); + res.append(&mut mr_list); + res.sort(); + Ok(res) + } + + pub async fn get_issue_list( + &self, + filter: ListPayload, + pagination: Pagination, + ) -> Result<(Vec, u64), MegaError> { + let (items, total) = self + .storage + .issue_storage() + .get_issue_list(filter.into(), pagination) + .await?; + Ok((items.into_iter().map(|m| m.into()).collect(), total)) + } + + pub async fn save_issue( + &self, + username: &str, + title: &str, + ) -> Result { + self.storage + .issue_storage() + .save_issue(username, title) + .await + } + + pub async fn close_issue(&self, link: &str) -> Result<(), MegaError> { + self.storage.issue_storage().close_issue(link).await + } + + pub async fn reopen_issue(&self, link: &str) -> Result<(), MegaError> { + self.storage.issue_storage().reopen_issue(link).await + } + + pub async fn edit_issue_title(&self, link: &str, title: &str) -> Result<(), MegaError> { + self.storage.issue_storage().edit_title(link, title).await + } + + pub async fn list_labels_by_page( + &self, + pagination: Pagination, + query: &str, + ) -> Result<(Vec, u64), MegaError> { + let (items, total) = self + .storage + .issue_storage() + .list_labels_by_page(pagination, query) + .await?; + Ok((items.into_iter().map(|m| m.into()).collect(), total)) + } + + pub async fn new_label( + &self, + name: &str, + color: &str, + description: &str, + ) -> Result { + let model = self + .storage + .issue_storage() + .new_label(name, color, description) + .await?; + Ok(model.into()) + } + + pub async fn get_label_by_id(&self, id: i64) -> Result, MegaError> { + let label = self.storage.issue_storage().get_label_by_id(id).await?; + Ok(label.map(|m| m.into())) + } +} diff --git a/ceres/src/application/api_service/mono/label_assignee.rs b/ceres/src/application/api_service/mono/label_assignee.rs new file mode 100644 index 000000000..19e62dae4 --- /dev/null +++ b/ceres/src/application/api_service/mono/label_assignee.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; + +use callisto::sea_orm_active_enums::ConvTypeEnum; +use common::errors::MegaError; +use jupiter::model::common::LabelAssigneeParams; + +use crate::application::api_service::mono::MonoApiService; + +impl MonoApiService { + pub async fn update_item_labels( + &self, + username: &str, + item_id: i64, + item_type: &str, + label_ids: Vec, + link: &str, + ) -> Result<(), MegaError> { + let issue_storage = self.storage.issue_storage(); + + let old_labels = issue_storage.find_item_exist_labels(item_id).await?; + let old_ids: HashSet = old_labels.iter().map(|l| l.label_id).collect(); + let new_ids: HashSet = label_ids.iter().copied().collect(); + + let to_add: Vec = new_ids.difference(&old_ids).copied().collect(); + let to_remove: Vec = old_ids.difference(&new_ids).copied().collect(); + + let params = LabelAssigneeParams { + item_id, + item_type: item_type.to_string(), + }; + + issue_storage + .modify_labels(to_add.clone(), to_remove.clone(), params) + .await?; + + if !to_remove.is_empty() { + self.storage + .conversation_storage() + .add_conversation( + link, + username, + Some(format!("{username} removed {to_remove:?}")), + ConvTypeEnum::Label, + ) + .await?; + } + + if !to_add.is_empty() { + self.storage + .conversation_storage() + .add_conversation( + link, + username, + Some(format!("{username} added {to_add:?}")), + ConvTypeEnum::Label, + ) + .await?; + } + + Ok(()) + } + + pub async fn update_item_assignees( + &self, + username: &str, + item_id: i64, + item_type: &str, + assignees: Vec, + link: &str, + ) -> Result<(), MegaError> { + let issue_storage = self.storage.issue_storage(); + + 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 new_ids: HashSet = assignees.iter().cloned().collect(); + + let to_add: Vec = new_ids.difference(&old_ids).cloned().collect(); + let to_remove: Vec = old_ids.difference(&new_ids).cloned().collect(); + + let params = LabelAssigneeParams { + item_id, + item_type: item_type.to_string(), + }; + + issue_storage + .modify_assignees(to_add.clone(), to_remove.clone(), params) + .await?; + + if !to_remove.is_empty() { + self.storage + .conversation_storage() + .add_conversation( + link, + username, + Some(format!("{username} unassigned {to_remove:?}")), + ConvTypeEnum::Assignee, + ) + .await?; + } + + if !to_add.is_empty() { + self.storage + .conversation_storage() + .add_conversation( + link, + username, + Some(format!("{username} assigned {to_add:?}")), + ConvTypeEnum::Assignee, + ) + .await?; + } + + Ok(()) + } +} diff --git a/ceres/src/application/api_service/mono/logic/path.rs b/ceres/src/application/api_service/mono/logic/path.rs index 0d9a4d558..b9dcafcbf 100644 --- a/ceres/src/application/api_service/mono/logic/path.rs +++ b/ceres/src/application/api_service/mono/logic/path.rs @@ -142,7 +142,11 @@ impl MonoServiceLogic { mod tests { use std::path::{Path, PathBuf}; - use common::errors::{BuckError, MegaError}; + use bytes::Bytes; + use common::{ + errors::{BuckError, MegaError}, + utils::ZERO_ID, + }; use super::MonoServiceLogic; @@ -292,4 +296,48 @@ mod tests { println!("name: {name}, path: {full_path:?}"); } } + + #[test] + fn validate_github_sync_path_accepts_project_and_third_party_subdirs() { + assert_eq!( + MonoServiceLogic::validate_github_sync_path("/project/foo").unwrap(), + "/project/foo" + ); + assert_eq!( + MonoServiceLogic::validate_github_sync_path("third-party/bar/").unwrap(), + "/third-party/bar" + ); + } + + #[test] + fn validate_github_sync_path_rejects_root_and_invalid_prefix() { + assert!(MonoServiceLogic::validate_github_sync_path("/project").is_err()); + assert!(MonoServiceLogic::validate_github_sync_path("/third-party").is_err()); + assert!(MonoServiceLogic::validate_github_sync_path("/other/x").is_err()); + } + + #[test] + fn receive_pack_report_failed_detects_ng_status() { + assert!(MonoServiceLogic::receive_pack_report_failed(&Bytes::from( + &b"unpack ok\nng refs/heads/main only single commit support\n"[..] + ))); + assert!(!MonoServiceLogic::receive_pack_report_failed(&Bytes::from( + &b"unpack ok\nok refs/heads/main\n"[..] + ))); + } + + #[test] + fn is_new_directory_cl_matches_zero_id() { + assert!(MonoServiceLogic::is_new_directory_cl(ZERO_ID)); + assert!(!MonoServiceLogic::is_new_directory_cl( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + } + + #[test] + fn sync_old_id_path_routing_import_vs_monorepo() { + let import_dir = PathBuf::from("/third-party"); + assert!(PathBuf::from("/third-party/rust/foo").starts_with(&import_dir)); + assert!(!PathBuf::from("/project/my-app").starts_with(&import_dir)); + } } diff --git a/ceres/src/application/api_service/mono/logic/tree.rs b/ceres/src/application/api_service/mono/logic/tree.rs index d299165f0..78f247d16 100644 --- a/ceres/src/application/api_service/mono/logic/tree.rs +++ b/ceres/src/application/api_service/mono/logic/tree.rs @@ -13,7 +13,7 @@ use git_internal::{ use jupiter::storage::mono_storage::RefUpdateData; use super::MonoServiceLogic; -use crate::api_service::mono::types::{RefUpdate, TreeUpdateResult}; +use crate::application::api_service::mono::types::{RefUpdate, TreeUpdateResult}; impl MonoServiceLogic { pub fn update_tree_hash( diff --git a/ceres/src/application/api_service/mono/mod.rs b/ceres/src/application/api_service/mono/mod.rs index 927e0d215..aceaf0e73 100644 --- a/ceres/src/application/api_service/mono/mod.rs +++ b/ceres/src/application/api_service/mono/mod.rs @@ -1,18 +1,31 @@ //! Monorepo API implementation (`MonoApiService` and domain modules). pub mod admin; +pub mod app_services; pub mod logic; pub mod service; pub mod types; pub mod buck; pub mod cl; +pub mod cl_list; pub mod cla; +pub mod code_review; +pub mod commit; +pub mod conversation; +pub mod dynamic_sidebar; pub mod edit; +pub mod gpg; +pub mod issue; +pub mod label_assignee; +pub mod note; +pub mod reviewer; pub mod sync; pub mod tag; +pub mod user; pub use admin::{ADMIN_FILE, EffectiveResourcePermission}; +pub use app_services::MonoAppServices; pub use cl::merge_strategy as cl_merge; pub use logic::MonoServiceLogic; pub use service::MonoApiService; diff --git a/ceres/src/application/api_service/mono/note.rs b/ceres/src/application/api_service/mono/note.rs new file mode 100644 index 000000000..f8cd5607e --- /dev/null +++ b/ceres/src/application/api_service/mono/note.rs @@ -0,0 +1,61 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::note::NoteShowResponse; + +impl MonoApiService { + pub async fn get_note_sync_state(&self, id: i32) -> Result { + let note = self + .storage + .note_storage() + .get_note_by_id(id.into()) + .await? + .ok_or_else(|| MegaError::NotFound(format!("Note {id} not found")))?; + + Ok(NoteShowResponse { + public_id: note.public_id, + description_schema_version: note.description_schema_version, + description_state: match ¬e.description_state { + Some(state) if !state.is_empty() => Some(state.clone()), + _ => None, + }, + description_html: match ¬e.description_html { + Some(html) if !html.is_empty() => html.clone(), + _ => String::new(), + }, + }) + } + + pub async fn update_note_sync_state( + &self, + id: i32, + description_html: &str, + description_state: &str, + description_schema_version: i32, + ) -> Result<(), MegaError> { + let note = self + .storage + .note_storage() + .get_note_by_id(id.into()) + .await? + .ok_or_else(|| MegaError::NotFound(format!("Note {id} not found")))?; + + if description_schema_version < note.description_schema_version { + return Err(MegaError::Other(format!( + "Invalid schema version: provided ({description_schema_version}) is older than current ({})", + note.description_schema_version + ))); + } + + self.storage + .note_storage() + .update_note( + id, + description_html, + description_state, + description_schema_version, + ) + .await?; + Ok(()) + } +} diff --git a/ceres/src/application/api_service/mono/reviewer.rs b/ceres/src/application/api_service/mono/reviewer.rs new file mode 100644 index 000000000..f27be7850 --- /dev/null +++ b/ceres/src/application/api_service/mono/reviewer.rs @@ -0,0 +1,59 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::change_list::{ReviewerInfo, ReviewersResponse}; + +impl MonoApiService { + pub async fn add_reviewers(&self, link: &str, reviewers: Vec) -> Result<(), MegaError> { + self.storage + .reviewer_storage() + .add_reviewers(link, reviewers) + .await + } + + pub async fn remove_reviewers( + &self, + link: &str, + reviewers: &[String], + ) -> Result<(), MegaError> { + self.storage + .reviewer_storage() + .remove_reviewers(link, reviewers) + .await + } + + pub async fn list_reviewers(&self, link: &str) -> Result { + let reviewers = self + .storage + .reviewer_storage() + .list_reviewers(link) + .await? + .into_iter() + .map(|r| ReviewerInfo { + username: r.username, + approved: r.approved, + system_required: r.system_required, + }) + .collect(); + Ok(ReviewersResponse { result: reviewers }) + } + + pub async fn reviewer_change_state( + &self, + link: &str, + username: &str, + approved: bool, + ) -> Result<(), MegaError> { + self.storage + .reviewer_storage() + .reviewer_change_state(link, username, approved) + .await + } + + pub async fn is_reviewer(&self, link: &str, username: &str) -> Result { + self.storage + .reviewer_storage() + .is_reviewer(link, username) + .await + } +} diff --git a/ceres/src/application/api_service/mono/service.rs b/ceres/src/application/api_service/mono/service.rs index 915c87649..bfef04dd9 100644 --- a/ceres/src/application/api_service/mono/service.rs +++ b/ceres/src/application/api_service/mono/service.rs @@ -23,9 +23,9 @@ use jupiter::{storage::Storage, utils::converter::FromMegaModel}; use super::logic::MonoServiceLogic; use crate::{ - api_service::{ApiHandler, cache::GitObjectCache, tree_ops}, + application::api_service::{ApiHandler, cache::GitObjectCache, tree_ops}, + infra::TransportContext, model::git::{CreateEntryInfo, CreateEntryResult, EditFilePayload, EditFileResult}, - pack::{import_repo::ImportRepo, monorepo::MonoRepo}, }; #[derive(Clone)] @@ -34,21 +34,18 @@ pub struct MonoApiService { pub git_object_cache: Arc, } -impl From<&MonoRepo> for MonoApiService { - fn from(mono_repo: &MonoRepo) -> Self { - MonoApiService { - storage: mono_repo.storage.clone(), - git_object_cache: mono_repo.git_object_cache.clone(), +impl MonoApiService { + pub fn new(ctx: TransportContext) -> Self { + Self { + storage: ctx.storage, + git_object_cache: ctx.git_object_cache, } } } -impl From<&ImportRepo> for MonoApiService { - fn from(import_repo: &ImportRepo) -> Self { - MonoApiService { - storage: import_repo.storage.clone(), - git_object_cache: import_repo.git_object_cache.clone(), - } +impl From for MonoApiService { + fn from(ctx: TransportContext) -> Self { + Self::new(ctx) } } diff --git a/ceres/src/application/api_service/mono/sync.rs b/ceres/src/application/api_service/mono/sync.rs index 8f41ebc4a..6665a3f09 100644 --- a/ceres/src/application/api_service/mono/sync.rs +++ b/ceres/src/application/api_service/mono/sync.rs @@ -11,14 +11,14 @@ use git_internal::{ use jupiter::{redis::lock::RedLock, utils::converter::FromMegaModel}; use crate::{ - api_service::{ + application::api_service::{ mono::{MonoApiService, MonoServiceLogic}, - state::ProtocolApiState, tree_ops, }, + bus::TransportRuntime, + infra::pack_stream::into_pack_byte_stream, model::third_party::{ThirdPartyClient, ThirdPartyRepoTrait}, - pack::into_pack_byte_stream, - protocol::{PushUserInfo, ServiceType, SmartSession, TransportProtocol}, + transport::protocol::{PushUserInfo, ServiceType, SmartSession, TransportProtocol}, }; impl MonoApiService { @@ -75,8 +75,10 @@ impl MonoApiService { Ok(()) => { txn.commit().await.map_err(MegaError::Db)?; guard.unlock().await?; - crate::api_service::mono::cl_merge::sync_path_prefix_main_refs(self, path) - .await?; + crate::application::api_service::mono::cl_merge::sync_path_prefix_main_refs( + self, path, + ) + .await?; return Ok(()); } Err(MegaError::StaleMonorepoRootRef) if attempt + 1 < MAX_ATTACH_ATTEMPTS => { @@ -170,10 +172,7 @@ impl MonoApiService { let res = remote_client .fetch_packs(std::slice::from_ref(&ref_hash), fetch_depth) .await?; - let pack_data = remote_client - .process_pack_stream(res) - .await - .map_err(|e| MegaError::Other(format!("{e}")))?; + let pack_data = remote_client.process_pack_stream(res).await?; if pack_data.is_empty() { return Err(MegaError::Other( "GitHub sync failed: remote returned no pack data".to_string(), @@ -189,12 +188,12 @@ impl MonoApiService { let old_id = self.resolve_sync_old_id(&repo_path_str, &ref_name).await?; - let commands = vec![crate::protocol::import_refs::RefCommand::new( + let commands = vec![crate::transport::protocol::import_refs::RefCommand::new( old_id, ref_hash.clone(), ref_name.clone(), )]; - let state = ProtocolApiState::new(self.storage.clone(), self.git_object_cache.clone()); + let state = TransportRuntime::new(self.storage.clone(), self.git_object_cache.clone()); let bytes = protocol .git_receive_pack_stream( &state, diff --git a/ceres/src/application/api_service/mono/tag.rs b/ceres/src/application/api_service/mono/tag.rs index 1a77a0b0e..da9017219 100644 --- a/ceres/src/application/api_service/mono/tag.rs +++ b/ceres/src/application/api_service/mono/tag.rs @@ -6,11 +6,12 @@ use git_internal::errors::GitError; use tracing; use crate::{ - api_service::{ + application::api_service::{ mono::MonoApiService, tag_ops::{ self, build_git_internal_tag, db_error, format_tagger_info, is_annotated_tag, lightweight_commit_tag, merge_paginated_tags, tag_already_exists, tags_full_ref, + validate_tag_name, }, }, model::tag::TagInfo, @@ -38,6 +39,7 @@ impl MonoApiService { tagger_email: Option, message: Option, ) -> Result { + validate_tag_name(&name)?; let mono_storage = self.storage.mono_storage(); let tagger_info = format_tagger_info(tagger_name, tagger_email); @@ -46,16 +48,16 @@ impl MonoApiService { let full_ref = tags_full_ref(&name); match mono_storage.get_tag_by_name(&name).await { - Ok(Some(_)) => return Err(tag_already_exists(&name)), + Ok(Some(_)) => return Err(tag_already_exists(&name).into()), Ok(None) => {} Err(e) => { tracing::error!("DB error while checking tag existence: {}", e); - return Err(db_error()); + return Err(db_error().into()); } } if let Ok(Some(_)) = mono_storage.get_ref_by_name(&full_ref).await { - return Err(tag_already_exists(&name)); + return Err(tag_already_exists(&name).into()); } if is_annotated_tag(&message) { @@ -79,7 +81,7 @@ impl MonoApiService { Ok(v) => v, Err(e) => { tracing::error!("DB error while listing tags: {}", e); - return Err(db_error()); + return Err(db_error().into()); } }; @@ -126,7 +128,7 @@ impl MonoApiService { Ok(None) => {} Err(e) => { tracing::error!("DB error while getting tag: {}", e); - return Err(db_error()); + return Err(db_error().into()); } } @@ -182,7 +184,7 @@ impl MonoApiService { } Err(e) => { tracing::error!("DB error while deleting tag: {}", e); - Err(db_error()) + Err(db_error().into()) } } } @@ -291,7 +293,7 @@ impl MonoApiService { "Target commit '{}' not found while resolving tree hash", commit_id ); - Err(tag_ops::commit_not_found(commit_id)) + Err(tag_ops::commit_not_found(commit_id).into()) } Err(e) => { tracing::error!( @@ -299,7 +301,7 @@ impl MonoApiService { commit_id, e ); - Err(db_error()) + Err(db_error().into()) } } } @@ -310,12 +312,12 @@ impl MonoApiService { match mono_storage.get_commit_by_hash(t).await { Ok(commit_opt) => { if commit_opt.is_none() { - return Err(tag_ops::commit_not_found(t)); + return Err(tag_ops::commit_not_found(t).into()); } } Err(e) => { tracing::error!("DB error while fetching commit by hash: {}", e); - return Err(db_error()); + return Err(db_error().into()); } } } diff --git a/ceres/src/application/api_service/mono/user.rs b/ceres/src/application/api_service/mono/user.rs new file mode 100644 index 000000000..a1ce4f984 --- /dev/null +++ b/ceres/src/application/api_service/mono/user.rs @@ -0,0 +1,134 @@ +use common::errors::MegaError; + +use super::service::MonoApiService; +use crate::model::{ + notification::{ + NotificationEventTypeInfo, UpdateUserNotificationConfig, UserNotificationConfig, + UserNotificationPreferenceItem, + }, + user::{ListSSHKey, ListToken}, +}; + +impl MonoApiService { + pub async fn save_ssh_key( + &self, + username: String, + title: &str, + ssh_key: &str, + fingerprint: &str, + ) -> Result<(), MegaError> { + self.storage + .user_storage() + .save_ssh_key(username, title, ssh_key, fingerprint) + .await + } + + pub async fn delete_ssh_key(&self, username: String, key_id: i64) -> Result<(), MegaError> { + self.storage + .user_storage() + .delete_ssh_key(username, key_id) + .await + } + + pub async fn list_user_ssh_keys(&self, username: String) -> Result, MegaError> { + let keys = self.storage.user_storage().list_user_ssh(username).await?; + Ok(keys.into_iter().map(|k| k.into()).collect()) + } + + pub async fn generate_user_token(&self, username: String) -> Result { + self.storage.user_storage().generate_token(username).await + } + + pub async fn delete_user_token(&self, username: String, key_id: i64) -> Result<(), MegaError> { + self.storage + .user_storage() + .delete_token(username, key_id) + .await + } + + pub async fn list_user_tokens(&self, username: String) -> Result, MegaError> { + let tokens = self.storage.user_storage().list_token(username).await?; + Ok(tokens.into_iter().map(|t| t.into()).collect()) + } + + pub async fn list_notification_event_types( + &self, + ) -> Result, MegaError> { + Ok(self + .storage + .notification_storage() + .list_event_types() + .await? + .into_iter() + .map(|t| NotificationEventTypeInfo { + code: t.code, + category: t.category, + description: t.description, + system_required: t.system_required, + default_enabled: t.default_enabled, + }) + .collect()) + } + + pub async fn get_user_notification_config( + &self, + username: &str, + email: &str, + ) -> Result { + let stg = self.storage.notification_storage(); + stg.upsert_user_settings(username, email).await?; + + let settings = stg + .get_user_settings(username) + .await? + .ok_or_else(|| MegaError::Other("user settings missing".to_string()))?; + + let prefs = stg + .list_user_preferences(username) + .await? + .into_iter() + .map(|p| UserNotificationPreferenceItem { + event_type_code: p.event_type_code, + enabled: p.enabled, + }) + .collect(); + + Ok(UserNotificationConfig { + enabled: settings.enabled, + delivery_mode: settings.delivery_mode, + email: settings.email, + preferences: prefs, + }) + } + + pub async fn update_user_notification_config( + &self, + username: &str, + email: &str, + payload: UpdateUserNotificationConfig, + ) -> Result<(), MegaError> { + let stg = self.storage.notification_storage(); + stg.upsert_user_settings(username, email).await?; + + if let Some(enabled) = payload.enabled { + stg.set_global_enabled(username, enabled).await?; + } + if let Some(mode) = payload.delivery_mode { + stg.set_delivery_mode(username, &mode).await?; + } + if let Some(items) = payload.preferences { + for item in items { + stg.set_user_preference(username, &item.event_type_code, item.enabled) + .await?; + } + } + Ok(()) + } + + pub async fn find_bot_by_token( + &self, + token: &str, + ) -> Result, MegaError> { + self.storage.bots_storage().find_bot_by_token(token).await + } +} diff --git a/ceres/src/application/api_service/state.rs b/ceres/src/application/api_service/state.rs deleted file mode 100644 index ced05063f..000000000 --- a/ceres/src/application/api_service/state.rs +++ /dev/null @@ -1,2 +0,0 @@ -//! Backward-compatible re-export; prefer [`crate::transport::ProtocolApiState`] or [`crate::bus::TransportRuntime`]. -pub use crate::transport::ProtocolApiState; diff --git a/ceres/src/application/api_service/tag_ops.rs b/ceres/src/application/api_service/tag_ops.rs index c2cc9c46d..81244d642 100644 --- a/ceres/src/application/api_service/tag_ops.rs +++ b/ceres/src/application/api_service/tag_ops.rs @@ -2,6 +2,7 @@ use std::str::FromStr; +use common::errors::MegaError; use git_internal::{ errors::GitError, hash::ObjectHash, @@ -31,16 +32,57 @@ pub fn tags_full_ref(name: &str) -> String { format!("refs/tags/{name}") } -pub fn tag_already_exists(name: &str) -> GitError { - GitError::CustomError(format!("[code:400] Tag '{name}' already exists")) +pub fn tag_already_exists(name: &str) -> MegaError { + MegaError::bad_request(format!("Tag '{name}' already exists")) } -pub fn commit_not_found(commit_id: &str) -> GitError { - GitError::CustomError(format!("[code:404] Target commit '{commit_id}' not found")) +pub fn commit_not_found(commit_id: &str) -> MegaError { + MegaError::NotFound(format!("Target commit '{commit_id}' not found")) } -pub fn db_error() -> GitError { - GitError::CustomError("[code:500] DB error".to_string()) +pub fn db_error() -> MegaError { + MegaError::Other("DB error".to_string()) +} + +/// Validate tag name against a conservative subset of Git ref rules. +pub fn validate_tag_name(name: &str) -> Result<(), MegaError> { + if name.is_empty() { + return Err(MegaError::bad_request("Tag name must not be empty")); + } + + if name.len() > 255 { + return Err(MegaError::bad_request("Tag name is too long")); + } + + if name.contains("..") || name.contains("@{") { + return Err(MegaError::bad_request( + "Tag name contains reserved sequence '..' or '@{'", + )); + } + + if name.contains("//") { + return Err(MegaError::bad_request("Tag name must not contain '//'")); + } + + if name.ends_with(".lock") { + return Err(MegaError::bad_request("Tag name must not end with '.lock'")); + } + + let forbidden = [' ', '~', '^', ':', '?', '*', '[', '\\']; + for c in name.chars() { + if forbidden.contains(&c) { + return Err(MegaError::bad_request(format!( + "Tag name '{name}' contains forbidden character '{c}'" + ))); + } + if c == '\0' || c.is_control() { + return Err(MegaError::bad_request( + "Tag name contains invalid control characters", + )); + } + } + + Ok(()) } /// Build git-internal tag id and resolved object id from create-tag inputs. @@ -124,6 +166,14 @@ mod tests { assert!(is_annotated_tag(&Some("release".into()))); } + #[test] + fn validate_tag_name_rejects_empty() { + assert!(matches!( + validate_tag_name(""), + Err(MegaError::BadRequest(_)) + )); + } + #[test] fn merge_paginated_tags_fills_from_lightweight() { let annotated = vec![lightweight_commit_tag("a", "1", "", "t")]; diff --git a/ceres/src/application/api_service/tree_ops.rs b/ceres/src/application/api_service/tree_ops.rs index a13e2380e..c12c65ea7 100644 --- a/ceres/src/application/api_service/tree_ops.rs +++ b/ceres/src/application/api_service/tree_ops.rs @@ -15,7 +15,7 @@ use git_internal::{ use jupiter::utils::converter::generate_git_keep_with_timestamp; use crate::{ - api_service::ApiHandler, + application::api_service::ApiHandler, model::git::{TreeBriefItem, TreeCommitItem, TreeHashItem}, }; diff --git a/ceres/src/application/artifact/mod.rs b/ceres/src/application/artifact/mod.rs index f139f5b66..e9d97dd96 100644 --- a/ceres/src/application/artifact/mod.rs +++ b/ceres/src/application/artifact/mod.rs @@ -26,6 +26,17 @@ impl ArtifactApplicationService { .gc_unreferenced_artifact_objects_once(grace, batch_limit) .await } + + pub fn weak_etag_for_oid_size(oid: &str, size_bytes: i64) -> String { + ArtifactService::weak_etag_for_oid_size(oid, size_bytes) + } + + pub fn parse_artifact_object_range( + range_header_value: Option<&str>, + len: u64, + ) -> Result, MegaError> { + ArtifactService::parse_artifact_object_range(range_header_value, len) + } } impl Deref for ArtifactApplicationService { diff --git a/ceres/src/application/build_trigger/buck_upload_handler.rs b/ceres/src/application/build_trigger/buck_upload_handler.rs index ce6942faf..5395e0cdf 100644 --- a/ceres/src/application/build_trigger/buck_upload_handler.rs +++ b/ceres/src/application/build_trigger/buck_upload_handler.rs @@ -7,7 +7,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::changes_calculator::MonoChangesCalculator; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{ BuckFileUploadPayload, BuildTrigger, BuildTriggerPayload, BuildTriggerType, TriggerContext, diff --git a/ceres/src/application/build_trigger/changes_calculator.rs b/ceres/src/application/build_trigger/changes_calculator.rs index 3a216f90a..ef874d8e4 100644 --- a/ceres/src/application/build_trigger/changes_calculator.rs +++ b/ceres/src/application/build_trigger/changes_calculator.rs @@ -6,7 +6,7 @@ use git_internal::hash::ObjectHash; use super::changes_port::ChangesPort; use crate::{ - api_service::mono::MonoApiService, build_trigger::TriggerContext, + application::{api_service::mono::MonoApiService, build_trigger::TriggerContext}, model::change_list::ClDiffFile, }; diff --git a/ceres/src/application/build_trigger/changes_port.rs b/ceres/src/application/build_trigger/changes_port.rs index 53100e8e3..7a600e0c6 100644 --- a/ceres/src/application/build_trigger/changes_port.rs +++ b/ceres/src/application/build_trigger/changes_port.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use common::errors::MegaError; use git_internal::hash::ObjectHash; -use crate::{api_service::mono::MonoApiService, model::change_list::ClDiffFile}; +use crate::{application::api_service::mono::MonoApiService, model::change_list::ClDiffFile}; /// Port for computing CL file diffs used by build trigger handlers. #[async_trait] diff --git a/ceres/src/application/build_trigger/dispatcher.rs b/ceres/src/application/build_trigger/dispatcher.rs index 0a8ddf70c..7bea91e20 100644 --- a/ceres/src/application/build_trigger/dispatcher.rs +++ b/ceres/src/application/build_trigger/dispatcher.rs @@ -5,7 +5,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use orion_client::OrionBuildClient; -use crate::build_trigger::{BuildTrigger, BuildTriggerPayload}; +use crate::application::build_trigger::{BuildTrigger, BuildTriggerPayload}; /// Handles dispatching build triggers to the build execution layer (Orion). pub struct BuildDispatcher { @@ -107,7 +107,7 @@ mod tests { use tokio::{net::TcpListener, sync::mpsc}; use super::*; - use crate::build_trigger::{BuildTriggerType, TriggerSource, WebEditPayload}; + use crate::application::build_trigger::{BuildTriggerType, TriggerSource, WebEditPayload}; #[derive(Clone)] struct MockOrionState { diff --git a/ceres/src/application/build_trigger/git_push_handler.rs b/ceres/src/application/build_trigger/git_push_handler.rs index 194bb7d95..8c7836340 100644 --- a/ceres/src/application/build_trigger/git_push_handler.rs +++ b/ceres/src/application/build_trigger/git_push_handler.rs @@ -6,7 +6,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::changes_calculator::MonoChangesCalculator; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{ BuildTrigger, BuildTriggerPayload, BuildTriggerType, GitPushPayload, TriggerContext, diff --git a/ceres/src/application/build_trigger/manual_handler.rs b/ceres/src/application/build_trigger/manual_handler.rs index 2e72f74ba..942132299 100644 --- a/ceres/src/application/build_trigger/manual_handler.rs +++ b/ceres/src/application/build_trigger/manual_handler.rs @@ -6,7 +6,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::changes_calculator::MonoChangesCalculator; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{ BuildTrigger, BuildTriggerPayload, BuildTriggerType, ManualPayload, TriggerContext, @@ -39,9 +39,7 @@ impl ManualHandler { .mono_storage() .get_commit_by_hash(commit_hash) .await? - .ok_or_else(|| { - MegaError::Other(format!("[code:404] Commit not found: {}", commit_hash)) - })?; + .ok_or_else(|| MegaError::NotFound(format!("Commit not found: {commit_hash}")))?; // Parse parents_id JSON array let parent_ids: Vec = diff --git a/ceres/src/application/build_trigger/mod.rs b/ceres/src/application/build_trigger/mod.rs index 7ceedfb9b..69a12c788 100644 --- a/ceres/src/application/build_trigger/mod.rs +++ b/ceres/src/application/build_trigger/mod.rs @@ -5,7 +5,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use orion_client::OrionBuildClient; -use crate::api_service::cache::GitObjectCache; +use crate::application::api_service::cache::GitObjectCache; mod buck_upload_handler; mod changes_calculator; diff --git a/ceres/src/application/build_trigger/ref_resolver.rs b/ceres/src/application/build_trigger/ref_resolver.rs index c64928ee2..ae09dc3b5 100644 --- a/ceres/src/application/build_trigger/ref_resolver.rs +++ b/ceres/src/application/build_trigger/ref_resolver.rs @@ -81,9 +81,8 @@ impl RefResolver { } // Not found - Err(MegaError::Other(format!( - "[code:404] Reference not found: '{}' (not a branch, tag, or commit)", - ref_name + Err(MegaError::NotFound(format!( + "Reference not found: '{ref_name}' (not a branch, tag, or commit)" ))) } diff --git a/ceres/src/application/build_trigger/retry_handler.rs b/ceres/src/application/build_trigger/retry_handler.rs index eef421dfc..b4ffb782b 100644 --- a/ceres/src/application/build_trigger/retry_handler.rs +++ b/ceres/src/application/build_trigger/retry_handler.rs @@ -6,7 +6,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::changes_calculator::MonoChangesCalculator; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{ BuildTrigger, BuildTriggerPayload, BuildTriggerType, RetryPayload, TriggerContext, diff --git a/ceres/src/application/build_trigger/service.rs b/ceres/src/application/build_trigger/service.rs index 3b22f2090..f48bcad01 100644 --- a/ceres/src/application/build_trigger/service.rs +++ b/ceres/src/application/build_trigger/service.rs @@ -35,7 +35,7 @@ use orion_client::OrionBuildClient; use super::model::{ BuildParams, GitPushEvent, ListTriggersParams, TriggerContext, TriggerRecord, TriggerResponse, }; -use crate::{ +use crate::application::{ api_service::cache::GitObjectCache, build_trigger::{RefResolver, TriggerRegistry}, code_edit::utils as edit_utils, @@ -79,9 +79,7 @@ impl BuildTriggerService { fn check_build_enabled(&self) -> Result<(), MegaError> { if !self.is_enabled() { - return Err(MegaError::Other( - "[code:503] Build system is not enabled".to_string(), - )); + return Err(MegaError::unavailable("Build system is not enabled")); } Ok(()) } @@ -133,7 +131,7 @@ impl BuildTriggerService { .cl_storage() .get_cl(cl_link) .await? - .ok_or_else(|| MegaError::Other(format!("[code:404] CL not found: {}", cl_link)))?; + .ok_or_else(|| MegaError::NotFound(format!("CL not found: {cl_link}")))?; let context = Self::context_from_cl(&self.storage, cl).await?; let id = self.registry.trigger_build(context).await?; Ok(Some(id)) @@ -168,7 +166,7 @@ impl BuildTriggerService { .await .map_err(|_| { let ref_str = ref_name.unwrap_or_else(|| "main".to_string()); - MegaError::Other(format!("[code:404] Reference not found: {}", ref_str)) + MegaError::NotFound(format!("Reference not found: {ref_str}")) })?; let mut context = TriggerContext::from_manual( @@ -198,10 +196,7 @@ impl BuildTriggerService { .get_by_id(original_trigger_id) .await? .ok_or_else(|| { - MegaError::Other(format!( - "[code:404] Trigger not found: {}", - original_trigger_id - )) + MegaError::NotFound(format!("Trigger not found: {original_trigger_id}")) })?; let trigger_record = TriggerRecord::from_db_model(original_trigger); @@ -271,9 +266,7 @@ impl BuildTriggerService { .build_trigger_storage() .get_by_id(trigger_id) .await? - .ok_or_else(|| { - MegaError::Other(format!("[code:404] Trigger not found: {}", trigger_id)) - })?; + .ok_or_else(|| MegaError::NotFound(format!("Trigger not found: {trigger_id}")))?; let record = TriggerRecord::from_db_model(model); TriggerResponse::from_trigger_record(&record) @@ -286,7 +279,7 @@ mod tests { use tempfile::tempdir; use super::*; - use crate::build_trigger::BuildTriggerType; + use crate::application::build_trigger::BuildTriggerType; #[tokio::test] async fn test_context_from_cl_resolves_repo_root_from_registered_repo_path() { diff --git a/ceres/src/application/build_trigger/web_edit_handler.rs b/ceres/src/application/build_trigger/web_edit_handler.rs index ae8041bb5..d955f2d4c 100644 --- a/ceres/src/application/build_trigger/web_edit_handler.rs +++ b/ceres/src/application/build_trigger/web_edit_handler.rs @@ -6,7 +6,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::changes_calculator::MonoChangesCalculator; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{ BuildTrigger, BuildTriggerPayload, BuildTriggerType, TriggerContext, TriggerHandler, @@ -88,7 +88,7 @@ mod tests { use api_model::buck2::{status::Status, types::ProjectRelativePath}; use super::*; - use crate::build_trigger::{BuildTriggerType, TriggerSource}; + use crate::application::build_trigger::{BuildTriggerType, TriggerSource}; #[test] fn test_resolve_cl_link_prefers_existing_cl_link() { diff --git a/ceres/src/application/code_edit/model.rs b/ceres/src/application/code_edit/model.rs index 4647a6279..8498186b3 100644 --- a/ceres/src/application/code_edit/model.rs +++ b/ceres/src/application/code_edit/model.rs @@ -11,10 +11,12 @@ use jupiter::{ use orion_client::OrionBuildClient; use crate::{ - api_service::{ApiHandler, cache::GitObjectCache}, - application::webhook::{WebhookEvent, dispatch_cl_webhook}, - build_trigger::{BuildTriggerService, TriggerContext}, - code_edit::{model, utils as edit_utils}, + application::{ + api_service::{ApiHandler, cache::GitObjectCache}, + build_trigger::{BuildTriggerService, TriggerContext}, + code_edit::{model, utils as edit_utils}, + webhook::{WebhookEvent, dispatch_cl_webhook}, + }, merge_checker::CheckerRegistry, }; diff --git a/ceres/src/application/code_edit/on_edit.rs b/ceres/src/application/code_edit/on_edit.rs index b0f8c1c43..e9ecb2cf3 100644 --- a/ceres/src/application/code_edit/on_edit.rs +++ b/ceres/src/application/code_edit/on_edit.rs @@ -7,9 +7,11 @@ use git_internal::errors::GitError; use jupiter::storage::{Storage, mono_storage::MonoStorage}; use crate::{ - api_service::{cache::GitObjectCache, mono::MonoApiService}, - build_trigger::{BuildTriggerService, TriggerContext}, - code_edit::{model, utils as edit_utils}, + application::{ + api_service::{cache::GitObjectCache, mono::MonoApiService}, + build_trigger::{BuildTriggerService, TriggerContext}, + code_edit::{model, utils as edit_utils}, + }, model::git::EditCLMode, }; diff --git a/ceres/src/application/code_edit/on_push.rs b/ceres/src/application/code_edit/on_push.rs index ff823c656..b4f8a0c92 100644 --- a/ceres/src/application/code_edit/on_push.rs +++ b/ceres/src/application/code_edit/on_push.rs @@ -5,7 +5,7 @@ use common::errors::MegaError; use jupiter::storage::Storage; use orion_client::OrionBuildClient; -use crate::{ +use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, build_trigger::{BuildTriggerService, TriggerContext}, code_edit::{ diff --git a/ceres/src/application/code_edit/post_receive/import.rs b/ceres/src/application/code_edit/post_receive/import.rs index 13f72c5c9..9f846a773 100644 --- a/ceres/src/application/code_edit/post_receive/import.rs +++ b/ceres/src/application/code_edit/post_receive/import.rs @@ -13,8 +13,8 @@ use git_internal::{hash::ObjectHash, internal::object::commit::Commit}; use jupiter::{redis::lock::RedLock, storage::Storage, utils::converter::FromGitModel}; use crate::{ - api_service::{cache::GitObjectCache, mono::MonoApiService, tree_ops}, - protocol::import_refs::{CommandType, RefCommand}, + application::api_service::{cache::GitObjectCache, mono::MonoApiService, tree_ops}, + transport::protocol::import_refs::{CommandType, RefCommand}, }; pub async fn dispatch_import_receive_pack_finalized( diff --git a/ceres/src/application/code_edit/post_receive/mono.rs b/ceres/src/application/code_edit/post_receive/mono.rs index 971bcc791..8b7140eec 100644 --- a/ceres/src/application/code_edit/post_receive/mono.rs +++ b/ceres/src/application/code_edit/post_receive/mono.rs @@ -13,13 +13,15 @@ use jupiter::storage::Storage; use orion_client::OrionBuildClient; use crate::{ - api_service::{ - ApiHandler, - cache::GitObjectCache, - mono::{MonoApiService, cl_merge}, + application::{ + api_service::{ + ApiHandler, + cache::GitObjectCache, + mono::{MonoApiService, cl_merge}, + }, + code_edit::{on_push::OnpushCodeEdit, utils::get_changed_files}, }, bus::{ApplicationEventHandler, TransportEvent}, - code_edit::{on_push::OnpushCodeEdit, utils::get_changed_files}, }; /// Handles CL creation, bootstrap, build triggers, and code-review reanchoring after mono push. diff --git a/ceres/src/application/code_edit/utils.rs b/ceres/src/application/code_edit/utils.rs index c838acc41..b9684f7cb 100644 --- a/ceres/src/application/code_edit/utils.rs +++ b/ceres/src/application/code_edit/utils.rs @@ -16,7 +16,7 @@ use git_internal::{ use jupiter::{storage::Storage, utils::converter::FromMegaModel}; use crate::{ - api_service::{ApiHandler, commit_ops, mono::MonoServiceLogic}, + application::api_service::{ApiHandler, commit_ops, mono::MonoServiceLogic}, model::change_list::ClDiffFile, }; diff --git a/ceres/src/application/mod.rs b/ceres/src/application/mod.rs index fccadc884..34fa165d4 100644 --- a/ceres/src/application/mod.rs +++ b/ceres/src/application/mod.rs @@ -3,4 +3,5 @@ pub mod artifact; pub mod buck; pub mod build_trigger; pub mod code_edit; +pub mod notification; pub mod webhook; diff --git a/mono/src/notification/dispatcher.rs b/ceres/src/application/notification/dispatcher.rs similarity index 67% rename from mono/src/notification/dispatcher.rs rename to ceres/src/application/notification/dispatcher.rs index b19108a1b..1110fd3cf 100644 --- a/mono/src/notification/dispatcher.rs +++ b/ceres/src/application/notification/dispatcher.rs @@ -1,18 +1,30 @@ use std::sync::Arc; +use async_trait::async_trait; +use common::errors::MegaError; use jupiter::storage::NotificationStorage; use tokio::time::{Duration, interval}; use tracing::{info, warn}; -use crate::email::Mailer; +/// Sends notification email payloads produced by the notification application layer. +#[async_trait] +pub trait EmailMailer: Send + Sync { + async fn send_html( + &self, + to: &str, + subject: &str, + html: &str, + text: Option<&str>, + ) -> Result<(), MegaError>; +} pub struct EmailDispatcher { stg: NotificationStorage, - mailer: Arc, + mailer: Arc, } impl EmailDispatcher { - pub fn new(stg: NotificationStorage, mailer: Arc) -> Self { + pub fn new(stg: NotificationStorage, mailer: Arc) -> Self { Self { stg, mailer } } @@ -34,7 +46,7 @@ impl EmailDispatcher { } } - async fn tick_once(&self) -> Result<(), jupiter::sea_orm::DbErr> { + pub async fn tick_once(&self) -> Result<(), jupiter::sea_orm::DbErr> { let jobs = self.stg.fetch_pending_jobs(50).await?; for job in jobs { if job.to_email.trim().is_empty() { @@ -75,16 +87,31 @@ impl EmailDispatcher { #[cfg(test)] mod tests { - use callisto::{email_jobs, notification_event_types}; - use jupiter::{ - migration::apply_migrations, - sea_orm::{ActiveModelTrait, EntityTrait, Set}, - tests::test_db_connection, - }; + use std::sync::Arc; + + use async_trait::async_trait; + use common::errors::MegaError; + use jupiter::tests::test_db_connection; + use jupiter_migrate::apply_migrations; use tempfile::TempDir; use super::*; - use crate::email::NoopMailer; + use crate::application::notification::ensure_cl_comment_event_type; + + struct NoopMailer; + + #[async_trait] + impl EmailMailer for NoopMailer { + async fn send_html( + &self, + _to: &str, + _subject: &str, + _html: &str, + _text: Option<&str>, + ) -> Result<(), MegaError> { + Ok(()) + } + } #[tokio::test] async fn test_dispatcher_sends_pending_jobs() { @@ -92,24 +119,8 @@ mod tests { let db = test_db_connection(dir.path()).await; apply_migrations(&db, true).await.unwrap(); - let stg = NotificationStorage::new(Arc::new(db.clone())); - let now = chrono::Utc::now().naive_utc(); - - // ensure event type exists - notification_event_types::ActiveModel { - code: Set("cl.comment.created".into()), - category: Set("cl".into()), - description: Set("New comment".into()), - system_required: Set(false), - default_enabled: Set(true), - created_at: Set(now), - updated_at: Set(now), - } - .insert(&db) - .await - .unwrap(); - - // enqueue a job + let stg = NotificationStorage::new(Arc::new(db)); + ensure_cl_comment_event_type(&stg).await.unwrap(); stg.enqueue_email_job( "alice", "alice@example.com", @@ -121,14 +132,13 @@ mod tests { .await .unwrap(); + let pending_before = stg.fetch_pending_jobs(10).await.unwrap(); + assert_eq!(pending_before.len(), 1); + let dispatcher = EmailDispatcher::new(stg.clone(), Arc::new(NoopMailer)); dispatcher.tick_once().await.unwrap(); let jobs = stg.fetch_pending_jobs(10).await.unwrap(); assert!(jobs.is_empty(), "pending queue should be empty after send"); - - let sent = email_jobs::Entity::find().all(&db).await.unwrap(); - assert_eq!(sent.len(), 1); - assert_eq!(sent[0].status, "sent"); } } diff --git a/ceres/src/application/notification/mod.rs b/ceres/src/application/notification/mod.rs new file mode 100644 index 000000000..b27a785bf --- /dev/null +++ b/ceres/src/application/notification/mod.rs @@ -0,0 +1,5 @@ +pub mod dispatcher; +pub mod triggers; + +pub use dispatcher::{EmailDispatcher, EmailMailer}; +pub use triggers::{EVENT_CL_COMMENT_CREATED, ensure_cl_comment_event_type, on_cl_comment_created}; diff --git a/ceres/src/application/notification/triggers.rs b/ceres/src/application/notification/triggers.rs new file mode 100644 index 000000000..6c71f68d4 --- /dev/null +++ b/ceres/src/application/notification/triggers.rs @@ -0,0 +1,246 @@ +use std::collections::HashSet; + +use callisto::notification_event_types; +use common::errors::MegaError; +use jupiter::{ + sea_orm::{ActiveModelTrait, Set}, + storage::{ + cl_reviewer_storage::ClReviewerStorage, cl_storage::ClStorage, + notification_storage::NotificationStorage, + }, +}; + +pub const EVENT_CL_COMMENT_CREATED: &str = "cl.comment.created"; + +/// Ensures the CL comment notification event type exists (idempotent). +pub async fn ensure_cl_comment_event_type(stg: &NotificationStorage) -> Result<(), MegaError> { + if stg + .get_event_type(EVENT_CL_COMMENT_CREATED) + .await? + .is_some() + { + return Ok(()); + } + + let now = chrono::Utc::now().naive_utc(); + notification_event_types::ActiveModel { + code: Set(EVENT_CL_COMMENT_CREATED.to_owned()), + category: Set("cl".to_owned()), + description: Set("New comment on a Change List".to_owned()), + system_required: Set(false), + default_enabled: Set(true), + created_at: Set(now), + updated_at: Set(now), + } + .insert(stg.db()) + .await?; + + Ok(()) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Trigger: a new comment is created on a Change List. +pub async fn on_cl_comment_created( + notif_stg: &NotificationStorage, + cl_stg: &ClStorage, + reviewer_stg: &ClReviewerStorage, + actor_username: &str, + cl_link: &str, + comment_text: &str, +) -> Result<(), MegaError> { + ensure_cl_comment_event_type(notif_stg).await?; + + let cl = cl_stg + .get_cl(cl_link) + .await? + .ok_or_else(|| MegaError::NotFound(format!("CL {cl_link} not found")))?; + + let reviewers = reviewer_stg.list_reviewers(cl_link).await?; + + let mut recipients: HashSet = HashSet::new(); + recipients.insert(cl.username); + for r in reviewers { + recipients.insert(r.username); + } + recipients.remove(actor_username); + + for username in recipients { + if !notif_stg + .should_send(&username, EVENT_CL_COMMENT_CREATED) + .await? + { + continue; + } + + let settings = match notif_stg.get_user_settings(&username).await? { + Some(s) => s, + None => continue, + }; + + let subject = format!("New comment on CL {cl_link}"); + let body_text = format!("{actor_username} commented on {cl_link}: {comment_text}"); + let body_html = format!( + "

{actor_username} commented on {cl_link}:

{}

", + escape_html(comment_text) + ); + + notif_stg + .enqueue_email_job( + &username, + &settings.email, + EVENT_CL_COMMENT_CREATED, + &subject, + &body_html, + Some(&body_text), + ) + .await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use callisto::{email_jobs, mega_cl, mega_cl_reviewer}; + use jupiter::{ + sea_orm::{ColumnTrait, EntityTrait, QueryFilter}, + storage::base_storage::{BaseStorage, StorageConnector}, + tests::test_db_connection, + }; + use jupiter_migrate::apply_migrations; + use tempfile::TempDir; + + use super::*; + + #[tokio::test] + async fn test_on_cl_comment_created_enqueues_jobs_for_author_and_reviewers() { + let dir = TempDir::new().unwrap(); + let db = test_db_connection(dir.path()).await; + apply_migrations(&db, true).await.unwrap(); + + let base = BaseStorage::new(Arc::new(db.clone())); + let notif = NotificationStorage::new(Arc::new(db.clone())); + let cl_stg = ClStorage { base: base.clone() }; + let reviewer_stg = ClReviewerStorage { base: base.clone() }; + + let now = chrono::Utc::now().naive_utc(); + mega_cl::ActiveModel { + id: Set(1), + link: Set("CL1".to_string()), + title: Set("t".to_string()), + merge_date: Set(None), + status: Set(callisto::sea_orm_active_enums::MergeStatusEnum::Open), + path: Set("/".to_string()), + from_hash: Set("a".to_string()), + to_hash: Set("b".to_string()), + created_at: Set(now), + updated_at: Set(now), + username: Set("alice".to_string()), + base_branch: Set("main".to_string()), + } + .insert(&db) + .await + .unwrap(); + + mega_cl_reviewer::ActiveModel { + id: Set(1), + cl_link: Set("CL1".to_string()), + username: Set("bob".to_string()), + approved: Set(false), + system_required: Set(false), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&db) + .await + .unwrap(); + + notif + .upsert_user_settings("alice", "alice@example.com") + .await + .unwrap(); + notif + .upsert_user_settings("bob", "bob@example.com") + .await + .unwrap(); + notif + .upsert_user_settings("carol", "carol@example.com") + .await + .unwrap(); + + on_cl_comment_created(¬if, &cl_stg, &reviewer_stg, "carol", "CL1", "hello") + .await + .unwrap(); + + let jobs = email_jobs::Entity::find().all(&db).await.unwrap(); + assert_eq!(jobs.len(), 2); + + let alice_job = email_jobs::Entity::find() + .filter(email_jobs::Column::Username.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")) + .one(&db) + .await + .unwrap(); + assert!(bob_job.is_some()); + } + + #[tokio::test] + async fn test_on_cl_comment_created_respects_should_send() { + let dir = TempDir::new().unwrap(); + let db = test_db_connection(dir.path()).await; + apply_migrations(&db, true).await.unwrap(); + + let base = BaseStorage::new(Arc::new(db.clone())); + let notif = NotificationStorage::new(Arc::new(db.clone())); + let cl_stg = ClStorage { base: base.clone() }; + let reviewer_stg = ClReviewerStorage { base: base.clone() }; + let now = chrono::Utc::now().naive_utc(); + + mega_cl::ActiveModel { + id: Set(1), + link: Set("CL2".to_string()), + title: Set("t".to_string()), + merge_date: Set(None), + status: Set(callisto::sea_orm_active_enums::MergeStatusEnum::Open), + path: Set("/".to_string()), + from_hash: Set("a".to_string()), + to_hash: Set("b".to_string()), + created_at: Set(now), + updated_at: Set(now), + username: Set("alice".to_string()), + base_branch: Set("main".to_string()), + } + .insert(&db) + .await + .unwrap(); + + notif + .upsert_user_settings("alice", "alice@example.com") + .await + .unwrap(); + notif.set_global_enabled("alice", false).await.unwrap(); + + on_cl_comment_created(¬if, &cl_stg, &reviewer_stg, "bob", "CL2", "hello") + .await + .unwrap(); + + let jobs = email_jobs::Entity::find().all(&db).await.unwrap(); + assert_eq!(jobs.len(), 0); + } +} diff --git a/ceres/src/application/webhook/admin.rs b/ceres/src/application/webhook/admin.rs new file mode 100644 index 000000000..0cd0c26e8 --- /dev/null +++ b/ceres/src/application/webhook/admin.rs @@ -0,0 +1,68 @@ +//! Webhook CRUD for the mono HTTP API. + +use api_model::common::Pagination; +use common::errors::MegaError; +use jupiter::{ + idgenerator::IdInstance, + service::webhook_service::{encrypt_webhook_secret, validate_webhook_target_url}, + storage::webhook_storage::WebhookWithEventTypes, +}; + +use crate::{ + application::api_service::mono::MonoApiService, + model::webhook::{CreateWebhookRequest, WebhookResponse, parse_webhook_event_types}, +}; + +impl MonoApiService { + pub async fn create_webhook( + &self, + payload: CreateWebhookRequest, + ) -> Result { + validate_webhook_target_url(&payload.target_url) + .map_err(|e| MegaError::Other(e.to_string()))?; + if payload.secret.is_empty() { + return Err(MegaError::Other( + "webhook secret cannot be empty".to_string(), + )); + } + + let encrypted_secret = encrypt_webhook_secret(&payload.secret)?; + let event_types = + parse_webhook_event_types(payload.event_types).map_err(MegaError::Other)?; + + let now = chrono::Utc::now().naive_utc(); + let model = callisto::mega_webhook::Model { + id: IdInstance::next_id(), + target_url: payload.target_url, + secret: encrypted_secret, + event_types: serde_json::to_string(&event_types).unwrap_or_else(|_| "[]".to_string()), + path_filter: payload.path_filter, + active: payload.active.unwrap_or(true), + created_at: now, + updated_at: now, + }; + + let created: WebhookWithEventTypes = self + .storage + .webhook_storage() + .create_webhook(model, event_types) + .await?; + Ok(created.into()) + } + + pub async fn list_webhooks( + &self, + pagination: Pagination, + ) -> Result<(Vec, u64), MegaError> { + let (webhooks, total) = self + .storage + .webhook_storage() + .list_webhooks(pagination) + .await?; + Ok((webhooks.into_iter().map(|w| w.into()).collect(), total)) + } + + pub async fn delete_webhook(&self, id: i64) -> Result<(), MegaError> { + self.storage.webhook_storage().delete_webhook(id).await + } +} diff --git a/ceres/src/application/webhook/delivery.rs b/ceres/src/application/webhook/delivery.rs new file mode 100644 index 000000000..f28b915ba --- /dev/null +++ b/ceres/src/application/webhook/delivery.rs @@ -0,0 +1,30 @@ +//! CL lifecycle webhook delivery. + +use callisto::mega_cl; +use common::errors::MegaError; +pub use jupiter::service::webhook_service::{ + ClPayload, RepositoryPayload, WebhookEvent, WebhookPayload, +}; +use jupiter::{service::webhook_service::WebhookService, storage::Storage}; + +/// Dispatches CL webhook events asynchronously. +#[derive(Clone)] +pub struct WebhookDispatcher { + service: WebhookService, +} + +impl WebhookDispatcher { + pub fn from_storage(storage: &Storage) -> Result { + Ok(Self { + service: storage.webhook_service.clone(), + }) + } + + pub fn dispatch(&self, event_type: WebhookEvent, cl_model: &mega_cl::Model) { + self.service.dispatch(event_type, cl_model); + } +} + +pub fn dispatch_cl_webhook(storage: &Storage, event_type: WebhookEvent, cl_model: &mega_cl::Model) { + storage.webhook_service.dispatch(event_type, cl_model); +} diff --git a/ceres/src/application/webhook/mod.rs b/ceres/src/application/webhook/mod.rs index 86c00d2cb..daed2d545 100644 --- a/ceres/src/application/webhook/mod.rs +++ b/ceres/src/application/webhook/mod.rs @@ -1,30 +1,9 @@ -//! Webhook delivery facade (orchestration entry point for CL lifecycle events). +//! Webhook orchestration: CL event delivery and HTTP admin CRUD. -use callisto::mega_cl; -use common::errors::MegaError; -pub use jupiter::service::webhook_service::{ - ClPayload, RepositoryPayload, WebhookEvent, WebhookPayload, -}; -use jupiter::{service::webhook_service::WebhookService, storage::Storage}; - -/// Dispatches CL webhook events asynchronously. -#[derive(Clone)] -pub struct WebhookDispatcher { - service: WebhookService, -} - -impl WebhookDispatcher { - pub fn from_storage(storage: &Storage) -> Result { - Ok(Self { - service: storage.webhook_service.clone(), - }) - } +pub mod admin; +pub mod delivery; - pub fn dispatch(&self, event_type: WebhookEvent, cl_model: &mega_cl::Model) { - self.service.dispatch(event_type, cl_model); - } -} - -pub fn dispatch_cl_webhook(storage: &Storage, event_type: WebhookEvent, cl_model: &mega_cl::Model) { - storage.webhook_service.dispatch(event_type, cl_model); -} +pub use delivery::{ + ClPayload, RepositoryPayload, WebhookDispatcher, WebhookEvent, WebhookPayload, + dispatch_cl_webhook, +}; diff --git a/ceres/src/bus/runtime.rs b/ceres/src/bus/runtime.rs index 2176ef964..10b4307a7 100644 --- a/ceres/src/bus/runtime.rs +++ b/ceres/src/bus/runtime.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use jupiter::storage::Storage; use super::handler::ApplicationEventHandler; -use crate::{code_edit::post_receive::RuntimeApplicationHandler, infra::cache::GitObjectCache}; +use crate::{ + application::code_edit::post_receive::RuntimeApplicationHandler, infra::cache::GitObjectCache, +}; #[derive(Clone)] pub struct TransportRuntime { diff --git a/ceres/src/infra/context.rs b/ceres/src/infra/context.rs new file mode 100644 index 000000000..dbe6b928f --- /dev/null +++ b/ceres/src/infra/context.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use jupiter::storage::Storage; + +use super::cache::GitObjectCache; + +/// Transport-agnostic handles shared by Git transport handlers and application services. +#[derive(Clone)] +pub struct TransportContext { + pub storage: Storage, + pub git_object_cache: Arc, +} + +impl TransportContext { + pub fn new(storage: Storage, git_object_cache: Arc) -> Self { + Self { + storage, + git_object_cache, + } + } +} diff --git a/ceres/src/infra/mod.rs b/ceres/src/infra/mod.rs index a5c08fdb0..1cb04dc4f 100644 --- a/ceres/src/infra/mod.rs +++ b/ceres/src/infra/mod.rs @@ -1 +1,8 @@ pub mod cache; +pub mod context; +pub mod pack_decode; +pub mod pack_stream; + +pub use context::TransportContext; +pub use pack_decode::map_decode_stream_error; +pub use pack_stream::{PackByteStream, PackStreamError, into_pack_byte_stream}; diff --git a/ceres/src/infra/pack_decode.rs b/ceres/src/infra/pack_decode.rs new file mode 100644 index 000000000..62358ccd9 --- /dev/null +++ b/ceres/src/infra/pack_decode.rs @@ -0,0 +1,11 @@ +//! Isolates `axum-core` stream error mapping required by `git-internal` pack decode. +//! +//! `git-internal` still types decode streams with `axum_core::Error`; keep that +//! dependency confined here until upstream accepts `std::io::Error`. + +use std::fmt::Display; + +/// Maps a pack decode stream error into the type expected by `Pack::decode_stream`. +pub fn map_decode_stream_error(err: E) -> axum_core::Error { + axum_core::Error::new(std::io::Error::other(err.to_string())) +} diff --git a/ceres/src/infra/pack_stream.rs b/ceres/src/infra/pack_stream.rs new file mode 100644 index 000000000..d6746727d --- /dev/null +++ b/ceres/src/infra/pack_stream.rs @@ -0,0 +1,16 @@ +use std::pin::Pin; + +use bytes::Bytes; +use futures::{Stream, TryStreamExt}; + +/// Framework-neutral receive-pack body stream (decoupled from axum). +pub type PackStreamError = Box; +pub type PackByteStream = Pin> + Send>>; + +pub fn into_pack_byte_stream(stream: S) -> PackByteStream +where + S: Stream> + Send + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + Box::pin(stream.map_err(|e| Box::new(e) as PackStreamError)) +} diff --git a/ceres/src/lib.rs b/ceres/src/lib.rs index 0abdf1fd8..764c6c406 100644 --- a/ceres/src/lib.rs +++ b/ceres/src/lib.rs @@ -9,26 +9,9 @@ pub mod merge_checker; pub mod model; pub mod transport; -// Legacy module paths (internal + `mono` crate compatibility). -pub mod api_service { - pub use crate::application::api_service::*; -} -pub mod build_trigger { - pub use crate::application::build_trigger::*; -} -pub mod code_edit { - pub use crate::application::code_edit::*; -} -pub mod pack { - pub use crate::transport::pack::*; -} -pub mod protocol { - pub use crate::transport::protocol::*; -} - pub use application::api_service::{ - ADMIN_FILE, EffectiveResourcePermission, MonoApiService, MonoServiceLogic, RefUpdate, - TreeUpdateResult, cl_merge, + ADMIN_FILE, EffectiveResourcePermission, MonoApiService, MonoAppServices, MonoServiceLogic, + RefUpdate, TreeUpdateResult, cl_merge, }; pub use bus::{ApplicationEventHandler, TransportEvent, TransportRuntime}; pub use transport::ProtocolApiState; diff --git a/ceres/src/merge_checker/cl_sync_checker.rs b/ceres/src/merge_checker/cl_sync_checker.rs index 6a478cad7..1d6dfd3cc 100644 --- a/ceres/src/merge_checker/cl_sync_checker.rs +++ b/ceres/src/merge_checker/cl_sync_checker.rs @@ -63,3 +63,39 @@ impl Checker for ClSyncChecker { })) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jupiter::storage::Storage; + use serde_json::json; + + use super::ClSyncChecker; + use crate::merge_checker::{CheckType, Checker, ConditionResult}; + + #[tokio::test] + async fn cl_sync_checker_passes_when_hashes_match() { + let checker = ClSyncChecker { + storage: Arc::new(Storage::mock()), + }; + let result = checker + .run(&json!({"cl_from": "abc123", "current": "abc123"})) + .await; + assert_eq!(result.check_type_code, CheckType::ClSync); + assert_eq!(result.status, ConditionResult::PASSED); + } + + #[tokio::test] + async fn cl_sync_checker_fails_when_hashes_differ() { + let checker = ClSyncChecker { + storage: Arc::new(Storage::mock()), + }; + let result = checker + .run(&json!({"cl_from": "abc123", "current": "def456"})) + .await; + assert_eq!(result.check_type_code, CheckType::ClSync); + assert_eq!(result.status, ConditionResult::FAILED); + assert!(!result.message.is_empty()); + } +} diff --git a/ceres/src/merge_checker/commit_message_checker.rs b/ceres/src/merge_checker/commit_message_checker.rs index 588b3a836..b8c826a50 100644 --- a/ceres/src/merge_checker/commit_message_checker.rs +++ b/ceres/src/merge_checker/commit_message_checker.rs @@ -36,3 +36,30 @@ impl Checker for CommitMessageChecker { })) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::CommitMessageChecker; + use crate::merge_checker::{CheckType, Checker, ConditionResult}; + + #[tokio::test] + async fn commit_message_checker_passes_conventional_title() { + let checker = CommitMessageChecker; + let result = checker + .run(&json!({"title": "feat: add webhook support"})) + .await; + assert_eq!(result.check_type_code, CheckType::CommitMessage); + assert_eq!(result.status, ConditionResult::PASSED); + } + + #[tokio::test] + async fn commit_message_checker_fails_non_conventional_title() { + let checker = CommitMessageChecker; + let result = checker.run(&json!({"title": "add webhook support"})).await; + assert_eq!(result.check_type_code, CheckType::CommitMessage); + assert_eq!(result.status, ConditionResult::FAILED); + assert!(!result.message.is_empty()); + } +} diff --git a/ceres/src/model/admin.rs b/ceres/src/model/admin.rs new file mode 100644 index 000000000..781bc1d86 --- /dev/null +++ b/ceres/src/model/admin.rs @@ -0,0 +1,12 @@ +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct IsAdminResponse { + pub is_admin: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct AdminListResponse { + pub admins: Vec, +} diff --git a/ceres/src/model/bots.rs b/ceres/src/model/bots.rs index 4d6db4819..8f0ccd8a8 100644 --- a/ceres/src/model/bots.rs +++ b/ceres/src/model/bots.rs @@ -2,6 +2,7 @@ use callisto::{ bot_installations, sea_orm_active_enums::{InstallationBotStatusEnum, InstallationTargetTypeEnum}, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -88,3 +89,49 @@ impl From for InstallationTargetTypeEnum { } } } + +/// Authenticated bot principal resolved from a `bot_` bearer token. +#[derive(Debug, Clone)] +pub struct BotIdentity { + pub bot_id: i64, + pub token_id: i64, +} + +impl BotIdentity { + pub fn from_models(bot: callisto::bots::Model, token: callisto::bot_tokens::Model) -> Self { + Self { + bot_id: bot.id, + token_id: token.id, + } + } +} + +/// Request body for creating a new bot token. +#[derive(Deserialize, ToSchema)] +pub struct CreateBotTokenRequest { + /// Human-readable token name for identification. + pub token_name: String, + /// Optional relative expiry in seconds from now. + pub expires_in: Option, +} + +/// Response body when a bot token is created. +/// +/// Note: `token_plain` is only returned once and is never stored in plaintext. +#[derive(Serialize, ToSchema)] +pub struct CreateBotTokenResponse { + pub id: i64, + pub token_name: String, + pub expires_at: Option>, + pub token_plain: String, +} + +/// Item in the list bot tokens response. +#[derive(Serialize, ToSchema)] +pub struct ListBotTokenItem { + pub id: i64, + pub token_name: String, + pub expires_at: Option>, + pub revoked: bool, + pub created_at: DateTime, +} diff --git a/ceres/src/model/commit.rs b/ceres/src/model/commit.rs index 52fce0358..db570c1b9 100644 --- a/ceres/src/model/commit.rs +++ b/ceres/src/model/commit.rs @@ -49,3 +49,9 @@ pub struct CommitFilesChangedPage { pub struct CommitBindingResponse { pub username: Option, } + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct UpdateCommitBindingRequest { + pub username: Option, + pub is_anonymous: bool, +} diff --git a/ceres/src/model/conversation.rs b/ceres/src/model/conversation.rs index 29a8105a5..01a3ecfff 100644 --- a/ceres/src/model/conversation.rs +++ b/ceres/src/model/conversation.rs @@ -122,3 +122,54 @@ impl From for ConvType { } } } + +impl From for ConvTypeEnum { + fn from(value: ConvType) -> Self { + match value { + ConvType::Comment => ConvTypeEnum::Comment, + ConvType::Deploy => ConvTypeEnum::Deploy, + ConvType::Commit => ConvTypeEnum::Commit, + ConvType::ForcePush => ConvTypeEnum::ForcePush, + ConvType::Edit => ConvTypeEnum::Edit, + ConvType::Review => ConvTypeEnum::Review, + ConvType::Approve => ConvTypeEnum::Approve, + ConvType::MergeQueue => ConvTypeEnum::MergeQueue, + ConvType::Merged => ConvTypeEnum::Merged, + ConvType::Closed => ConvTypeEnum::Closed, + ConvType::Reopen => ConvTypeEnum::Reopen, + ConvType::Label => ConvTypeEnum::Label, + ConvType::Assignee => ConvTypeEnum::Assignee, + ConvType::Mention => ConvTypeEnum::Mention, + ConvType::Draft => ConvTypeEnum::Draft, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceType { + Mention, + BuildRelates, + Blocks, +} + +impl From for ReferenceType { + fn from(value: callisto::sea_orm_active_enums::ReferenceTypeEnum) -> Self { + use callisto::sea_orm_active_enums::ReferenceTypeEnum; + match value { + ReferenceTypeEnum::Mention => ReferenceType::Mention, + ReferenceTypeEnum::BuildRelates => ReferenceType::BuildRelates, + ReferenceTypeEnum::Blocks => ReferenceType::Blocks, + } + } +} + +impl From for callisto::sea_orm_active_enums::ReferenceTypeEnum { + fn from(value: ReferenceType) -> Self { + use callisto::sea_orm_active_enums::ReferenceTypeEnum; + match value { + ReferenceType::Mention => ReferenceTypeEnum::Mention, + ReferenceType::BuildRelates => ReferenceTypeEnum::BuildRelates, + ReferenceType::Blocks => ReferenceTypeEnum::Blocks, + } + } +} diff --git a/ceres/src/model/gpg.rs b/ceres/src/model/gpg.rs index e3a3a3ac0..320e0f490 100644 --- a/ceres/src/model/gpg.rs +++ b/ceres/src/model/gpg.rs @@ -1,3 +1,4 @@ +use callisto::gpg_key; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -20,3 +21,15 @@ pub struct GpgKey { pub created_at: DateTime, pub expires_at: Option>, } + +impl GpgKey { + pub fn from_stored(user_id: String, key: gpg_key::Model) -> Self { + Self { + user_id, + key_id: key.key_id, + fingerprint: key.fingerprint, + created_at: key.created_at.and_utc(), + expires_at: key.expires_at.map(|dt| dt.and_utc()), + } + } +} diff --git a/ceres/src/model/group.rs b/ceres/src/model/group.rs index ee96da02c..881418787 100644 --- a/ceres/src/model/group.rs +++ b/ceres/src/model/group.rs @@ -42,7 +42,7 @@ pub struct GroupMemberResponse { pub joined_at: i64, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum PermissionValue { Read, @@ -64,7 +64,7 @@ impl PermissionValue { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ResourceTypeValue { Note, @@ -215,3 +215,34 @@ impl From for ResourceTypeValue { } } } + +#[cfg(test)] +mod tests { + use callisto::sea_orm_active_enums::PermissionEnum; + + use super::{PermissionValue, ResourceTypeValue}; + + #[test] + fn permission_value_satisfies_hierarchy() { + assert!(PermissionValue::Admin.satisfies(PermissionValue::Write)); + assert!(PermissionValue::Write.satisfies(PermissionValue::Read)); + assert!(!PermissionValue::Read.satisfies(PermissionValue::Write)); + } + + #[test] + fn resource_type_value_try_from_note() { + assert_eq!( + ResourceTypeValue::try_from("note").unwrap(), + ResourceTypeValue::Note + ); + assert!(ResourceTypeValue::try_from("issue").is_err()); + } + + #[test] + fn permission_value_round_trips_with_permission_enum() { + let write = PermissionValue::Write; + let as_enum: PermissionEnum = write.into(); + let back: PermissionValue = as_enum.into(); + assert_eq!(back, write); + } +} diff --git a/ceres/src/model/mod.rs b/ceres/src/model/mod.rs index 70960b94c..baf1f9ce7 100644 --- a/ceres/src/model/mod.rs +++ b/ceres/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod blame; pub mod bots; pub mod buck; @@ -12,7 +13,9 @@ pub mod group; pub mod issue; pub mod label; pub mod merge_queue; +pub mod note; pub mod notification; pub mod tag; pub mod third_party; pub mod user; +pub mod webhook; diff --git a/mono/src/api/notes/model.rs b/ceres/src/model/note.rs similarity index 89% rename from mono/src/api/notes/model.rs rename to ceres/src/model/note.rs index fb982de69..df2dfc768 100644 --- a/mono/src/api/notes/model.rs +++ b/ceres/src/model/note.rs @@ -2,14 +2,14 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; #[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct UpdateRequest { +pub struct NoteUpdateRequest { pub description_html: String, pub description_state: String, pub description_schema_version: i32, } #[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ShowResponse { +pub struct NoteShowResponse { #[serde(rename = "id")] pub public_id: String, diff --git a/ceres/src/model/third_party.rs b/ceres/src/model/third_party.rs index d0f44a621..c9ea3b72b 100644 --- a/ceres/src/model/third_party.rs +++ b/ceres/src/model/third_party.rs @@ -4,7 +4,6 @@ use std::{ str::from_utf8, }; -use axum::Error as AxumError; use bytes::{BufMut, Bytes, BytesMut}; use common::errors::MegaError; use futures::{Stream, StreamExt}; @@ -216,7 +215,7 @@ impl ThirdPartyClient { pub async fn process_pack_stream( &self, res: impl Stream> + Unpin, - ) -> Result, AxumError> { + ) -> Result, MegaError> { let stream = res.map(|r| r.map_err(|e| io::Error::other(format!("reqwest error: {e}")))); let mut reader = StreamReader::new(stream); @@ -292,6 +291,9 @@ impl ThirdPartyClient { #[cfg(test)] mod tests { + use bytes::Bytes; + use futures::stream; + use super::*; #[test] @@ -302,4 +304,33 @@ mod tests { assert!(text.contains("deepen 1")); assert!(text.contains("want abc123")); } + + #[tokio::test] + async fn process_pack_stream_continues_after_flush_before_pack() { + let client = ThirdPartyClient::new("https://github.com/foo/bar.git"); + + // pkt-line: shallow negotiation line, flush (0000), then PACK payload + let shallow_payload = b"shallow 1\n"; + let shallow_line = format!("{:04x}", shallow_payload.len() + 4); + let pack_chunk = b"PACK1234"; + let pack_line = format!("{:04x}", pack_chunk.len() + 4); + let mut body = Vec::new(); + body.extend_from_slice(shallow_line.as_bytes()); + body.extend_from_slice(shallow_payload); + body.extend_from_slice(b"0000"); + body.extend_from_slice(pack_line.as_bytes()); + body.extend_from_slice(pack_chunk); + + let stream = stream::iter(vec![Ok(Bytes::from(body))]); + let pack_data = client + .process_pack_stream(stream) + .await + .expect("process_pack_stream should succeed"); + + assert!( + !pack_data.is_empty(), + "PACK data should be read after 0000 flush" + ); + assert!(pack_data.starts_with(b"PACK")); + } } diff --git a/ceres/src/model/webhook.rs b/ceres/src/model/webhook.rs new file mode 100644 index 000000000..128ff27d0 --- /dev/null +++ b/ceres/src/model/webhook.rs @@ -0,0 +1,111 @@ +use callisto::sea_orm_active_enums::WebhookEventTypeEnum; +use jupiter::{sea_orm::ActiveEnum, storage::webhook_storage::WebhookWithEventTypes}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateWebhookRequest { + pub target_url: String, + pub secret: String, + /// Event types: "cl.created", "cl.updated", "cl.merged", "cl.closed", "cl.reopened", "cl.comment.created", "*" + pub event_types: Vec, + pub path_filter: Option, + pub active: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct WebhookResponse { + pub id: i64, + pub target_url: String, + pub event_types: Vec, + pub path_filter: Option, + pub active: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct ListWebhooksQuery { + pub page: Option, + pub per_page: Option, +} + +impl From for WebhookResponse { + fn from(value: WebhookWithEventTypes) -> Self { + let m = value.webhook; + Self { + id: m.id, + target_url: m.target_url, + event_types: value + .event_types + .into_iter() + .map(|e| e.to_value()) + .collect(), + path_filter: m.path_filter, + active: m.active, + created_at: m.created_at.to_string(), + updated_at: m.updated_at.to_string(), + } + } +} + +pub fn parse_webhook_event_types(raw: Vec) -> Result, String> { + raw.into_iter() + .map(|s| { + WebhookEventTypeEnum::try_from_value(&s).map_err(|_| format!("invalid event type: {s}")) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use callisto::sea_orm_active_enums::WebhookEventTypeEnum; + use chrono::NaiveDateTime; + + use super::*; + + #[test] + fn parse_webhook_event_types_accepts_known_values() { + let parsed = parse_webhook_event_types(vec!["cl.created".to_string(), "all".to_string()]) + .expect("valid event types"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0], WebhookEventTypeEnum::ClCreated); + assert_eq!(parsed[1], WebhookEventTypeEnum::All); + } + + #[test] + fn parse_webhook_event_types_rejects_unknown_values() { + let err = parse_webhook_event_types(vec!["not.a.real.event".to_string()]) + .expect_err("invalid event type"); + assert!(err.contains("invalid event type")); + } + + #[test] + fn webhook_response_from_maps_fields() { + use jupiter::storage::webhook_storage::WebhookWithEventTypes; + + let now = + NaiveDateTime::parse_from_str("2025-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let webhook = callisto::mega_webhook::Model { + id: 42, + target_url: "https://example.com/hook".to_string(), + secret: "enc".to_string(), + event_types: "[]".to_string(), + path_filter: Some("/project".to_string()), + active: true, + created_at: now, + updated_at: now, + }; + let value = WebhookWithEventTypes { + webhook, + event_types: vec![WebhookEventTypeEnum::ClCreated], + }; + + let response = WebhookResponse::from(value); + assert_eq!(response.id, 42); + assert_eq!(response.target_url, "https://example.com/hook"); + assert_eq!(response.event_types, vec!["cl.created".to_string()]); + assert_eq!(response.path_filter.as_deref(), Some("/project")); + assert!(response.active); + } +} diff --git a/ceres/src/transport/pack/import_repo.rs b/ceres/src/transport/pack/import_repo.rs index 69b5f9821..593921228 100644 --- a/ceres/src/transport/pack/import_repo.rs +++ b/ceres/src/transport/pack/import_repo.rs @@ -34,12 +34,14 @@ use tokio::sync::mpsc::{self, Sender}; use tokio_stream::wrappers::ReceiverStream; use crate::{ - api_service::cache::GitObjectCache, + application::api_service::cache::GitObjectCache, bus::{ApplicationEventHandler, TransportEvent}, - pack::RepoHandler, - protocol::{ - import_refs::{CommandType, RefCommand, Refs}, - repo::Repo, + transport::{ + pack::RepoHandler, + protocol::{ + import_refs::{CommandType, RefCommand, Refs}, + repo::Repo, + }, }, }; diff --git a/ceres/src/transport/pack/mod.rs b/ceres/src/transport/pack/mod.rs index b1433e276..8877edd3e 100644 --- a/ceres/src/transport/pack/mod.rs +++ b/ceres/src/transport/pack/mod.rs @@ -1,6 +1,5 @@ use std::{ collections::{HashMap, HashSet}, - pin::Pin, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -9,13 +8,12 @@ use std::{ }; use async_trait::async_trait; -use bytes::Bytes; use common::{ config::PackConfig, errors::{MegaError, ProtocolError}, utils::ZERO_ID, }; -use futures::{Stream, TryStreamExt, future::join_all}; +use futures::{TryStreamExt, future::join_all}; use git_internal::{ errors::GitError, hash::ObjectHash, @@ -38,17 +36,7 @@ use crate::transport::protocol::import_refs::{RefCommand, Refs}; pub mod import_repo; pub mod monorepo; -/// Framework-neutral receive-pack body stream (decoupled from axum). -pub type PackStreamError = Box; -pub type PackByteStream = Pin> + Send>>; - -pub fn into_pack_byte_stream(stream: S) -> PackByteStream -where - S: Stream> + Send + 'static, - E: std::error::Error + Send + Sync + 'static, -{ - Box::pin(stream.map_err(|e| Box::new(e) as PackStreamError)) -} +pub use crate::infra::pack_stream::{PackByteStream, PackStreamError, into_pack_byte_stream}; #[async_trait] pub trait RepoHandler: Send + Sync + 'static { @@ -289,8 +277,7 @@ pub trait RepoHandler: Send + Sync + 'static { Some(pack_config.pack_decode_cache_path.clone()), pack_config.clean_cache_after_decode, ); - let decode_stream = - stream.map_err(|e| axum::Error::new(std::io::Error::other(e.to_string()))); + let decode_stream = stream.map_err(crate::infra::map_decode_stream_error); p.decode_stream(decode_stream, sender, Some(pack_id_sender)) .await; Ok((receiver, pack_id_receiver)) diff --git a/ceres/src/transport/pack/monorepo.rs b/ceres/src/transport/pack/monorepo.rs index 8ddf82c70..eeef6a056 100644 --- a/ceres/src/transport/pack/monorepo.rs +++ b/ceres/src/transport/pack/monorepo.rs @@ -35,11 +35,12 @@ use tokio::sync::{RwLock, mpsc}; use tokio_stream::wrappers::ReceiverStream; use crate::{ - api_service::{cache::GitObjectCache, mono::MonoApiService}, bus::{ApplicationEventHandler, TransportEvent}, - model::change_list::ClDiffFile, - pack::RepoHandler, - protocol::import_refs::{RefCommand, Refs}, + infra::cache::GitObjectCache, + transport::{ + pack::RepoHandler, + protocol::import_refs::{RefCommand, Refs}, + }, }; pub struct MonoRepo { @@ -55,7 +56,7 @@ pub struct MonoRepo { pub cl_link: Arc>>, pub application: Arc, pub username: Option, - /// Ref commands for this push (same role as on [`ImportRepo`](crate::pack::import_repo::ImportRepo)). + /// Ref commands for this push (same role as on [`ImportRepo`](crate::transport::pack::import_repo::ImportRepo)). pub command_list: Mutex>, } @@ -616,21 +617,4 @@ impl MonoRepo { pub fn username(&self) -> String { self.username.clone().unwrap_or(String::from("Anonymous")) } - - pub async fn get_commit_blobs( - &self, - commit_hash: &str, - ) -> Result, MegaError> { - let api_service: MonoApiService = self.into(); - api_service.get_commit_blobs(commit_hash).await - } - - pub async fn cl_files_list( - &self, - old_files: Vec<(PathBuf, ObjectHash)>, - new_files: Vec<(PathBuf, ObjectHash)>, - ) -> Result, MegaError> { - let api_service: MonoApiService = self.into(); - api_service.cl_files_list(old_files, new_files).await - } } diff --git a/ceres/src/transport/protocol/mod.rs b/ceres/src/transport/protocol/mod.rs index 06d0bc028..ec0e825c3 100644 --- a/ceres/src/transport/protocol/mod.rs +++ b/ceres/src/transport/protocol/mod.rs @@ -15,7 +15,7 @@ use tokio::sync::RwLock; use crate::{ bus::TransportRuntime, - pack::{RepoHandler, import_repo::ImportRepo, monorepo::MonoRepo}, + transport::pack::{RepoHandler, import_repo::ImportRepo, monorepo::MonoRepo}, }; pub mod import_refs; @@ -228,4 +228,37 @@ impl SmartSession { } #[cfg(test)] -mod tests {} +mod tests { + use std::str::FromStr; + + use super::{Capability, ServiceType, SideBind}; + + #[test] + fn service_type_from_str_parses_known_services() { + assert_eq!( + ServiceType::from_str("git-upload-pack").unwrap(), + ServiceType::UploadPack + ); + assert_eq!( + ServiceType::from_str("git-receive-pack").unwrap(), + ServiceType::ReceivePack + ); + assert!(ServiceType::from_str("git-invalid").is_err()); + } + + #[test] + fn capability_from_str_parses_known_values() { + assert_eq!( + Capability::from_str("report-status-v2").unwrap(), + Capability::ReportStatusv2 + ); + assert!(Capability::from_str("unknown-cap").is_err()); + } + + #[test] + fn side_bind_values_match_git_sideband_codes() { + assert_eq!(SideBind::PackfileData.value(), 1); + assert_eq!(SideBind::ProgressInfo.value(), 2); + assert_eq!(SideBind::Error.value(), 3); + } +} diff --git a/ceres/src/transport/protocol/smart.rs b/ceres/src/transport/protocol/smart.rs index 7267ec324..268a92131 100644 --- a/ceres/src/transport/protocol/smart.rs +++ b/ceres/src/transport/protocol/smart.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::Result; use bytes::{Buf, BufMut, Bytes, BytesMut}; use callisto::sea_orm_active_enums::RefTypeEnum; -use common::errors::ProtocolError; +use common::errors::{ProtocolError, mega_to_protocol_error}; use tokio_stream::wrappers::ReceiverStream; use crate::{ @@ -303,7 +303,7 @@ impl SmartSession { c.failed(msg.clone()); } } - return Err(e.into()); + return Err(mega_to_protocol_error(e)); } finalize_ms = Some(t_finalize.elapsed().as_millis()); @@ -523,7 +523,7 @@ pub fn add_pkt_line_string(pkt_line_stream: &mut BytesMut, buf_str: String) { /// /// ``` /// use bytes::Bytes; -/// use ceres::protocol::smart::read_pkt_line; +/// use ceres::transport::protocol::smart::read_pkt_line; /// /// let mut bytes = Bytes::from_static(b"000Bexample"); /// let (length, line) = read_pkt_line(&mut bytes); @@ -557,7 +557,7 @@ pub mod test { use tempfile::TempDir; use tokio::{task, time::sleep}; - use crate::protocol::{ + use crate::transport::protocol::{ Capability, ServiceType, SmartSession, TransportProtocol, import_refs::{CommandType, RefCommand}, smart::{add_pkt_line_string, read_pkt_line, read_until_white_space}, diff --git a/common/Cargo.toml b/common/Cargo.toml index eb249ba80..013878d5f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -11,9 +11,7 @@ path = "src/lib.rs" [dependencies] git-internal = { workspace = true } -api-model = { workspace = true } -axum = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/common/README.md b/common/README.md index ea09a7d7c..eb2a1fea3 100644 --- a/common/README.md +++ b/common/README.md @@ -1 +1,15 @@ -## Common Module \ No newline at end of file +# common + +Shared workspace crate: configuration loading, error types, and utilities used by `mono`, `ceres`, `jupiter`, and other crates. + +## Configuration + +- Default template: [`config/config.toml`](../config/config.toml) +- 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 + +## Errors + +- `MegaError` / `MegaResult` — application errors +- `ProtocolError` — Git protocol errors (HTTP mapping in `mono`) diff --git a/common/src/errors.rs b/common/src/errors.rs index 8e5a54341..c69797752 100644 --- a/common/src/errors.rs +++ b/common/src/errors.rs @@ -1,12 +1,6 @@ use std::convert::Infallible; use anyhow::Result; -use api_model::common::CommonResult; -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; use cedar_policy::ParseErrors; use config::ConfigError; use git_internal::errors::GitError; @@ -61,6 +55,21 @@ pub enum MegaError { #[error("Not Found error: {0}")] NotFound(String), + #[error("Bad request: {0}")] + BadRequest(String), + + #[error("Conflict: {0}")] + Conflict(String), + + #[error("Forbidden: {0}")] + Forbidden(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("Service unavailable: {0}")] + Unavailable(String), + #[error("ObjStorage error: {0}")] ObjStorage(String), @@ -82,6 +91,47 @@ pub enum MegaError { Other(String), } +impl MegaError { + pub fn bad_request(msg: impl Into) -> Self { + Self::BadRequest(msg.into()) + } + + pub fn conflict(msg: impl Into) -> Self { + Self::Conflict(msg.into()) + } + + pub fn forbidden(msg: impl Into) -> Self { + Self::Forbidden(msg.into()) + } + + pub fn unauthorized(msg: impl Into) -> Self { + Self::Unauthorized(msg.into()) + } + + pub fn unavailable(msg: impl Into) -> Self { + Self::Unavailable(msg.into()) + } + + /// Parse legacy `[code:xxx] message` strings into typed variants. + pub fn from_legacy_message(msg: impl Into) -> Self { + let msg = msg.into(); + if let Some((code, clean)) = parse_legacy_http_code(&msg) { + return match code { + 400 => Self::BadRequest(clean.to_string()), + 401 => Self::Unauthorized(clean.to_string()), + 403 => Self::Forbidden(clean.to_string()), + 404 => Self::NotFound(clean.to_string()), + 409 => Self::Conflict(clean.to_string()), + 413 => Self::BadRequest(clean.to_string()), + 416 => Self::BadRequest(clean.to_string()), + 503 => Self::Unavailable(clean.to_string()), + _ => Self::Other(msg), + }; + } + Self::Other(msg) + } +} + impl From for MegaError { fn from(err: Infallible) -> MegaError { match err {} @@ -94,13 +144,129 @@ impl From for MegaError { } } +/// Parse `[code:xxx]` anywhere in a message. Returns (status_code, clean_message). +pub fn parse_legacy_http_code(err_str: &str) -> Option<(u16, &str)> { + let start = err_str.find("[code:")?; + let code_start = start + 6; + let remaining = &err_str[start..]; + let code_end_relative = remaining.find(']')?; + if code_end_relative <= 6 { + return None; + } + let code_end = start + code_end_relative; + let code = &err_str[code_start..code_end]; + if code.is_empty() || !code.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let status: u16 = code.parse().ok()?; + let msg_start = code_end + 1; + let msg = err_str.get(msg_start..).unwrap_or("").trim_start(); + Some((status, msg)) +} + +pub fn buck_error_http_status(err: &BuckError) -> u16 { + match err { + BuckError::SessionNotFound(_) | BuckError::FileNotInManifest(_) => 404, + BuckError::SessionExpired => 410, + BuckError::RateLimitExceeded => 429, + BuckError::FileSizeExceedsLimit(_, _) => 413, + BuckError::FileAlreadyUploaded(_) => 409, + BuckError::Forbidden(_) => 403, + BuckError::HashMismatch { .. } + | BuckError::ValidationError(_) + | BuckError::InvalidSessionStatus { .. } + | BuckError::FilesNotFullyUploaded { .. } => 400, + } +} + +pub fn git_error_http_status(err: &GitError) -> u16 { + match err { + GitError::ObjectNotFound(_) | GitError::RepoNotFound | GitError::NotFoundHashValue(_) => { + 404 + } + GitError::UnAuthorized(_) => 401, + GitError::InvalidObjectType(_) + | GitError::InvalidBlobObject(_) + | GitError::InvalidTreeObject + | GitError::InvalidTreeItem(_) + | GitError::EmptyTreeItems(_) + | GitError::InvalidSignatureType(_) + | GitError::InvalidCommitObject + | GitError::InvalidCommit(_) + | GitError::InvalidTagObject(_) + | GitError::InvalidNoteObject(_) + | GitError::InvalidPathError(_) + | GitError::ConversionError(_) => 400, + GitError::CustomError(msg) => parse_legacy_http_code(msg) + .map(|(code, _)| code) + .unwrap_or_else(|| git_custom_error_http_status(msg)), + _ => 500, + } +} + +fn git_custom_error_http_status(msg: &str) -> u16 { + let lower = msg.to_ascii_lowercase(); + if lower.contains("not found") || lower.contains("doesn't exist") { + 404 + } else if lower.contains("duplicate") + || lower.contains("invalid") + || lower.contains("required") + || lower.contains("conflict") + { + 400 + } else if lower.contains("forbidden") || lower.contains("denied") { + 403 + } else { + 500 + } +} + +/// Suggested HTTP status code for a [`MegaError`]. +pub fn mega_error_http_status(err: &MegaError) -> u16 { + match err { + MegaError::NotFound(_) | MegaError::ObjStorageNotFound(_) => 404, + MegaError::BadRequest(_) => 400, + MegaError::Conflict(_) + | MegaError::StaleMonorepoRootRef + | MegaError::ObjStorageInconsistent(_) => 409, + MegaError::Forbidden(_) => 403, + MegaError::Unauthorized(_) => 401, + MegaError::Unavailable(_) => 503, + MegaError::Buck(buck_err) => buck_error_http_status(buck_err), + MegaError::Git(git_err) => git_error_http_status(git_err), + MegaError::Other(msg) => parse_legacy_http_code(msg) + .map(|(code, _)| code) + .unwrap_or(500), + MegaError::Db(_) + | MegaError::Redis(_) + | MegaError::Io(_) + | MegaError::Config(_) + | MegaError::EncodeError(_) + | MegaError::SerdeJson(_) + | MegaError::Pgp(_) + | MegaError::Clap(_) + | MegaError::Anyhow(_) + | MegaError::ObjStorage(_) => 500, + } +} + +/// Whether the error message is safe to expose to API clients. +pub fn mega_error_is_client_safe(err: &MegaError) -> bool { + let status = mega_error_http_status(err); + status < 500 || status == 503 +} + impl From for GitError { fn from(val: MegaError) -> Self { match val { - // Preserve HTTP semantics across crates: ApiError can parse [code:404]. - MegaError::NotFound(msg) => GitError::CustomError(format!("[code:404] {msg}")), + MegaError::NotFound(msg) => GitError::CustomError(msg), + MegaError::BadRequest(msg) => GitError::CustomError(msg), + MegaError::Conflict(msg) => GitError::CustomError(msg), + MegaError::Forbidden(msg) => GitError::CustomError(msg), + MegaError::Unauthorized(msg) => GitError::CustomError(msg), + MegaError::Unavailable(msg) => GitError::CustomError(msg), MegaError::ObjStorageNotFound(msg) => { - GitError::CustomError(format!("[code:404] ObjStorage not found: {msg}")) + GitError::CustomError(format!("ObjStorage not found: {msg}")) } other => GitError::CustomError(other.to_string()), } @@ -166,38 +332,86 @@ pub enum ProtocolError { Disabled, } -impl From for ProtocolError { - fn from(err: MegaError) -> ProtocolError { - ProtocolError::InvalidInput(err.to_string()) +pub fn protocol_error_http_status(err: &ProtocolError) -> u16 { + match err { + ProtocolError::NotFound(_) => 404, + ProtocolError::InvalidInput(_) => 400, + ProtocolError::Deny(_) => 401, + ProtocolError::TooLarge(_) => 413, + ProtocolError::Disabled => 403, + ProtocolError::IO(_) => 500, } } -impl IntoResponse for ProtocolError { - fn into_response(self) -> Response { - let (status, message) = match self { - ProtocolError::Deny(err) => { - // This error is caused by bad user input so don't log it - (StatusCode::UNAUTHORIZED, err) - } - ProtocolError::TooLarge(err) => (StatusCode::PAYLOAD_TOO_LARGE, err), - ProtocolError::NotFound(err) => { - // Because `TraceLayer` wraps each request in a span that contains the request - // method, uri, etc we don't need to include those details here - // tracing::error!(%err, "error"); - - // Don't expose any details about the error to the client - (StatusCode::NOT_FOUND, err) - } - ProtocolError::InvalidInput(err) => (StatusCode::BAD_REQUEST, err), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Something went wrong".to_owned(), - ), - }; +/// Whether the protocol error message is safe to expose to clients. +pub fn protocol_error_is_client_safe(err: &ProtocolError) -> bool { + protocol_error_http_status(err) < 500 +} - (status, Json(CommonResult::::failed(&message))).into_response() +/// Explicit transport-boundary mapping from domain errors to protocol errors. +pub fn mega_to_protocol_error(err: MegaError) -> ProtocolError { + match err { + MegaError::NotFound(msg) => ProtocolError::NotFound(msg), + MegaError::BadRequest(msg) => ProtocolError::InvalidInput(msg), + MegaError::Unauthorized(msg) => ProtocolError::Deny(msg), + MegaError::Forbidden(msg) => ProtocolError::InvalidInput(msg), + MegaError::Unavailable(msg) => ProtocolError::InvalidInput(msg), + MegaError::Conflict(msg) => ProtocolError::InvalidInput(msg), + MegaError::Io(e) => ProtocolError::IO(e), + other => ProtocolError::InvalidInput(other.to_string()), } } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + + #[test] + fn parse_legacy_http_code_extracts_status_and_message() { + let (code, msg) = parse_legacy_http_code("[code:404] CL not found: abc").unwrap(); + assert_eq!(code, 404); + assert_eq!(msg, "CL not found: abc"); + } + + #[test] + fn mega_error_from_legacy_message_maps_typed_variants() { + let err = MegaError::from_legacy_message("[code:503] Build system is not enabled"); + assert!(matches!(err, MegaError::Unavailable(_))); + assert_eq!(mega_error_http_status(&err), 503); + } + + #[test] + fn mega_error_http_status_maps_not_found() { + assert_eq!( + mega_error_http_status(&MegaError::NotFound("CL not found".into())), + 404 + ); + } + + #[test] + fn git_error_http_status_maps_custom_not_found() { + assert_eq!( + git_error_http_status(&GitError::CustomError("File not found".into())), + 404 + ); + } + + #[test] + fn protocol_error_http_status_maps_not_found() { + assert_eq!( + protocol_error_http_status(&ProtocolError::NotFound("repo missing".into())), + 404 + ); + } + + #[test] + fn protocol_error_http_status_maps_disabled() { + assert_eq!(protocol_error_http_status(&ProtocolError::Disabled), 403); + } + + #[test] + fn mega_to_protocol_error_preserves_not_found() { + let err = mega_to_protocol_error(MegaError::NotFound("repo".into())); + assert!(matches!(err, ProtocolError::NotFound(_))); + } +} diff --git a/context/Cargo.toml b/context/Cargo.toml deleted file mode 100644 index 9b63eaaa1..000000000 --- a/context/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "context" -version = "0.1.0" -edition.workspace = true - -[lib] -name = "context" -path = "src/lib.rs" - -[features] -default = [] - -[dependencies] -common = { workspace = true } -jupiter = { workspace = true } -vault = { workspace = true } diff --git a/docs/README.md b/docs/README.md index f3bdece2a..02be41645 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,28 @@ -# Mega - Monorepo Engine for Enterprise and Individual - -# Contents - -1. [Philosophy](philosophy.md) -2. [Getting Started](getting-started.md) -3. [Deployment](deployment.md) -4. [Troubleshooting](troubleshooting.md) -5. [Development](development.md) -6. [Database](database.md) -7. [API](api.md) -8. [Contributing](contributing.md) -9. [Code of Conduct](code-of-conduct.md) -10. [Security](security.md) -11. [FAQ](faq.md) \ No newline at end of file +# Mega Documentation + +## Getting started + +- [Getting Started](getting-started.md) — quick pointers +- [Docker demo](../docker/README.md) — recommended first run +- [Development](development.md) — native build and test +- [Orion deployment](../orion/docs/deployment.md) — build runner setup + +## Architecture and APIs + +- [Architecture](architecture.md) — workspace layout, runtime, Swagger +- [LFS API](lfs-api.md) — Git LFS endpoints +- [Artifacts protocol](artifacts-protocol.md) (EN) · [Artifacts protocol (CN)](artifacts-protocol-CN.md) +- [Orion ↔ Mega object access](orion-mega-object-access.md) (draft) + +## Contributing and community + +- [Contributing](contributing.md) +- [Code of Conduct](code-of-conduct.md) +- [Security](security.md) +- [Agent harness](agent.md) — AI agent workflow notes (CN) +- [Troubleshooting](troubleshooting.md) + +## Work in progress + +- [Philosophy](philosophy.md) +- [FAQ](faq.md) diff --git a/docs/agent.md b/docs/agent.md index f13fb489b..fcbdd5542 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -28,7 +28,8 @@ - Rust workspace 当前有 12 个成员,而不是 11 个:`api-model`、`ceres`、`common`、`context`、`io-orbit`、`jupiter`、`jupiter/callisto`、`mono`、`orion`、`orion-server`、`saturn`、`vault`。 - README 已声明 PR 前置检查:`cargo clippy --all-targets --all-features -- -D warnings`、`cargo +nightly fmt --all --check`、`cargo buckal build`,并要求依赖变更后运行 `cargo buckal migrate`。 - `docs/contributing.md` 要求提交包含 `Signed-off-by`,并说明 PGP 签名要求;AI 提交规范不能只写 Conventional Commits 和 `Co-Authored-By`。 -- `jupiter/README.md` 已定义 SeaORM migration 和 entity 生成流程,entity 输出目录是 `jupiter/callisto/src`。 +- 数据库迁移见 `jupiter-migrate/README.md`;entity 输出目录是 `jupiter/callisto/src`。 +- REST API 以 Swagger 为准(`/swagger-ui`);架构见 `docs/architecture.md`。 - `saturn/` 已有 Cedar schema、policy 和解析/授权代码,是策略审查 agent 的合理边界。 - `mono/src/api/api_router.rs` 已聚合 file tree、commit、Buck、artifacts、permission、reviewer 等 API;MCP MVP 应优先包装这些已有能力,而不是先承诺新的代码索引能力。 @@ -496,7 +497,6 @@ libra status --short - Claude Code Subagents: https://code.claude.com/docs/en/sub-agents - Claude Code Skills: https://code.claude.com/docs/en/slash-commands - Claude Code MCP: https://code.claude.com/docs/en/mcp -- Mega `README.md` +- Mega `docs/architecture.md` - Mega `docs/development.md` - Mega `docs/contributing.md` -- Mega `docs/api.md` diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 0b4d4ab6b..000000000 --- a/docs/api.md +++ /dev/null @@ -1,115 +0,0 @@ -# API - -## Mega HTTP API - -### git protocol related API - -HTTP implement for git transfer data between two repositories - -1. When the client initially connects the server will immediately respond with a version number, and a listing of each reference it has (all branches and tags) along with the object name that each reference currently points to. - - ```bash - GET **/info/refs/ - ``` - -2. Pushing data to a server will invoke the receive-pack process on the server, which will allow the client to tell it which references it should update and then send all the data the server will need for those new references to be complete. Once all the data is received and validated, the server will then update its references to what the client specified. - - ```bash - GET **/git-receive-pack - ``` - -3. When one Git repository wants to get data that a second repository has, the first can fetch from the second. This operation determines what data the server has that the client does not then streams that data down to the client in Pack file format. - - ```bash - GET **/git-upload-pack - ``` - -### git lfs API - -The Git LFS client uses an HTTPS server to coordinate fetching and storing large binary objects separately from a Git server. - -1. Downloading the Git objects required by the LFS protocol using an object ID. - - ```bash - GET **/objetcs/:object_id - ``` - -2. The client uploads objects through individual PUT requests. The URL and headers are provided by an upload action object. - - ```bash - PUT **/objetcs/:object_id - ``` - -3. The client can request the current active locks for a repository by sending a GET to /locks - - ```bash - GET **/locks - ``` - -4. List Locks for Verification。The client can use the Lock Verification endpoint to check for active locks that can affect a Git push - - ```bash - POST **/locks/verify - ``` - -5. Create Lock: The client sends the following to create a lock by sending a POST to /locks.Servers should ensure that users have push access to the repository, and that files are locked exclusively to one user. - - ```bash - POST **/locks - ``` - -6. Delete Lock: The client can delete a lock, given its ID, by sending a POST to /locks/:id/unlock - - ```bash - POST **/locks/:id/unlock - ``` - -7. The Batch API is used to request the ability to transfer LFS objects with the LFS server. The Batch URL is built by adding /objects/batch to the LFS server URL. - - ```bash - POST **/objects/batch - ``` - -### git objects retrieval API - -This part of the API, prefixed with /api/v1, is primarily for fetching Git raw objects and displaying web project hierarchies. - -> Suppose the mega server is running on `MEGA_URL`, while placeholders surrounded by `<>` is necessary and `[]` is optional, but both are needed to be replaced if chosen. - -1. Retrieve original information of a Git object by object ID and return as String. - - ```bash - curl -X GET ${MEGA_URL}/api/v1/blob?object_id= - ``` - -2. Retrieve a Git object by object ID and return it as a file stream - - ```bash - curl -X GET ${MEGA_URL}/api/v1/object?object_id=&repo_path= - ``` - -3. Retrieve directory hierarchy via path or object ID. The default value for `object_id` is `None` and `repo_path` is `/` - - ```bash - curl -X GET ${MEGA_URL}/api/v1/tree?[object_id=][&][repo_path=] - ``` - -4. Check `API service` status - - ```bash - curl -X GET ${MEGA_URL}/api/v1/status - ``` - -5. Count number of objects of a given repository - - ```bash - curl -X GET ${MEGA_URL}/api/v1/count-objs?repo_path= - ``` - -6. Update commit binding to associate a commit with a specific user or mark as anonymous - - ```bash - curl -X PUT ${MEGA_URL}/api/v1/commits//binding \ - -H "Content-Type: application/json" \ - -d '{"username": "", "is_anonymous": false}' - ``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..ede036d96 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,90 @@ +# Architecture + +Mega is a Rust workspace centered on the `mono` server binary. Domain logic lives in `ceres`; persistence in `jupiter` + `callisto`; schema migrations in `jupiter-migrate`. + +## Workspace crates + +| Crate | Role | +|-------|------| +| `mono` | HTTP/SSH server binary, REST routers, composition root (`bootstrap::AppContext`) | +| `ceres` | Git transport, application services, HTTP DTOs — see [ceres/README.md](../ceres/README.md) | +| `jupiter` | Storage layer and services over SeaORM | +| `jupiter/callisto` | Generated SeaORM entities | +| `jupiter-migrate` | SeaORM migrations (feature-gated; see [jupiter-migrate/README.md](../jupiter-migrate/README.md)) | +| `api-model` | Wire protocol between `mono` and Orion (buck2, artifacts, pagination) | +| `common` | Shared config, errors, utilities | +| `io-orbit` | Object storage abstraction (local, S3, GCS) | +| `saturn` | Cedar policy engine | +| `vault` | Cryptographic vault (PGP, PKI, secrets) | +| `orion` | Build runner (Buck2 WebSocket client) | +| `orion-server` | Build task server | +| `orion-scheduler` | VM / runner orchestration | +| `clients/orion-client` | HTTP client for Orion APIs | + +Frontend UI: `moon/apps/web` (Next.js). + +## Runtime assembly + +```text +mono CLI + └─ bootstrap::AppContext (storage, vault, config, redis) + ├─ HTTP server: REST + Git Smart HTTP + Swagger + ├─ SSH server: Git over SSH + └─ TransportRuntime (ceres) — Git pack handlers + application event bus +``` + +`mono` constructs a `TransportRuntime` with storage, `GitObjectCache`, and `RuntimeApplicationHandler`, then wires it to Git protocol handlers and REST routes. Push flow details: [ceres/README.md](../ceres/README.md#git-push-event-flow). + +## Error handling boundaries + +REST and application logic use `MegaError` (`common::errors`), mapped to HTTP by `ApiError` in `mono`. Git Smart HTTP/SSH transport uses `ProtocolError` with `protocol_error::into_response`. See [ceres/README.md#error-type-boundaries](../ceres/README.md#error-type-boundaries). + +## DTO and module boundaries + +HTTP/OpenAPI types live in `ceres/model`. `mono` routers must not import `jupiter::model`, `callisto`, or `jupiter::service` directly — use `ceres::model` and `MonoApiService` facades. `ceres/src/transport` must not depend on `MonoApiService`. CI enforces these rules in [`.github/workflows/base.yml`](../.github/workflows/base.yml). Full rules: [ceres/README.md#model-boundary](../ceres/README.md#model-boundary). + +## HTTP API discovery + +When the `mono` HTTP server is running (default port `8000`): + +| Resource | URL | +|----------|-----| +| Swagger UI | `http://localhost:8000/swagger-ui` | +| OpenAPI JSON | `http://localhost:8000/api/openapi.json` | + +REST handlers are defined in `mono/src/api/` with `utoipa` annotations. Do not maintain a separate hand-written API catalog. + +### Git LFS + +LFS handlers are mounted at two equivalent prefixes: + +| Audience | Base path | +|----------|-----------| +| Git LFS clients | `.git/info/lfs/...` (e.g. `/project/foo.git/info/lfs/objects/batch`) | +| OpenAPI / tools | `/api/v1/lfs/...` | + +See [lfs-api.md](lfs-api.md). + +### Git Smart HTTP / SSH + +Standard `info/refs`, `git-upload-pack`, and `git-receive-pack` under repo-scoped paths such as `/project/...` and `/third-party/...`. + +## Database schema + +Source of truth: + +- Migrations: `jupiter-migrate/src/migration/` +- Entities: `jupiter/callisto/src/` + +Migrations apply automatically when `mono` starts (`jupiter` `migrate` feature enabled). No hand-maintained ER diagram in docs. + +## Orion ecosystem + +- Runner: [orion/README.md](../orion/README.md) +- Deployment: [orion/docs/deployment.md](../orion/docs/deployment.md) +- Object access from Orion: [orion-mega-object-access.md](orion-mega-object-access.md) (draft) + +## Related projects (external) + +- [Libra](https://github.com/web3infra-foundation/libra) — Git-compatible agent client +- [ScorpioFS](https://github.com/web3infra-foundation/scorpiofs) — FUSE filesystem for monorepo folders diff --git a/docs/database.md b/docs/database.md deleted file mode 100644 index e7a3bdcfb..000000000 --- a/docs/database.md +++ /dev/null @@ -1,372 +0,0 @@ - - -## 1.Basic Design of Mega Monorepo - -The purpose of this document is to refactor the current storage design of Mega, enabling it to effectively manage project monorepo while remaining compatible with the Git protocol. - -Mega's storage structure is mainly divided into the following parts: - -### Mega Directory: - -Similar to the 'tree' in Git, Mega maintains relationships between files and file names. In the database, Mega independently manages directory information for the current version. - -### Import directory -- The primary purpose of importing directories is to synchronize the original Git repository into the Mega directory. Projects within the import directory are maintained in a **read-only** state, preserving the original commit information. -- Projects pushed to the import directory can have multiple commits. -- Projects in the import directory can be quickly transformed into the Mega directory. -- Import directories can be configured in the configuration file. -- Once a directory is initialized as an import directory, it cannot be changed back to a regular directory. - -## 2. Database Design - -### Table Overall - -| Table Name | Description | Mega Push | Mega Pull | Git Push | Git Repo | -|-----------------|---------------------------------------------------------------------------------------------------------|-----------|-----------|----------|----------| -| mega_commit | Store all commit objects related with mega directory, have mr status | ✓ | | | | -| mega_tree | Store all tree objects related with mega directory, together with mega_commit to find history directory | ✓ | | | | -| mega_blob | Store all blob objects under mega directory. | ✓ | ✓ | | | -| mega_tag | Store all annotated tag with mega directory. | ✓ | ✓ | | | -| mega_mr | Merge request related to mega commits. | ✓ | | | | -| mega_mr_conv | MR conversation list | ✓ | | | | -| mega_mr_comment | MR Comment | ✓ | | | | -| mega_issue | Manage mega's issue. | | | | | -| mega_refs | This table maintains refs information corresponding to each directory of mega | ✓ | | | | -| git_repo | Maintain Relations between import_repo and repo_path. | | | ✓ | ✓ | -| import_refs | Obtains the latest commit_id through repo_id and ref_name, also storing the repo lightweight tags. | | | ✓ | ✓ | -| git_commit | Store all parsed commit objects related with repo. | | | ✓ | ✓ | -| git_tree | Store all parsed tree objects related with repo. | | | ✓ | ✓ | -| git_blob | Store all parsed blob objects related with repo. | | | ✓ | ✓ | -| git_tag | Store all annotated tag related with repo. | | | ✓ | ✓ | -| raw_blob | Store all raw objects with both git repo and mega directory. | ✓ | ✓ | ✓ | ✓ | -| git_pr | Pull request sync from third parties like GitHub. | | | | | -| git_issue | Issues sync from third parties like GitHub. | | | | | -| lfs_objects | Store objects related to LFS protocol. | | | | | -| lfs_locks | Store locks for lfs files. | | | | | -| commit_auths | Store commit binding information associating commits with users. | ✓ | | | | - -### ER Diagram - - -```mermaid -erDiagram - - MEGA-SNAPSHOT |o--|{ MEGA-COMMITS : "belong to" - MEGA-SNAPSHOT |o--|{ GIT-TREE : contains - MEGA-COMMITS ||--|| MEGA-TREE : points - MEGA-COMMITS ||--|| RAW-OBJECTS : points - MEGA-COMMITS ||--|| MEGA-MR : "belong to" - MEGA-TREE }|--o{ MEGA-BLOB : points - MEGA-TREE ||--|| RAW-OBJECTS : points - MEGA-TREE }|--|| MEGA-MR : "belong to" - MEGA-TREE }o..o{ GIT-TREE : points - MEGA-BLOB ||--|| RAW-OBJECTS : points - MEGA-BLOB }|--|| MEGA-MR : "belong to" - MEGA-TAG |o--o| MEGA-COMMITS : points - MEGA-TAG ||--|| RAW-OBJECTS : points - RAW-OBJECTS ||--o| LFS-OBJECTS : points - LFS-OBJECTS ||--o| LFS-LOCKS : points - GIT-PR }o--|| GIT-REPO : "belong to" - GIT-ISSUE }o--|| GIT-REPO : "belong to" - GIT-REFS ||--|| GIT-COMMIT : points - GIT-REFS ||--|| GIT-TAG : points - GIT-REFS }|--|| GIT-REPO : "belong to" - GIT-COMMIT ||--|| GIT-TREE : has - GIT-COMMIT ||--|| RAW-OBJECTS : has - GIT-COMMIT }|--|| GIT-REPO : "belong to" - GIT-TREE ||--o{ GIT-BLOB : has - GIT-TREE ||--|| RAW-OBJECTS : points - GIT-TREE }|--|| GIT-REPO : "belong to" - GIT-BLOB ||--|| RAW-OBJECTS : points - GIT-BLOB }|--|| GIT-REPO : "belong to" - GIT-TAG }o--|| GIT-REPO : "belong to" - GIT-TAG |o--o| GIT-COMMIT : points - GIT-TAG ||--|| RAW-OBJECTS : points - COMMIT-AUTHS ||--o| GIT-COMMIT : binds - COMMIT-AUTHS ||--o| MEGA-COMMITS : binds - -``` - -### Table Details - - -#### mega_commit - -| Column | Type | Constraints | Description | -|------------|-------------|-------------|-------------| -| id | BIGINT | PRIMARY KEY | | -| commit_id | VARCHAR(40) | NOT NULL | | -| tree | VARCHAR(40) | NOT NULL | | -| parents_id | TEXT[] | NOT NULL | | -| author | TEXT | | | -| committer | TEXT | | | -| content | TEXT | | | -| created_at | TIMESTAMP | NOT NULL | | - - -#### mega_tree - -| Column | Type | Constraints | Description | -|------------|-------------|-------------|-----------------------------| -| id | BIGINT | PRIMARY KEY | | -| tree_id | VARCHAR(40) | NOT NULL | | -| sub_trees | TEXT[] | NOT NULL | {name, sha1, mode, repo_id} | -| commit_id | VARCHAR(40) | NOT NULL | | -| size | INT | NOT NULL | | -| created_at | TIMESTAMP | NOT NULL | | - -#### mega_blob - -| Column | Type | Constraints | -|------------|-------------|-------------| -| id | BIGINT | PRIMARY KEY | -| blob_id | VARCHAR(40) | NOT NULL | -| commit_id | VARCHAR(40) | NOT NULL | -| name | TEXT | NOT NULL | -| size | INT | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | - - -#### mega_tag - -| Column | Type | Constraints | Description | -|-------------|-------------|-------------|-------------------------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| tag_id | VARCHAR(40) | NOT NULL | | -| object_id | VARCHAR(40) | NOT NULL | point to the object's sha1 | -| object_type | VARCHAR(20) | NOT NULL | In Git, each object type is assigned a unique integer value | -| tag_name | TEXT | NOT NULL | tag's name | -| tagger | TEXT | NOT NULL | tag's signature | -| message | TEXT | NOT NULL | | -| created_at | TIMESTAMP | NOT NULL | | - -#### mega_mr - -| Column | Type | Constraints | Description | -|------------|-------------|-------------|--------------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| mr_link | VARCHAR(40) | NOT NULL | A MR identifier with a length of 6-8 characters. | -| merge_date | TIMESTAMP | | | -| status | VARCHAR(20) | NOT NULL | | -| path | TEXT | NOT NULL | | -| from_hash | VARCHAR(40) | NOT NULL | merge from which commit | -| to_hash | VARCHAR(40) | NOT NULL | merge to commit hash | -| created_at | TIMESTAMP | NOT NULL | | -| updated_at | TIMESTAMP | NOT NULL | | - -#### mega_mr_conv - -| Column | Type | Constraints | Description | -|------------|-------------|-------------|------------------------------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| mr_id | BIGINT | NOT NULL | | -| user_id | BIGINT | NOT NULL | | -| conv_type | VARCHAR(20) | NOT NULL | conversation type, can be comment, commit, force push, edit etc. | -| created_at | TIMESTAMP | NOT NULL | | -| updated_at | TIMESTAMP | NOT NULL | | - -#### mega_mr_comment - -| Column | Type | Constraints | Description | -|---------|---------|-------------|---------------------------------| -| id | BIGINT | PRIMARY KEY | | -| conv_id | BIGINT | NOT NULL | related table mega_mr_conv's id | -| comment | TEXT | | | -| edited | BOOLEAN | NOT NULL | | - -#### mega_issue - -| Column | Type | Constraints | -|-------------|--------------|--------------| -| id | BIGINT | PRIMARY KEY | -| number | BIGINT | NOT NULL | -| title | VARCHAR(255) | NOT NULL | -| sender_name | VARCHAR(255) | NOT NULL | -| sender_id | BIGINT | NOT NULL | -| state | VARCHAR(255) | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | -| updated_at | TIMESTAMP | NOT NULL | -| closed_at | TIMESTAMP | DEFAULT NULL | - - -#### mega_refs - -| Column | Type | Constraints | Description | -|-----------------|-------------|-------------|-----------------------------------| -| id | BIGINT | PRIMARY KEY | | -| path | TEXT | NOT NULL | monorepo path refs | -| ref_commit_hash | VARCHAR(40) | NOT NULL | point to the commit or tag object | -| ref_tree_hash | VARCHAR(40) | NOT NULL | point to the tree object | -| created_at | TIMESTAMP | NOT NULL | | -| updated_at | TIMESTAMP | NOT NULL | | - - -#### import_refs - -| Column | Type | Constraints | Description | -|----------------|-------------|-------------|--------------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| repo_id | BIGINT | NOT NULL | | -| ref_name | TEXT | NOT NULL | reference name, can be branch and tag | -| ref_git_id | VARCHAR(40) | NOT NULL | point to the commit or tag object | -| default_branch | BOOLEAN | NOT NULL | Indicates whether the branch is a default branch | -| ref_type | VARCHAR(20) | NOT NULL | ref_type: can be 'tag' or 'branch' | -| created_at | TIMESTAMP | NOT NULL | | -| updated_at | TIMESTAMP | NOT NULL | | - - -#### git_repo - -| Column | Type | Constraints | Description | -|------------|-----------|-------------|-----------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| repo_path | TEXT | NOT NULL | git repo's absolute path under mega directory | -| repo_name | TEXT | NOT NULL | | -| created_at | TIMESTAMP | NOT NULL | | -| updated_at | TIMESTAMP | NOT NULL | | - -#### git_commit - -| Column | Type | Constraints | -|------------|-------------|-------------| -| id | BIGINT | PRIMARY KEY | -| repo_id | BIGINT | NOT NULL | -| commit_id | VARCHAR(40) | NOT NULL | -| tree | VARCHAR(40) | NOT NULL | -| parents_id | TEXT[] | NOT NULL | -| author | TEXT | | -| committer | TEXT | | -| content | TEXT | | -| created_at | TIMESTAMP | NOT NULL | - -#### git_tree - -| Column | Type | Constraints | -|------------|--------------|-------------| -| id | BIGINT | PRIMARY KEY | -| repo_id | BIGINT | NOT NULL | -| tree_id | VARCHAR(40) | NOT NULL | -| sub_trees | TEXT[] | | -| size | INT | NOT NULL | -| commit_id | VARCHAR(40) | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | - -#### git_blob - -| Column | Type | Constraints | -|--------------|--------------|-------------| -| id | BIGINT | PRIMARY KEY | -| repo_id | BIGINT | NOT NULL | -| blob_id | VARCHAR(40) | NOT NULL | -| name | VARCHAR(128) | | -| size | INT | NOT NULL | -| commit_id | VARCHAR(40) | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | - -#### git_tag - -| Column | Type | Constraints | -|-------------|-------------|-------------| -| id | BIGINT | PRIMARY KEY | -| repo_id | BIGINT | NOT NULL | -| tag_id | VARCHAR(40) | NOT NULL | -| object_id | VARCHAR(40) | NOT NULL | -| object_type | VARCHAR(20) | NOT NULL | -| tag_name | TEXT | NOT NULL | -| tagger | TEXT | NOT NULL | -| message | TEXT | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | - -#### raw_blob - -| Column | Type | Constraints | Description | -|--------------|-------------|-------------|-------------------------------------------------------------------| -| id | BIGINT | PRIMARY KEY | | -| sha1 | VARCHAR(40) | NOT NULL | git object's sha1 hash | -| content | TEXT | | -| file_type | VARCHAR(20) | | -| storage_type | VARCHAR(20) | NOT NULL | data storage type, can be 'database', 'local-fs' and 'remote_url' | -| data | BYTEA | | | -| local_path | TEXT | | | -| remote_url | TEXT | | | -| created_at | TIMESTAMP | NOT NULL | - -#### git_pr - -| Column | Type | Constraints | -|------------------|--------------|--------------| -| id | BIGINT | PRIMARY KEY | -| number | BIGINT | NOT NULL | -| title | VARCHAR(255) | NOT NULL | -| state | VARCHAR(255) | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | -| updated_at | TIMESTAMP | NOT NULL | -| closed_at | TIMESTAMP | DEFAULT NULL | -| merged_at | TIMESTAMP | DEFAULT NULL | -| merge_commit_sha | VARCHAR(200) | DEFAULT NULL | -| repo_id | BIGINT | NOT NULL | -| sender_name | VARCHAR(255) | NOT NULL | -| sender_id | BIGINT | NOT NULL | -| user_name | VARCHAR(255) | NOT NULL | -| user_id | BIGINT | NOT NULL | -| commits_url | VARCHAR(255) | NOT NULL | -| patch_url | VARCHAR(255) | NOT NULL | -| head_label | VARCHAR(255) | NOT NULL | -| head_ref | VARCHAR(255) | NOT NULL | -| base_label | VARCHAR(255) | NOT NULL | -| base_ref | VARCHAR(255) | NOT NULL | - - -#### git_issue - -| Column | Type | Constraints | -|-------------|--------------|--------------| -| id | BIGINT | PRIMARY KEY | -| number | BIGINT | NOT NULL | -| title | VARCHAR(255) | NOT NULL | -| sender_name | VARCHAR(255) | NOT NULL | -| sender_id | BIGINT | NOT NULL | -| state | VARCHAR(255) | NOT NULL | -| created_at | TIMESTAMP | NOT NULL | -| updated_at | TIMESTAMP | NOT NULL | -| closed_at | TIMESTAMP | DEFAULT NULL | -| repo_id | BIGINT | NOT NULL | - - -#### lfs_locks - -| Column | Type | Constraints | -|--------|-------------|-------------| -| id | VARCHAR(40) | PRIMARY KEY | -| data | TEXT | | - - -#### lfs_objects - -| Column | Type | Constraints | -|--------|-------------|-------------| -| oid | VARCHAR(64) | PRIMARY KEY | -| size | BIGINT | | -| exist | BOOLEAN | | - - -#### commit_auths - -| Column | Type | Constraints | Description | -|------------------|-------------|-------------|--------------------------------------------------| -| id | VARCHAR | PRIMARY KEY | | -| commit_sha | VARCHAR | NOT NULL | Git commit SHA hash | -| author_email | VARCHAR | NOT NULL | Original author email from commit | -| matched_username | VARCHAR | NULL | Username that this commit is bound to | -| is_anonymous | BOOLEAN | NOT NULL | Whether the commit should be treated as anonymous | -| matched_at | TIMESTAMP | NULL | When the binding was created/updated | -| created_at | TIMESTAMP | NOT NULL | When the record was created | - - -## 3. Prerequisites - - -- Generating entities: -Entities can be generated from the database table structure with the following command - -`sea-orm-cli generate entity -u "postgres://postgres:$postgres@localhost/mega" -o jupiter/entity/src` \ No newline at end of file diff --git a/docs/development.md b/docs/development.md index 51c3f61fa..bd4ff97dc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,349 +1,104 @@ # Development -## Architect +## Prerequisites -![Mega Architect](images/architect.svg) - -## Quick start manuel to developing or testing - -### MacOS - -1. Install Rust on your macOS machine. - - ```bash - $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - ``` - -2. Clone mega repository and build it. - - ```bash - $ git clone https://github.com/web3infra-foundation/mega.git - $ cd mega - $ git submodule update --init --recursive - $ cargo build - ``` - -3. Install PostgreSQL and init database. (You can skip this step if using SQLite in `config.toml`) - - 1. Install PostgreSQL 16 with `brew` command. - - ```bash - $ brew install postgresql@16 - $ echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc - $ brew services start postgresql@16 - $ initdb /Volumes/Data/postgres -E utf8 # /Volumes/Data is path store data - ``` - - 2. Create a database, then find the dump file in the SQL directory of the Mega repository and import it into the database. - - ```bash - $ psql postgres - ``` - - ```sql - postgres=# \l - postgres=# DROP DATABASE IF EXISTS mega; - postgres=# CREATE DATABASE mega; - postgres=# \q - ``` - - - 3. Create user and grant privileges. - - ```sql - postgres=# DROP USER IF EXISTS mega; - postgres=# CREATE USER mega WITH ENCRYPTED PASSWORD 'mega'; - postgres=# GRANT ALL PRIVILEGES ON DATABASE mega TO mega; - ``` - - ```bash - $ psql mega -c "GRANT ALL ON ALL TABLES IN SCHEMA public to mega;" - $ psql mega -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public to mega;" - $ psql mega -c "GRANT ALL ON ALL FUNCTIONS IN SCHEMA public to mega;" - ``` - -4. Update config file for local test. For local testing, Mega uses the `config.toml` file to configure the required parameters. See [Configuration](#configuration). - -5. Init the Mega - - ```bash - $ cd mega - $ cargo run init - ``` - -6. Start the Mega server for testing. - - ```bash - # Starting a single http server - $ cargo run service http - # Or Starting multiple server - $ cargo run service multi http ssh - ``` - -7. Test the `git push` and `git clone` - - ```bash - $ cd mega - $ git remote add local http://localhost:8000/projects/mega.git - $ git push local main - $ cd /tmp - $ git clone http://localhost:8000/projects/mega.git - ``` - -### Arch Linux - -1. Install Rust. - - ```bash - $ pacman -S rustup - $ rustup default stable - ``` - -2. Clone mega repository and build. - - ```bash - $ git clone https://github.com/web3infra-foundation/mega.git - $ cd mega - $ git submodule update --init --recursive - $ cargo build - ``` - -3. Install PostgreSQL and initialize database. (You can skip this step if using SQLite in `config.toml`) - - 1.Install PostgreSQL. - - ```bash - $ pacman -S postgresql - # Switch to `postgres` user - $ sudo -i -u postgres - postgres $ initdb -D /var/lib/postgres/data -E utf8 # /Volumes/Data is where data will be stored - postgres $ exit - $ systemctl enable --now postgresql - ``` - - 2.Create database. - - ```bash - $ sudo -u postgres psql postgres - ``` - - ```sql - postgres=# \l - postgres=# DROP DATABASE IF EXISTS mega; - postgres=# CREATE DATABASE mega; - postgres=# \q - ``` +- Rust (stable; workspace edition 2024) +- [Buck2](https://buck2.build/) and [cargo-buckal](https://github.com/buck2hub/cargo-buckal) for CI-parity builds (see root [README.md](../README.md)) +- PostgreSQL or SQLite (configured in `config/config.toml`) +- Optional: Docker for the [demo stack](../docker/README.md) - 3.Create user and grant privileges. - - ```sql - $ sudo -u postgres psql postgres - postgres=# DROP USER IF EXISTS mega; - postgres=# CREATE USER mega WITH ENCRYPTED PASSWORD 'mega'; - postgres=# GRANT ALL PRIVILEGES ON DATABASE mega TO mega; - ``` - - ```bash - $ sudo -u postgres psql mega -c "GRANT ALL ON ALL TABLES IN SCHEMA public to mega;" - $ sudo -u postgres psql mega -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public to mega;" - $ sudo -u postgres psql mega -c "GRANT ALL ON ALL FUNCTIONS IN SCHEMA public to mega;" - ``` - -4. Config `config.toml`. See [Configuration](#configuration). - -5. Init Mega. - - ```bash - $ cd mega - $ cargo run init - ``` - -6. Start Mega server. - - ```bash - # Start a single https server - $ cargo run service http - # Or Start multiple server - $ cargo run service multi http ssh - ``` - -7. Test `git push` and `git clone` - - ```bash - $ cd /tmp - $ git clone https://github.com/Rust-for-Linux/linux.git - $ cd linux - $ git remote add mega http://localhost:8000/third-party/linux.git - $ git push --all mega - $ sudo rm -r /tmp/linux - $ cd /tmp - $ git clone http://localhost:8000/third-party/linux.git - ``` - -### GitHub Codespace - -If you are using GitHub codespaces, you can follow the steps below to set up the Mega project. When you create a new Codespace, the Mega project will be cloned automatically. You can then follow the steps below to set up the project. - -You can skip this step (PostgreSQL setup) if using SQLite in `config.toml`. - -When the codespace is ready, the PostgreSQL will be installed and started automatically. You can then follow the steps below to set up the database with below steps. +## Clone and build ```bash -## Start PostgreSQL -/etc/init.d/postgresql start - -sudo -u postgres psql mega -c "CREATE DATABASE mega;" -sudo -u postgres psql mega -c "CREATE USER mega WITH ENCRYPTED PASSWORD 'mega';" -sudo -u postgres psql mega -c "GRANT ALL PRIVILEGES ON DATABASE mega TO mega;" -sudo -u postgres psql mega -c "GRANT ALL ON ALL TABLES IN SCHEMA public to mega;" -sudo -u postgres psql mega -c "GRANT ALL ON ALL TABLES IN SCHEMA public to mega;" -sudo -u postgres psql mega -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public to mega;" -sudo -u postgres psql mega -c "GRANT ALL ON ALL FUNCTIONS IN SCHEMA public to mega;" +git clone https://github.com/web3infra-foundation/mega.git +cd mega +git submodule update --init --recursive +cargo build -p mono ``` ---- ## Configuration -Setting `config.toml` file for the Mega project, default config file can be found under [config directory](/config/config.toml). - -Currently, the mono bin and mega bin use two different files, each with a different default database type: mono uses `Postgres`, while mega uses `SQLite`. - -### Path -- Default: automatically load `config.toml` in current directory. -- Specify manually: use `--config "/path/to/config.toml"` - -### Enhance -- You can use environment variables starting with `MEGA_` to override the configuration in `config.toml`. - - like `MEGA_BASE_DIR` to override `base_dir`. // with `env::set_var()` - - use separator `__` (2 \* `_`) for nested keys, like `MEGA_LOG__LEVEL` for `log.level` or `MEGA_LOG__WITH_ANSI` for `log.with_ansi`. -- Support `${}` syntax to reference other keys in the same file. - - like `db_path = "${base_dir}/mega.db"`, `${base_dir}` will be replaced by the value of `base_dir` - - or `key = "${xxx.yyy}/zzz"` (prefix `xxx.` can't be omitted) - - only support `String` type - - substitute from up to down - - see codes in [config.rs](/common/src/config.rs) - ---- - -### Attention -- DO NOT use `Array` Type in PostgreSQL but use `JSON` instead, for compatibility with SQLite & MySQL. (`JSON` <==> `serde_json::Value`) ---- -## Tests -> Keep in mind that it's impossible to find all bugs. -> -> Tests are the last line of defense. - -### Unit Tests -Unit tests are small, focused tests that verify the behavior of a single function or module in isolation. +Default config: [config/config.toml](../config/config.toml). -#### Example: +- **Path:** `./config.toml` in the working directory, or `--config /path/to/config.toml` +- **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`) -```rust -// ...Other Codes +Use PostgreSQL `JSON` columns (not arrays) for SQLite compatibility. -#[cfg(test)] // indicates this block will only be compiled when running tests -mod tests { - use super::*; +## Run the server - #[test] // indicates that this function is a test, which will be run by `cargo test` - fn test_add() { - let result = add(1, 1); - assert_eq!(result, 2); // assert is important to tests - } -} +```bash +cargo run --bin mono -- service http +# or multiple protocols: +cargo run --bin mono -- service multi http ssh ``` -### Integration Tests -Integration tests verify that different parts of your **library** work correctly together. -They are **external** to your crate and use your code in the same way any other code would. +Database migrations apply automatically on first `Storage::new` when the `migrate` feature is enabled (default for `mono`). See [jupiter-migrate/README.md](../jupiter-migrate/README.md). -#### Steps -You can refer to the implementation of the mega **module**. ([mega/tests](/mega/tests)) -1. Create a `tests` directory at the **same level** as your `src` directory (e.g. `libra/tests`). -2. Add `*.rs` files in this directory. // Each file will be compiled as a separate **crate**. +Swagger UI: `http://localhost:8000/swagger-ui` (default HTTP port). -#### Attention -- The `tests` in **root** directory (workspace) is NOT integration tests, but some `data` for other tests. -- If you need a common module, use `tests/common/mod.rs` rather than `tests/common.rs`, to declare it's not a test file. -- There is no need to add `#[cfg(test)]` to the `tests` directory. `tests` will be compiled only when running tests. +## Post-start initialization (optional) -#### Run integration tests -The following command will be executed in `GitHub Actions`. +To seed Buckal bundles and third-party imports via API: -This command DOES NOT run **Unit Tests** (which could be very messy). ```bash -cargo test --workspace --test '*' -- --nocapture +python3 scripts/init_mega/init_mega.py --base-url http://127.0.0.1:8000 ``` -- `--workspace` : Run tests for **all packages** in the workspace. -- `--test` : Test the specified **integration test**. -- `--` : Pass the following arguments to the test binary. -- `--nocapture` : DO NOT capture the output (e.g. `println!`) of the test. -If you want to run tests in a specific package, you can use `--package`. +See [scripts/init_mega/README.md](../scripts/init_mega/README.md). -For more information, please refer to the [rust wiki](https://rustwiki.org/zh-CN/cargo/commands/cargo-test.html). +## Git smoke test ---- -## Comment Guideline - -This guide outlines the recommended order for importing dependencies in Rust projects. - -### File Header Comments (//!) - -### Struct Comments (///) - -### Function Comments (///) - ---- -## Rust Dependency Import Order Guideline +```bash +git remote add local http://localhost:8000/projects/mega.git +git push local main +git clone http://localhost:8000/projects/mega.git /tmp/mega-clone +``` -This guide outlines the recommended order for importing dependencies in Rust projects. +Import repos use paths under `/third-party/` (configurable via `import_dir`). -#### 1. Rust Standard Library +## Tests -Import dependencies from the Rust standard library. +### Unit tests -#### 2. Third-Party Crates +```bash +cargo test -p ceres --features migrate +cargo test -p mono +``` -Import dependencies from third-party crates. +### Workspace integration tests -#### 3. Other Modules in Workspace +```bash +cargo test --workspace --test '*' -- --nocapture +``` -Import dependencies from other modules within the project workspace. +- `--workspace` — all packages +- `--test '*'` — integration test binaries only (not unit tests in `src/`) -#### 4. Within Modules +### Pre-submit checks -Import functions and structs from within modules. +```bash +cargo clippy --all-targets --all-features -- -D warnings +cargo +nightly fmt --all --check +cargo buckal build +``` -Example: +## Architecture -```rust +See [architecture.md](architecture.md) and [ceres/README.md](../ceres/README.md). -// 1. Rust Standard Library -use std::collections::HashMap; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::{Arc, Mutex}; +![Mega Architect](images/architect.svg) -// 2. Third-Party Crates -use bytes::{BufMut, Bytes, BytesMut}; -use russh::server::{self, Auth, Msg, Session}; -use russh::{Channel, ChannelId}; -use russh_keys::key; -use tokio::io::{AsyncReadExt, BufReader}; +## Comment and import order -// 3. Other Modules in Workspace -use storage::driver::database::storage::ObjectStorage; +File headers use `//!`, public items use `///`. -// 4. Other Files in the Same Module -use crate::protocol::pack::{self}; -use crate::protocol::ServiceType; -use crate::protocol::{PackProtocol, Protocol}; -``` +Rust import order: -### Additional Notes: +1. Standard library +2. Third-party crates +3. Workspace crates +4. Crate-internal modules -- Always group imports with an empty line between different sections for better readability. -- Alphabetize imports within each section to maintain consistency. -- Avoid using extern crate syntax for Rust 2018 edition and later; prefer using use with crates. -- Do not use `super::` and `self::` in imports. It can lead to ambiguity and hinder code readability. Instead, use crate to reference the current crate's modules. +Group sections with blank lines; alphabetize within each group. Prefer `crate::` over `super::` / `self::` in imports. diff --git a/docs/faq.md b/docs/faq.md index f1d072a65..0bafed59c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,7 @@ # FAQ +> **Work in progress.** See the [README](../README.md) for an overview. External references below. + ## References 1. [What is monorepo? (and should you use it?)](https://semaphoreci.com/blog/what-is-monorepo) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8b3a7945c..89d4cdb47 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1 +1,7 @@ -# Getting Started \ No newline at end of file +# Getting Started + +**Fastest path:** run the local demo with [Docker](../docker/README.md). + +**Native development:** see [Development](development.md) for building `mono`, configuration, and tests. + +**Architecture overview:** [Architecture](architecture.md). diff --git a/docs/lfs-api-improvements.md b/docs/lfs-api-improvements.md deleted file mode 100644 index c81f4f33c..000000000 --- a/docs/lfs-api-improvements.md +++ /dev/null @@ -1,45 +0,0 @@ -# LFS API Improvement Recommendations (GitHub Official Specification Comparison) - -## Comparison Baseline -- Git LFS Batch API: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md -- Git LFS Locking API: https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -- Git LFS Server Discovery: https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md - -## Issues Found and Fix Recommendations - -1) Error codes are too uniform, missing 4xx status codes -- Current state: Most errors in `mono/src/api/router/lfs_router.rs` are mapped to 500. -- Specification: Not found objects/locks should return 404; request parameter errors should return 400. -- Completed: Added `map_lfs_error` in router layer, mapping error messages containing `Not found` / `Invalid` to 404/400. -- Follow-up: Use explicit error types instead of strings in `ceres/src/lfs/handler.rs` to avoid string matching. - -2) Download endpoint Content-Type -- Current state: Download object/chunk uses `application/vnd.git-lfs+json`. -- Specification: Data streams should return binary stream `application/octet-stream`. -- Completed: Changed router download response to `application/octet-stream`. - -3) Batch download missing 404 semantics -- Current state: `lfs_process_batch` returns errors mapped to 500 at router layer when download objects are missing. -- Specification: Not found objects should return `error` at object level, with overall status 200. -- Recommendation: In `handler::lfs_process_batch`, preserve object-level `error` for missing download objects (already implemented), router maintains 200; avoid treating missing as top-level error in handler. - -4) Upload pre-validation insufficient -- Current state: `lfs_upload_object` returns generic error if meta doesn't exist (router maps to 404). -- Recommendation: Use explicit "Not found" message or error type in handler to avoid string matching; validate that `size` matches request body length (currently commented out). - -5) Chunk download behavior in non-split mode -- Current state: `lfs_download_chunk` validates hash after slicing in non-split mode, returns 500 on mismatch. -- Recommendation: Return 400 for hash mismatch; return 404 for missing chunk. - -6) Lock endpoint error semantics -- Current state: Deleting non-existent lock returns 500. -- Recommendation: `lfs_delete_lock` should return 404 for lock not found; return 403 for no permission (currently no owner validation). - -7) OpenAPI coverage -- Completed: Added `utoipa::path` to all LFS routes and registered LFS schemas in `ApiDoc`. -- Recommendation: Provide example response (stream) documentation for LFS download endpoints in future versions. - -## Priority -- High: Error code semantics (404/400), download Content-Type, lock deletion 404. -- Medium: Batch download missing objects maintain 200 + object-level error, upload size validation. -- Low: Hash mismatch 400, permission/lock owner validation supplement. diff --git a/docs/lfs-api.md b/docs/lfs-api.md index 2a1813ad1..3fafde899 100644 --- a/docs/lfs-api.md +++ b/docs/lfs-api.md @@ -1,82 +1,110 @@ -# LFS API Documentation +# Git LFS API -## Overview -Git LFS is used to store large files in a separate LFS storage. Git repositories only store pointer files, and clients complete object upload, download, and lock management through the `/info/lfs` endpoints. +Git LFS stores large blobs outside the Git object graph. Clients negotiate upload/download through batch requests and use separate object endpoints for binary transfer. -Base path: `.git/info/lfs` +Official references: -Content-Type: -- JSON requests/responses: `application/vnd.git-lfs+json` -- File/chunk downloads: `application/octet-stream` +- [Batch API](https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md) +- [Locking API](https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md) +- [Server discovery](https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md) + +Interactive docs: start `mono` and open Swagger UI at `/swagger-ui` (LFS routes under the LFS tag). See [architecture.md](architecture.md#http-api-discovery). + +## URL layout + +Mega exposes the same handlers on two prefixes: + +| Audience | Base path | Example | +|----------|-----------|---------| +| Git LFS clients | `.git/info/lfs` | `/project/foo.git/info/lfs/objects/batch` | +| OpenAPI / tools | `/api/v1/lfs` | `/api/v1/lfs/objects/batch` | + +Repo paths follow monorepo layout (`/project/...`, `/third-party/...`). The HTTP server rewrites `.../info/lfs/...` request URIs so handlers see a normalized path (`mono/src/server/http_server.rs`). + +## Content types + +| Use | Content-Type | +|-----|--------------| +| JSON request/response | `application/vnd.git-lfs+json` | +| Object download body | `application/octet-stream` | ## Endpoints -- `POST /info/lfs/objects/batch`: Batch request for upload/download operations -- `GET /info/lfs/objects/{oid}`: Download object -- `PUT /info/lfs/objects/{oid}`: Upload object (request body is binary file data) -- `GET /info/lfs/objects/{oid}/chunks`: Get chunk information (when split mode is enabled) -- `GET /info/lfs/objects/{oid}/chunks/{chunk_id}?offset=&size=`: Download a single chunk -- `GET /info/lfs/locks`: List locks (supports path/cursor/limit/refspec filtering) -- `POST /info/lfs/locks`: Create lock -- `POST /info/lfs/locks/verify`: Verify lock ownership (ours/theirs) -- `POST /info/lfs/locks/{id}/unlock`: Delete lock +Relative to either base path above: + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/objects/batch` | Batch upload/download negotiation | +| `GET` | `/objects/{oid}` | Download object (binary stream) | +| `PUT` | `/objects/{oid}` | Upload object (binary body) | +| `GET` | `/locks` | List locks (`path`, `cursor`, `limit`, `refspec` query params) | +| `POST` | `/locks` | Create lock | +| `POST` | `/locks/verify` | Verify locks before push | +| `POST` | `/locks/{id}/unlock` | Delete lock | + +Chunk download endpoints (`/objects/{oid}/chunks/...`) are **not** exposed in the current router. ## Examples -### Batch (Download) +### Batch (download) + ```bash curl -X POST \ -H "Content-Type: application/vnd.git-lfs+json" \ -d '{ "operation": "download", "transfers": ["basic"], - "objects": [{"oid": "abc123", "size": 1024}], + "objects": [{"oid": "abc123...", "size": 1024}], "hash_algo": "sha256" }' \ - https://host/repo.git/info/lfs/objects/batch + http://localhost:8000/project/demo.git/info/lfs/objects/batch ``` -### Upload Object +### Upload object + ```bash curl -X PUT \ --data-binary @file.bin \ - https://host/repo.git/info/lfs/objects/abc123 + http://localhost:8000/project/demo.git/info/lfs/objects/abc123... ``` -### Download Object +### Download object + ```bash curl -L \ -H "Accept: application/octet-stream" \ - https://host/repo.git/info/lfs/objects/abc123 -o file.bin + http://localhost:8000/project/demo.git/info/lfs/objects/abc123... -o file.bin ``` -### Lock Management -- List locks: - ```bash - curl "https://host/repo.git/info/lfs/locks?path=foo.bin&limit=50" - ``` -- Create lock: - ```bash - curl -X POST \ - -H "Content-Type: application/vnd.git-lfs+json" \ - -d '{"path":"foo.bin","ref":{"name":"main"}}' \ - https://host/repo.git/info/lfs/locks - ``` -- Delete lock: - ```bash - curl -X POST \ - -H "Content-Type: application/vnd.git-lfs+json" \ - -d '{"force":false,"ref":{"name":"main"}}' \ - https://host/repo.git/info/lfs/locks/{id}/unlock - ``` - -## Developer Notes -- Download/chunk endpoints return binary streams using `application/octet-stream`. -- Error code conventions: 404 for not found objects/locks, 400 for parameter errors, 500 for other errors. -- Batch download missing objects should return `error` field at object level while maintaining 200 status code overall. -- If chunking is enabled (config `lfs.local.enable_split=true`), first call `/objects/{oid}/chunks` to get the chunk list, then download each chunk individually. - -## Related Files -- Routes: `mono/src/api/router/lfs_router.rs` -- Business logic: `ceres/src/lfs/handler.rs` -- Data structures: `ceres/src/lfs/lfs_structs.rs` +### Lock management + +```bash +# List locks +curl "http://localhost:8000/project/demo.git/info/lfs/locks?path=foo.bin&limit=50" + +# Create lock +curl -X POST \ + -H "Content-Type: application/vnd.git-lfs+json" \ + -d '{"path":"foo.bin","ref":{"name":"main"}}' \ + http://localhost:8000/project/demo.git/info/lfs/locks + +# Delete lock +curl -X POST \ + -H "Content-Type: application/vnd.git-lfs+json" \ + -d '{"force":false,"ref":{"name":"main"}}' \ + http://localhost:8000/project/demo.git/info/lfs/locks/{id}/unlock +``` + +## Implementation notes + +- **Error mapping:** Router maps handler messages to HTTP status — `404` for not found, `400` for invalid input, `500` otherwise (`map_lfs_error` in `mono/src/api/router/lfs_router.rs`). +- **Batch download:** Missing objects should appear as per-object `error` fields with overall HTTP `200`, not a top-level failure. +- **Download Content-Type:** Object downloads return `application/octet-stream`, not LFS JSON. + +## Source files + +| Layer | Path | +|-------|------| +| Routes | `mono/src/api/router/lfs_router.rs` | +| Business logic | `ceres/src/lfs/handler.rs` | +| Types | `ceres/src/lfs/lfs_structs.rs` | diff --git a/docs/libra/development.md b/docs/libra/development.md deleted file mode 100644 index 4a25d15ff..000000000 --- a/docs/libra/development.md +++ /dev/null @@ -1,119 +0,0 @@ -# Libra Development - -In theory, libra should run on all platforms that support rust and sqlite. - -## storage - -Libra store project's data in `.libra` directory in the root of the project. - -Libra use sqlite to store some information, such as config, HEAD, refs, which are files in git. However, we keep `index file` and `objects` in the file system, which is the same as git. - -The structure of `.libra` directory: - -```bash -.libra/ -├── libra.db -└── objects - ├── xx - │ └── 9dda22e9f8a653838120287d1813305be6cfb3 - ├── info - └── pack -``` - -## Data Model - -### Database - -Libra use `sea-orm` to interact with sqlite database. The data model is defined in `libra/src/internal/model`, with two tables: `config` and `reference`. - -The `config` table is used to store the configuration of the project, which corresponds to the `config` file in git. The `reference` table is used to store the reference of the project, which corresponds to the `HEAD` and `refs/*` files in git. - -The relationship between the git file and the sqlite table is as follows: - -- **reference**: - -| Category | Description | Database format | -| ------------------------------------- | :------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.git/HEAD ` | Current head pointer (branch or commit hash) | - Reference(name=; kind=HEAD;commit=null;remote=null)
- Reference(name=null; kind=HEAD;commit=;remote=null) | -| `.git/refs/heads/` | Branch name, can’t be “HEAD” | - Reference(name=, kind=Branch; commit=; remote=null) | -| `.git/refs/tags/ ` | Similar to branch | - Reference(name=, kind=Tag; commit=; remote=null) | -| `.git/refs/remotes//` | Contains branch heads and HEAD*Remote HEAD can’t be detached.* | - Reference(name=, kind=Branch; commit=; remote=)
- Reference(name=; type=HEAD; commit=null; remote=) | - -- **config**: - -```ini -# config Example -[core] - filemode = true - ignorecase = false -[remote "origin"] - url = url.git - fetch = +refs/heads…… -``` - -| Category | Description | Database format | -| ------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.gitconfig` | Ini format configuration | - Config(configuration=”core”; name=null; key=”filemode”;value=”true” )
- Config(configuration=”core”; name=null; key=”ignorecase”;value=”false” )
- Config(configuration=”remote”; name=”origin”; key=”url”;value=”url.git” )
- Config(configuration=”remote”; name=”null”; key=”fetch”;value=”+refs……” ) | - -### Business Model Design - -For decoupling, the `sea-orm` model is not directly used in the code, and the business model is redefined (located in `libra/src/internal`), and common CRUD operations are implemented. - -Currently, the `config`, `head` and `reference` models are implemented. - -### Git Model - -Libra use mega's shared model to interact with git objects. The model is defined in `mercury/src/internal`. - -The model is used to interact with the git object, such as `commit`, `tree`, `blob`, `tag`, `index`, `pack`, `pack-index`, etc. - -Libar use interface in `libra/src/command/mod.rs` to process git object's read and write. - -## Add new command - -Libra use `clap` to parse the command line arguments. And each subcommand define it's own struct to parse the arguments. The arguments match is defined in `libra/src/main.rs`. - -Use `libra push` as an example. - -1. Add a new file in `libra/src/command` named `push.rs`, and add it to the `mod.rs` file. - -2. Define the subcommand struct and implement the `Parse` trait. - -```rust -#[derive(Parser, Debug)] -pub struct PushArgs { - #[clap(requires("refspec"))] - repository: Option, - #[clap(requires("repository"))] - refspec: Option, -} -``` - -3. Define the funtion to handle the subcommand, usually named `execute` - -```rust -pub async fn execute(args: PushArgs){ - unimplemented!() -} -``` - -4. Add the subcommand to the `Command` enum in `libra/src/main.rs` - -```rust -#[derive(Subcommand, Debug)] -enum Commands { - // ...... - #[command(about = "Update remote refs along with associated objects")] - Push(command::push::PushArgs), -} -``` - -5. Add the subcommand to the `match` in `libra/src/main.rs` - -```rust - // parse the command and execute the corresponding function with it's args - match args.command { - // ...... - Commands::Push(args) => command::push::execute(args).await, - } -``` diff --git a/docs/philosophy.md b/docs/philosophy.md index 6560f6fc9..1f8f43bce 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -1,5 +1,7 @@ # Philosophy +> **Work in progress.** See the [README](../README.md) for project goals. + ## Decentralized Version Control System VS Centralized Services ## Who Really Owns and Controls the Code and Data diff --git a/docs/scorpio/Design.md b/docs/scorpio/Design.md deleted file mode 100644 index f95183516..000000000 --- a/docs/scorpio/Design.md +++ /dev/null @@ -1,20 +0,0 @@ -# Preliminary proposal - -> **⚠️ IMPORTANT: Scorpio has been moved to a separate repository** -> -> This documentation is kept for historical reference. For the latest Scorpio/ScorpioFS development, please visit: -> **https://github.com/web3infra-foundation/scorpiofs** - -The following figure shows the preliminary design proposal of Scorpio. The `Repo Manager` is responsible for recording the mount point. - -Each Part Checkout corresponds to a `Checkout-Mounter`. It includes a Readonly Store and a `Mutable Overlay` and a `Readonly Store`. - -![Struct](../images/scorpio.svg) - -- `Mutable Overlay` as a mutable file layer, only keep the change of the specific checkout workspace. Like the [OverLay FS](https://en.wikipedia.org/wiki/OverlayFS), it will be stacked on top of the read-only layer `Readonly Store`. - -- `Readonly Store` saves a portion of the submitted git files or objects in the git repository and provides them to the upper mount point through multi-level caching. - - -Note that Scorpio may only requires using Tree and Blob objects, becase the commit and tag objects are essentially not related to the file itself, but rather to version management. - diff --git a/jupiter-migrate/Cargo.toml b/jupiter-migrate/Cargo.toml new file mode 100644 index 000000000..522b9b0b1 --- /dev/null +++ b/jupiter-migrate/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "jupiter-migrate" +version = "0.1.0" +edition.workspace = true + +[lib] +name = "jupiter_migrate" +path = "src/lib.rs" + +[dependencies] +callisto = { workspace = true } +common = { workspace = true } +sea-orm = { workspace = true, features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", +] } +sea-orm-migration = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/jupiter-migrate/README.md b/jupiter-migrate/README.md new file mode 100644 index 000000000..f522ca2ae --- /dev/null +++ b/jupiter-migrate/README.md @@ -0,0 +1,39 @@ +# jupiter-migrate + +SeaORM database migrations for Mega, extracted from `jupiter` so day-to-day `cargo check` does not compile migration code unless needed. + +## Apply migrations + +`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. + +Crates that need a migrated DB in tests should enable `jupiter/migrate` or `ceres` feature `migrate`. + +## Generate a new migration + +```bash +cd jupiter-migrate/src/migration +sea-orm-cli migrate generate "your_migration_name" +``` + +Commit the new file under `jupiter-migrate/src/migration/`. + +## Regenerate entities + +After schema changes, regenerate callisto entities (adjust connection URL for your DB): + +```bash +sea-orm-cli generate entity \ + -u postgres://postgres:postgres@localhost:5432/mono \ + -o jupiter/callisto/src \ + --with-serde both +``` + +Review generated diffs in `jupiter/callisto/src/` before committing. + +## Library API + +```rust +use jupiter_migrate::{apply_migrations, Migrator}; +``` + +`apply_migrations(&db, refresh)` runs pending migrations. `Migrator` is the SeaORM migrator trait implementation. diff --git a/jupiter-migrate/src/lib.rs b/jupiter-migrate/src/lib.rs new file mode 100644 index 000000000..dc7b47a34 --- /dev/null +++ b/jupiter-migrate/src/lib.rs @@ -0,0 +1,5 @@ +//! SeaORM database migrations for Mega (extracted from jupiter for faster `cargo check`). + +pub mod migration; + +pub use migration::{Migrator, apply_migrations}; diff --git a/jupiter/src/migration/m20250314_025943_init.rs b/jupiter-migrate/src/migration/m20250314_025943_init.rs similarity index 100% rename from jupiter/src/migration/m20250314_025943_init.rs rename to jupiter-migrate/src/migration/m20250314_025943_init.rs diff --git a/jupiter/src/migration/m20250427_031332_add_mr_refs_tag.rs b/jupiter-migrate/src/migration/m20250427_031332_add_mr_refs_tag.rs similarity index 100% rename from jupiter/src/migration/m20250427_031332_add_mr_refs_tag.rs rename to jupiter-migrate/src/migration/m20250427_031332_add_mr_refs_tag.rs diff --git a/jupiter/src/migration/m20250605_013340_alter_mega_mr_index.rs b/jupiter-migrate/src/migration/m20250605_013340_alter_mega_mr_index.rs similarity index 100% rename from jupiter/src/migration/m20250605_013340_alter_mega_mr_index.rs rename to jupiter-migrate/src/migration/m20250605_013340_alter_mega_mr_index.rs diff --git a/jupiter/src/migration/m20250610_000001_add_vault_storage.rs b/jupiter-migrate/src/migration/m20250610_000001_add_vault_storage.rs similarity index 100% rename from jupiter/src/migration/m20250610_000001_add_vault_storage.rs rename to jupiter-migrate/src/migration/m20250610_000001_add_vault_storage.rs diff --git a/jupiter/src/migration/m20250613_033821_alter_user_id.rs b/jupiter-migrate/src/migration/m20250613_033821_alter_user_id.rs similarity index 100% rename from jupiter/src/migration/m20250613_033821_alter_user_id.rs rename to jupiter-migrate/src/migration/m20250613_033821_alter_user_id.rs diff --git a/jupiter/src/migration/m20250618_065050_add_label.rs b/jupiter-migrate/src/migration/m20250618_065050_add_label.rs similarity index 100% rename from jupiter/src/migration/m20250618_065050_add_label.rs rename to jupiter-migrate/src/migration/m20250618_065050_add_label.rs diff --git a/jupiter/src/migration/m20250628_025312_add_username_in_conversation.rs b/jupiter-migrate/src/migration/m20250628_025312_add_username_in_conversation.rs similarity index 100% rename from jupiter/src/migration/m20250628_025312_add_username_in_conversation.rs rename to jupiter-migrate/src/migration/m20250628_025312_add_username_in_conversation.rs diff --git a/jupiter/src/migration/m20250702_072055_add_item_assignees.rs b/jupiter-migrate/src/migration/m20250702_072055_add_item_assignees.rs similarity index 100% rename from jupiter/src/migration/m20250702_072055_add_item_assignees.rs rename to jupiter-migrate/src/migration/m20250702_072055_add_item_assignees.rs diff --git a/jupiter/src/migration/m20250710_073119_create_reactions.rs b/jupiter-migrate/src/migration/m20250710_073119_create_reactions.rs similarity index 100% rename from jupiter/src/migration/m20250710_073119_create_reactions.rs rename to jupiter-migrate/src/migration/m20250710_073119_create_reactions.rs diff --git a/jupiter/src/migration/m20250725_103004_add_note.rs b/jupiter-migrate/src/migration/m20250725_103004_add_note.rs similarity index 100% rename from jupiter/src/migration/m20250725_103004_add_note.rs rename to jupiter-migrate/src/migration/m20250725_103004_add_note.rs diff --git a/jupiter/src/migration/m20250804_151214_alter_builds_end_at.rs b/jupiter-migrate/src/migration/m20250804_151214_alter_builds_end_at.rs similarity index 100% rename from jupiter/src/migration/m20250804_151214_alter_builds_end_at.rs rename to jupiter-migrate/src/migration/m20250804_151214_alter_builds_end_at.rs diff --git a/jupiter/src/migration/m20250812_022434_alter_mega_mr.rs b/jupiter-migrate/src/migration/m20250812_022434_alter_mega_mr.rs similarity index 100% rename from jupiter/src/migration/m20250812_022434_alter_mega_mr.rs rename to jupiter-migrate/src/migration/m20250812_022434_alter_mega_mr.rs diff --git a/jupiter/src/migration/m20250815_075653_remove_commit_id.rs b/jupiter-migrate/src/migration/m20250815_075653_remove_commit_id.rs similarity index 100% rename from jupiter/src/migration/m20250815_075653_remove_commit_id.rs rename to jupiter-migrate/src/migration/m20250815_075653_remove_commit_id.rs diff --git a/jupiter/src/migration/m20250819_025231_alter_builds.rs b/jupiter-migrate/src/migration/m20250819_025231_alter_builds.rs similarity index 100% rename from jupiter/src/migration/m20250819_025231_alter_builds.rs rename to jupiter-migrate/src/migration/m20250819_025231_alter_builds.rs diff --git a/jupiter/src/migration/m20250820_102133_gpgkey.rs b/jupiter-migrate/src/migration/m20250820_102133_gpgkey.rs similarity index 100% rename from jupiter/src/migration/m20250820_102133_gpgkey.rs rename to jupiter-migrate/src/migration/m20250820_102133_gpgkey.rs diff --git a/jupiter/src/migration/m20250821_083749_add_checks.rs b/jupiter-migrate/src/migration/m20250821_083749_add_checks.rs similarity index 100% rename from jupiter/src/migration/m20250821_083749_add_checks.rs rename to jupiter-migrate/src/migration/m20250821_083749_add_checks.rs diff --git a/jupiter/src/migration/m20250828_092459_remove_gpg_table.rs b/jupiter-migrate/src/migration/m20250828_092459_remove_gpg_table.rs similarity index 100% rename from jupiter/src/migration/m20250828_092459_remove_gpg_table.rs rename to jupiter-migrate/src/migration/m20250828_092459_remove_gpg_table.rs diff --git a/jupiter/src/migration/m20250828_092729_create_standalone_table.rs b/jupiter-migrate/src/migration/m20250828_092729_create_standalone_table.rs similarity index 100% rename from jupiter/src/migration/m20250828_092729_create_standalone_table.rs rename to jupiter-migrate/src/migration/m20250828_092729_create_standalone_table.rs diff --git a/jupiter/src/migration/m20250903_013904_create_task_table.rs b/jupiter-migrate/src/migration/m20250903_013904_create_task_table.rs similarity index 100% rename from jupiter/src/migration/m20250903_013904_create_task_table.rs rename to jupiter-migrate/src/migration/m20250903_013904_create_task_table.rs diff --git a/jupiter/src/migration/m20250903_071928_add_issue_refs.rs b/jupiter-migrate/src/migration/m20250903_071928_add_issue_refs.rs similarity index 100% rename from jupiter/src/migration/m20250903_071928_add_issue_refs.rs rename to jupiter-migrate/src/migration/m20250903_071928_add_issue_refs.rs diff --git a/jupiter/src/migration/m20250904_074945_modify_tasks_and_builds.rs b/jupiter-migrate/src/migration/m20250904_074945_modify_tasks_and_builds.rs similarity index 100% rename from jupiter/src/migration/m20250904_074945_modify_tasks_and_builds.rs rename to jupiter-migrate/src/migration/m20250904_074945_modify_tasks_and_builds.rs diff --git a/jupiter/src/migration/m20250904_120000_add_commit_auths.rs b/jupiter-migrate/src/migration/m20250904_120000_add_commit_auths.rs similarity index 100% rename from jupiter/src/migration/m20250904_120000_add_commit_auths.rs rename to jupiter-migrate/src/migration/m20250904_120000_add_commit_auths.rs diff --git a/jupiter/src/migration/m20250905_163011_add_mr_reviewer.rs b/jupiter-migrate/src/migration/m20250905_163011_add_mr_reviewer.rs similarity index 100% rename from jupiter/src/migration/m20250905_163011_add_mr_reviewer.rs rename to jupiter-migrate/src/migration/m20250905_163011_add_mr_reviewer.rs diff --git a/jupiter/src/migration/m20250910_153212_add_username_to_reviewer.rs b/jupiter-migrate/src/migration/m20250910_153212_add_username_to_reviewer.rs similarity index 100% rename from jupiter/src/migration/m20250910_153212_add_username_to_reviewer.rs rename to jupiter-migrate/src/migration/m20250910_153212_add_username_to_reviewer.rs diff --git a/jupiter/src/migration/m20250930_024736_mr_to_cl.rs b/jupiter-migrate/src/migration/m20250930_024736_mr_to_cl.rs similarity index 100% rename from jupiter/src/migration/m20250930_024736_mr_to_cl.rs rename to jupiter-migrate/src/migration/m20250930_024736_mr_to_cl.rs diff --git a/jupiter/src/migration/m20251011_091944_tasks_mr_id_to_cl_id.rs b/jupiter-migrate/src/migration/m20251011_091944_tasks_mr_id_to_cl_id.rs similarity index 100% rename from jupiter/src/migration/m20251011_091944_tasks_mr_id_to_cl_id.rs rename to jupiter-migrate/src/migration/m20251011_091944_tasks_mr_id_to_cl_id.rs diff --git a/jupiter/src/migration/m20251012_071700_mr_to_cl_batch.rs b/jupiter-migrate/src/migration/m20251012_071700_mr_to_cl_batch.rs similarity index 100% rename from jupiter/src/migration/m20251012_071700_mr_to_cl_batch.rs rename to jupiter-migrate/src/migration/m20251012_071700_mr_to_cl_batch.rs diff --git a/jupiter/src/migration/m20251021_073817_rename_mr_sync_to_cl_sync.rs b/jupiter-migrate/src/migration/m20251021_073817_rename_mr_sync_to_cl_sync.rs similarity index 100% rename from jupiter/src/migration/m20251021_073817_rename_mr_sync_to_cl_sync.rs rename to jupiter-migrate/src/migration/m20251021_073817_rename_mr_sync_to_cl_sync.rs diff --git a/jupiter/src/migration/m20251026_065433_drop_user_table.rs b/jupiter-migrate/src/migration/m20251026_065433_drop_user_table.rs similarity index 100% rename from jupiter/src/migration/m20251026_065433_drop_user_table.rs rename to jupiter-migrate/src/migration/m20251026_065433_drop_user_table.rs diff --git a/jupiter/src/migration/m20251027_062734_add_metadata_to_object.rs b/jupiter-migrate/src/migration/m20251027_062734_add_metadata_to_object.rs similarity index 100% rename from jupiter/src/migration/m20251027_062734_add_metadata_to_object.rs rename to jupiter-migrate/src/migration/m20251027_062734_add_metadata_to_object.rs diff --git a/jupiter/src/migration/m20251107_025431_add_cl_commits.rs b/jupiter-migrate/src/migration/m20251107_025431_add_cl_commits.rs similarity index 100% rename from jupiter/src/migration/m20251107_025431_add_cl_commits.rs rename to jupiter-migrate/src/migration/m20251107_025431_add_cl_commits.rs diff --git a/jupiter/src/migration/m20251109_073000_add_merge_queue.rs b/jupiter-migrate/src/migration/m20251109_073000_add_merge_queue.rs similarity index 100% rename from jupiter/src/migration/m20251109_073000_add_merge_queue.rs rename to jupiter-migrate/src/migration/m20251109_073000_add_merge_queue.rs diff --git a/jupiter/src/migration/m20251117_101804_add_commit_id_in_mega_tree.rs b/jupiter-migrate/src/migration/m20251117_101804_add_commit_id_in_mega_tree.rs similarity index 100% rename from jupiter/src/migration/m20251117_101804_add_commit_id_in_mega_tree.rs rename to jupiter-migrate/src/migration/m20251117_101804_add_commit_id_in_mega_tree.rs diff --git a/jupiter/src/migration/m20251117_181240_add_system_required_field_for_reviewer.rs b/jupiter-migrate/src/migration/m20251117_181240_add_system_required_field_for_reviewer.rs similarity index 100% rename from jupiter/src/migration/m20251117_181240_add_system_required_field_for_reviewer.rs rename to jupiter-migrate/src/migration/m20251117_181240_add_system_required_field_for_reviewer.rs diff --git a/jupiter/src/migration/m20251119_145041_add_draft_status.rs b/jupiter-migrate/src/migration/m20251119_145041_add_draft_status.rs similarity index 100% rename from jupiter/src/migration/m20251119_145041_add_draft_status.rs rename to jupiter-migrate/src/migration/m20251119_145041_add_draft_status.rs diff --git a/jupiter/src/migration/m20251125_135032_add_draft_conv_type.rs b/jupiter-migrate/src/migration/m20251125_135032_add_draft_conv_type.rs similarity index 100% rename from jupiter/src/migration/m20251125_135032_add_draft_conv_type.rs rename to jupiter-migrate/src/migration/m20251125_135032_add_draft_conv_type.rs diff --git a/jupiter/src/migration/m20251128_000001_create_buck_session.rs b/jupiter-migrate/src/migration/m20251128_000001_create_buck_session.rs similarity index 100% rename from jupiter/src/migration/m20251128_000001_create_buck_session.rs rename to jupiter-migrate/src/migration/m20251128_000001_create_buck_session.rs diff --git a/jupiter/src/migration/m20251203_013745_add_dynamic_sidebar.rs b/jupiter-migrate/src/migration/m20251203_013745_add_dynamic_sidebar.rs similarity index 100% rename from jupiter/src/migration/m20251203_013745_add_dynamic_sidebar.rs rename to jupiter-migrate/src/migration/m20251203_013745_add_dynamic_sidebar.rs diff --git a/jupiter/src/migration/m20251210_113942_remove_unique_constraint_from_order_index.rs b/jupiter-migrate/src/migration/m20251210_113942_remove_unique_constraint_from_order_index.rs similarity index 100% rename from jupiter/src/migration/m20251210_113942_remove_unique_constraint_from_order_index.rs rename to jupiter-migrate/src/migration/m20251210_113942_remove_unique_constraint_from_order_index.rs diff --git a/jupiter/src/migration/m20260106_070511_add_retry_time.rs b/jupiter-migrate/src/migration/m20260106_070511_add_retry_time.rs similarity index 100% rename from jupiter/src/migration/m20260106_070511_add_retry_time.rs rename to jupiter-migrate/src/migration/m20260106_070511_add_retry_time.rs diff --git a/jupiter/src/migration/m20260106_070515_remove_relay_mq_lfs_raw_table.rs b/jupiter-migrate/src/migration/m20260106_070515_remove_relay_mq_lfs_raw_table.rs similarity index 100% rename from jupiter/src/migration/m20260106_070515_remove_relay_mq_lfs_raw_table.rs rename to jupiter-migrate/src/migration/m20260106_070515_remove_relay_mq_lfs_raw_table.rs diff --git a/jupiter/src/migration/m20260108_085945_remove_splited_in_lfs_objects.rs b/jupiter-migrate/src/migration/m20260108_085945_remove_splited_in_lfs_objects.rs similarity index 100% rename from jupiter/src/migration/m20260108_085945_remove_splited_in_lfs_objects.rs rename to jupiter-migrate/src/migration/m20260108_085945_remove_splited_in_lfs_objects.rs diff --git a/jupiter/src/migration/m20260108_105158_remove_storage_type_enum.rs b/jupiter-migrate/src/migration/m20260108_105158_remove_storage_type_enum.rs similarity index 100% rename from jupiter/src/migration/m20260108_105158_remove_storage_type_enum.rs rename to jupiter-migrate/src/migration/m20260108_105158_remove_storage_type_enum.rs diff --git a/jupiter/src/migration/m20260115_000000_create_targets_table.rs b/jupiter-migrate/src/migration/m20260115_000000_create_targets_table.rs similarity index 100% rename from jupiter/src/migration/m20260115_000000_create_targets_table.rs rename to jupiter-migrate/src/migration/m20260115_000000_create_targets_table.rs diff --git a/jupiter/src/migration/m20260119_060233_add_mega_code_review.rs b/jupiter-migrate/src/migration/m20260119_060233_add_mega_code_review.rs similarity index 100% rename from jupiter/src/migration/m20260119_060233_add_mega_code_review.rs rename to jupiter-migrate/src/migration/m20260119_060233_add_mega_code_review.rs diff --git a/jupiter/src/migration/m20260127_081517_create_build_triggers.rs b/jupiter-migrate/src/migration/m20260127_081517_create_build_triggers.rs similarity index 100% rename from jupiter/src/migration/m20260127_081517_create_build_triggers.rs rename to jupiter-migrate/src/migration/m20260127_081517_create_build_triggers.rs diff --git a/jupiter/src/migration/m20260128_080549_add_mega_code_review_anchor_and_position.rs b/jupiter-migrate/src/migration/m20260128_080549_add_mega_code_review_anchor_and_position.rs similarity index 100% rename from jupiter/src/migration/m20260128_080549_add_mega_code_review_anchor_and_position.rs rename to jupiter-migrate/src/migration/m20260128_080549_add_mega_code_review_anchor_and_position.rs diff --git a/jupiter/src/migration/m20260130_065535_refactor_orion_module.rs b/jupiter-migrate/src/migration/m20260130_065535_refactor_orion_module.rs similarity index 100% rename from jupiter/src/migration/m20260130_065535_refactor_orion_module.rs rename to jupiter-migrate/src/migration/m20260130_065535_refactor_orion_module.rs diff --git a/jupiter/src/migration/m20260208_012349_change_build_events.rs b/jupiter-migrate/src/migration/m20260208_012349_change_build_events.rs similarity index 100% rename from jupiter/src/migration/m20260208_012349_change_build_events.rs rename to jupiter-migrate/src/migration/m20260208_012349_change_build_events.rs diff --git a/jupiter/src/migration/m20260209_064016_remove_default_dynamic_sidebar.rs b/jupiter-migrate/src/migration/m20260209_064016_remove_default_dynamic_sidebar.rs similarity index 100% rename from jupiter/src/migration/m20260209_064016_remove_default_dynamic_sidebar.rs rename to jupiter-migrate/src/migration/m20260209_064016_remove_default_dynamic_sidebar.rs diff --git a/jupiter/src/migration/m20260210_062050_create_target_state_history.rs b/jupiter-migrate/src/migration/m20260210_062050_create_target_state_history.rs similarity index 100% rename from jupiter/src/migration/m20260210_062050_create_target_state_history.rs rename to jupiter-migrate/src/migration/m20260210_062050_create_target_state_history.rs diff --git a/jupiter/src/migration/m20260211_102158_add_username_to_mega_cl_sqlite.rs b/jupiter-migrate/src/migration/m20260211_102158_add_username_to_mega_cl_sqlite.rs similarity index 100% rename from jupiter/src/migration/m20260211_102158_add_username_to_mega_cl_sqlite.rs rename to jupiter-migrate/src/migration/m20260211_102158_add_username_to_mega_cl_sqlite.rs diff --git a/jupiter/src/migration/m20260216_013852_create_group_permission_tables.rs b/jupiter-migrate/src/migration/m20260216_013852_create_group_permission_tables.rs similarity index 100% rename from jupiter/src/migration/m20260216_013852_create_group_permission_tables.rs rename to jupiter-migrate/src/migration/m20260216_013852_create_group_permission_tables.rs diff --git a/jupiter/src/migration/m20260224_142019_create_target_build_status.rs b/jupiter-migrate/src/migration/m20260224_142019_create_target_build_status.rs similarity index 100% rename from jupiter/src/migration/m20260224_142019_create_target_build_status.rs rename to jupiter-migrate/src/migration/m20260224_142019_create_target_build_status.rs diff --git a/jupiter/src/migration/m20260224_230000_create_notification_center.rs b/jupiter-migrate/src/migration/m20260224_230000_create_notification_center.rs similarity index 100% rename from jupiter/src/migration/m20260224_230000_create_notification_center.rs rename to jupiter-migrate/src/migration/m20260224_230000_create_notification_center.rs diff --git a/jupiter/src/migration/m20260228_100254_change_build_target_and_add_index_for_build_event_start_at.rs b/jupiter-migrate/src/migration/m20260228_100254_change_build_target_and_add_index_for_build_event_start_at.rs similarity index 100% rename from jupiter/src/migration/m20260228_100254_change_build_target_and_add_index_for_build_event_start_at.rs rename to jupiter-migrate/src/migration/m20260228_100254_change_build_target_and_add_index_for_build_event_start_at.rs diff --git a/jupiter/src/migration/m20260302_082846_add_cla_sign_status.rs b/jupiter-migrate/src/migration/m20260302_082846_add_cla_sign_status.rs similarity index 100% rename from jupiter/src/migration/m20260302_082846_add_cla_sign_status.rs rename to jupiter-migrate/src/migration/m20260302_082846_add_cla_sign_status.rs diff --git a/jupiter/src/migration/m20260304_013434_seed_cla_sign_check_config.rs b/jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs similarity index 100% rename from jupiter/src/migration/m20260304_013434_seed_cla_sign_check_config.rs rename to jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs diff --git a/jupiter/src/migration/m20260306_121829_create_bots_related_table.rs b/jupiter-migrate/src/migration/m20260306_121829_create_bots_related_table.rs similarity index 100% rename from jupiter/src/migration/m20260306_121829_create_bots_related_table.rs rename to jupiter-migrate/src/migration/m20260306_121829_create_bots_related_table.rs diff --git a/jupiter/src/migration/m20260308_191753_create_webhook.rs b/jupiter-migrate/src/migration/m20260308_191753_create_webhook.rs similarity index 100% rename from jupiter/src/migration/m20260308_191753_create_webhook.rs rename to jupiter-migrate/src/migration/m20260308_191753_create_webhook.rs diff --git a/jupiter/src/migration/m20260308_220000_add_base_branch_to_mega_cl.rs b/jupiter-migrate/src/migration/m20260308_220000_add_base_branch_to_mega_cl.rs similarity index 100% rename from jupiter/src/migration/m20260308_220000_add_base_branch_to_mega_cl.rs rename to jupiter-migrate/src/migration/m20260308_220000_add_base_branch_to_mega_cl.rs diff --git a/jupiter/src/migration/m20260308_230000_normalize_webhook_event_types.rs b/jupiter-migrate/src/migration/m20260308_230000_normalize_webhook_event_types.rs similarity index 100% rename from jupiter/src/migration/m20260308_230000_normalize_webhook_event_types.rs rename to jupiter-migrate/src/migration/m20260308_230000_normalize_webhook_event_types.rs diff --git a/jupiter/src/migration/m20260316_120000_add_bot_tokens_token_hash_index.rs b/jupiter-migrate/src/migration/m20260316_120000_add_bot_tokens_token_hash_index.rs similarity index 100% rename from jupiter/src/migration/m20260316_120000_add_bot_tokens_token_hash_index.rs rename to jupiter-migrate/src/migration/m20260316_120000_add_bot_tokens_token_hash_index.rs diff --git a/jupiter/src/migration/m20260324_024559_add_notes.rs b/jupiter-migrate/src/migration/m20260324_024559_add_notes.rs similarity index 100% rename from jupiter/src/migration/m20260324_024559_add_notes.rs rename to jupiter-migrate/src/migration/m20260324_024559_add_notes.rs diff --git a/jupiter/src/migration/m20260324_033322_fix_migration.rs b/jupiter-migrate/src/migration/m20260324_033322_fix_migration.rs similarity index 100% rename from jupiter/src/migration/m20260324_033322_fix_migration.rs rename to jupiter-migrate/src/migration/m20260324_033322_fix_migration.rs diff --git a/jupiter/src/migration/m20260327_034553_drop_legacy_tasks.rs b/jupiter-migrate/src/migration/m20260327_034553_drop_legacy_tasks.rs similarity index 100% rename from jupiter/src/migration/m20260327_034553_drop_legacy_tasks.rs rename to jupiter-migrate/src/migration/m20260327_034553_drop_legacy_tasks.rs diff --git a/jupiter/src/migration/m20260413_033315_create_artifact_tables.rs b/jupiter-migrate/src/migration/m20260413_033315_create_artifact_tables.rs similarity index 100% rename from jupiter/src/migration/m20260413_033315_create_artifact_tables.rs rename to jupiter-migrate/src/migration/m20260413_033315_create_artifact_tables.rs diff --git a/jupiter/src/migration/m20260612_011232_drop_build_events_log.rs b/jupiter-migrate/src/migration/m20260612_011232_drop_build_events_log.rs similarity index 100% rename from jupiter/src/migration/m20260612_011232_drop_build_events_log.rs rename to jupiter-migrate/src/migration/m20260612_011232_drop_build_events_log.rs diff --git a/jupiter/src/migration/mod.rs b/jupiter-migrate/src/migration/mod.rs similarity index 99% rename from jupiter/src/migration/mod.rs rename to jupiter-migrate/src/migration/mod.rs index 140e8d366..01b0760b9 100644 --- a/jupiter/src/migration/mod.rs +++ b/jupiter-migrate/src/migration/mod.rs @@ -20,7 +20,7 @@ //! # Usage //! //! ```rust,ignore -//! use jupiter::migrator::apply_migrations; +//! use jupiter_migrate::apply_migrations; //! //! // Apply pending migrations //! apply_migrations(&db, false).await?; diff --git a/jupiter/src/migration/runner.rs b/jupiter-migrate/src/migration/runner.rs similarity index 91% rename from jupiter/src/migration/runner.rs rename to jupiter-migrate/src/migration/runner.rs index fd989ab66..60611292e 100644 --- a/jupiter/src/migration/runner.rs +++ b/jupiter-migrate/src/migration/runner.rs @@ -23,11 +23,20 @@ mod tests { email_jobs, notification_event_types, user_notification_preferences, user_notification_settings, }; - use sea_orm::{ActiveModelTrait, ConnectionTrait, DbBackend, Set, Statement}; + use sea_orm::{ + ActiveModelTrait, ConnectOptions, ConnectionTrait, Database, DbBackend, Set, Statement, + }; use sea_orm_migration::prelude::MigratorTrait; use super::*; - use crate::tests::test_db_connection; + + async fn test_db_connection(temp_dir: &std::path::Path) -> DatabaseConnection { + let db_url = format!("sqlite://{}/test.db", temp_dir.to_string_lossy()); + std::fs::File::create(temp_dir.join("test.db")).expect("create test db file"); + let mut opt = ConnectOptions::new(db_url); + opt.max_connections(5).min_connections(1); + Database::connect(opt).await.expect("connect test database") + } #[tokio::test] async fn test_apply_migrations() { diff --git a/jupiter/Cargo.toml b/jupiter/Cargo.toml index e4d6ed3d5..884dba1fe 100644 --- a/jupiter/Cargo.toml +++ b/jupiter/Cargo.toml @@ -9,6 +9,10 @@ edition.workspace = true name = "jupiter" path = "src/lib.rs" +[features] +default = [] +migrate = ["dep:jupiter-migrate"] + [dependencies] callisto = { workspace = true } common = { workspace = true } @@ -16,6 +20,7 @@ git-internal = { workspace = true } saturn = { workspace = true } io-orbit = { workspace = true } api-model = { workspace = true } +jupiter-migrate = { workspace = true, optional = true } sea-orm = { workspace = true, features = [ "sqlx-postgres", @@ -24,7 +29,6 @@ sea-orm = { workspace = true, features = [ "macros", "debug-print", ] } -sea-orm-migration = { workspace = true } tracing = { workspace = true } bytes = { workspace = true } @@ -65,3 +69,4 @@ ring = "0.17.14" [dev-dependencies] redis-test = { workspace = true, features = ["aio"] } +jupiter-migrate = { workspace = true } diff --git a/jupiter/README.md b/jupiter/README.md index d84bd9829..7ca35963a 100644 --- a/jupiter/README.md +++ b/jupiter/README.md @@ -1,71 +1,7 @@ -## Jupiter Module - Monorepo and Mega Database Storage Engine +## Jupiter — Storage Engine -### Migration Guideline -1. Generate new migration +`jupiter` provides the database storage layer for Mega: SeaORM services, query helpers, and `jupiter/model` storage assembly types. -```bash +Generated entities live in [`callisto/`](callisto/). Schema migrations live in the separate [`jupiter-migrate`](../jupiter-migrate/) crate — see [jupiter-migrate/README.md](../jupiter-migrate/README.md) for generate/apply workflow. -cd mega/jupiter/src - -sea-orm-cli migrate generate "your_migration_name" -``` - -2. Generate entity files - -```bash - -cd mega/jupiter/src - -sea-orm-cli generate entity -u postgres://postgres:postgres@localhost:5432/mono -o ../callisto/src --with-serde both - -``` - -3. Apply Migration - -```bash -cd mega - -cargo run --bin mono -- service up -``` - -4. [Optional] Running Migrator CLI - -- Generate a new migration file - ```sh - cargo run -- generate MIGRATION_NAME - ``` -- Apply all pending migrations - ```sh - cargo run - ``` - ```sh - cargo run -- up - ``` -- Apply first 10 pending migrations - ```sh - cargo run -- up -n 10 - ``` -- Rollback last applied migrations - ```sh - cargo run -- down - ``` -- Rollback last 10 applied migrations - ```sh - cargo run -- down -n 10 - ``` -- Drop all tables from the database, then reapply all migrations - ```sh - cargo run -- fresh - ``` -- Rollback all applied migrations, then reapply all migrations - ```sh - cargo run -- refresh - ``` -- Rollback all applied migrations - ```sh - cargo run -- reset - ``` -- Check the status of all migrations - ```sh - cargo run -- status - ``` +`ceres` and `mono` consume `jupiter::storage::Storage`; HTTP DTO mapping belongs in `ceres/model`, not in routers. diff --git a/jupiter/src/lib.rs b/jupiter/src/lib.rs index b8e715496..8072e2610 100644 --- a/jupiter/src/lib.rs +++ b/jupiter/src/lib.rs @@ -1,9 +1,10 @@ /// Shared ID generation used with DB / storage paths. pub use idgenerator; +#[cfg(feature = "migrate")] +pub use jupiter_migrate; /// SeaORM — storage layer; dependents may use `jupiter::sea_orm` without a direct `sea-orm` dependency where appropriate. pub use sea_orm; -pub mod migration; pub mod model; pub mod redis; pub mod service; diff --git a/jupiter/src/model/mod.rs b/jupiter/src/model/mod.rs index eba481ce1..606dbcbca 100644 --- a/jupiter/src/model/mod.rs +++ b/jupiter/src/model/mod.rs @@ -1,8 +1,13 @@ -pub mod bot_token_dto; +//! Storage-layer assembly DTOs (bundles of `callisto` entities). +//! +//! These types are internal to Jupiter storage/services. Ceres application code may +//! construct them when calling storage; mono must not import this module. + +pub(crate) mod bot_token_dto; pub mod cl_dto; pub mod code_review_dto; pub mod common; -pub mod conv_dto; +pub(crate) mod conv_dto; pub mod group_dto; pub mod issue_dto; pub mod merge_queue_dto; diff --git a/jupiter/src/service/cl_service.rs b/jupiter/src/service/cl_service.rs index 16044b5d0..ab8230076 100644 --- a/jupiter/src/service/cl_service.rs +++ b/jupiter/src/service/cl_service.rs @@ -1,12 +1,9 @@ use common::errors::MegaError; -use crate::{ - model::cl_dto::CLDetails, - storage::{ - base_storage::{BaseStorage, StorageConnector}, - cl_storage::ClStorage, - conversation_storage::ConversationStorage, - }, +use crate::storage::{ + base_storage::{BaseStorage, StorageConnector}, + cl_storage::ClStorage, + conversation_storage::ConversationStorage, }; #[derive(Clone)] @@ -35,39 +32,6 @@ impl CLService { } } - #[deprecated(note = "use ceres::MonoApiService::get_cl_details instead")] - pub async fn get_cl_details( - &self, - link: &str, - username: String, - ) -> Result { - let (cl, labels) = self - .cl_storage - .get_cl_labels(link) - .await? - .ok_or_else(|| MegaError::Other("CL not found".to_string()))?; - - let conversations = self - .conversation_storage - .get_comments_with_reactions(link) - .await?; - - let (_, assignees) = self - .cl_storage - .get_cl_assignees(link) - .await? - .unwrap_or((cl.clone(), vec![])); - - let res = CLDetails { - cl, - labels, - conversations, - assignees, - username, - }; - Ok(res) - } - /// Create a new Draft CL /// /// # Arguments diff --git a/jupiter/src/storage/base_storage.rs b/jupiter/src/storage/base_storage.rs index 6169447a9..df03357f9 100644 --- a/jupiter/src/storage/base_storage.rs +++ b/jupiter/src/storage/base_storage.rs @@ -6,7 +6,6 @@ use sea_orm::{ ActiveModelTrait, DatabaseConnection, DatabaseTransaction, DbErr, EntityTrait, sea_query::OnConflict, }; -use sea_orm_migration::SchemaManagerConnection; #[async_trait] pub trait StorageConnector { @@ -18,38 +17,7 @@ pub trait StorageConnector { fn new(connection: Arc) -> Self; - fn build_connection_with_txn<'a>( - &'a self, - txn: Option<&'a DatabaseTransaction>, - ) -> SchemaManagerConnection<'a> { - if let Some(txn) = txn { - SchemaManagerConnection::Transaction(txn) - } else { - SchemaManagerConnection::Connection(self.get_connection()) - } - } - /// Performs batch saving of models in the database. - /// - /// The method takes a vector of models to be saved and performs batch inserts using the given entity type `E`. - /// The models should implement the `ActiveModelTrait` trait, which provides the necessary functionality for saving and inserting the models. - /// - /// The method splits the models into smaller chunks, each containing models configured by chunk_size, and inserts them into the database using the `E::insert_many` function. - /// The results of each insertion are collected into a vector of futures. - /// - /// Note: Currently, SQLx does not support packets larger than 16MB. - /// # Arguments - /// - /// * `save_models` - A vector of models to be saved. - /// - /// # Generic Constraints - /// - /// * `E` - The entity type that implements the `EntityTrait` trait. - /// * `A` - The model type that implements the `ActiveModelTrait` trait and is convertible from the corresponding model type of `E`. - /// - /// # Errors - /// - /// Returns a `MegaError` if an error occurs during the batch save operation. async fn batch_save_model(&self, save_models: Vec) -> Result<(), MegaError> where E: EntityTrait, @@ -82,19 +50,18 @@ pub trait StorageConnector { E: EntityTrait, A: ActiveModelTrait + From<::Model> + Send, { - let conn = self.build_connection_with_txn(txn); - let mut i = 0; let len = save_models.len(); while i < len { let end = (i + Self::BATCH_CHUNK_SIZE).min(len); let models = save_models[i..end].to_vec(); - let _ = match E::insert_many(models) - .on_conflict(onconflict.clone()) - .exec(&conn) - .await - { + let insert = E::insert_many(models).on_conflict(onconflict.clone()); + let _ = match if let Some(txn) = txn { + insert.exec(txn).await + } else { + insert.exec(self.get_connection()).await + } { Ok(_) => Ok(()), Err(DbErr::RecordNotInserted) => Ok(()), Err(e) => Err(e), diff --git a/jupiter/src/storage/git_db_storage.rs b/jupiter/src/storage/git_db_storage.rs index 439fcca98..70ff80238 100644 --- a/jupiter/src/storage/git_db_storage.rs +++ b/jupiter/src/storage/git_db_storage.rs @@ -136,9 +136,8 @@ impl GitDbStorage { txn: &DatabaseTransaction, ) -> Result<(), MegaError> { refs.repo_id = repo_id; - let conn = self.build_connection_with_txn(Some(txn)); import_refs::Entity::insert(refs.into_active_model()) - .exec(&conn) + .exec(txn) .await .map_err(|e| MegaError::Other(format!("Failed to insert import_refs: {e}")))?; Ok(()) diff --git a/jupiter/src/storage/group_storage.rs b/jupiter/src/storage/group_storage.rs index d5eda868f..c311ff0a0 100644 --- a/jupiter/src/storage/group_storage.rs +++ b/jupiter/src/storage/group_storage.rs @@ -59,7 +59,7 @@ impl GroupStorage { .await .map_err(|e| match e { DbErr::RecordNotInserted => { - MegaError::Other(format!("[code:409] Group already exists: {name}")) + MegaError::Conflict(format!("Group already exists: {name}")) } _ => e.into(), })?; @@ -466,7 +466,7 @@ fn is_unique_constraint_error(err: &DbErr) -> bool { fn map_unique_to_conflict(err: DbErr, group_name: String) -> MegaError { if is_unique_constraint_error(&err) { - MegaError::Other(format!("[code:409] Group already exists: {group_name}")) + MegaError::Conflict(format!("Group already exists: {group_name}")) } else { err.into() } @@ -503,7 +503,7 @@ mod tests { for result in [res_a, res_b] { match result { Ok(_) => success_count += 1, - Err(MegaError::Other(msg)) if msg.contains("[code:409]") => conflict_count += 1, + Err(MegaError::Conflict(_)) => conflict_count += 1, Err(e) => panic!("unexpected error: {e}"), } } @@ -582,7 +582,7 @@ mod tests { .await; match result { - Err(MegaError::Other(msg)) if msg.contains("[code:409]") => {} + Err(MegaError::Conflict(_)) => {} other => panic!("unexpected result: {other:?}"), } } diff --git a/jupiter/src/storage/init.rs b/jupiter/src/storage/init.rs index 1dd89cb61..ad1942732 100644 --- a/jupiter/src/storage/init.rs +++ b/jupiter/src/storage/init.rs @@ -5,11 +5,13 @@ use std::{ }; use common::{config::DbConfig, errors::MegaError}; +#[cfg(feature = "migrate")] +use jupiter_migrate::apply_migrations; use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use tracing::log; use url::Url; -use crate::{migration::apply_migrations, utils::id_generator}; +use crate::utils::id_generator; /// Create a database connection with failover logic. /// @@ -63,6 +65,7 @@ pub async fn database_connection(db_config: &DbConfig) -> DatabaseConnection { } else { sqlite_connection(db_config).await.unwrap() }; + #[cfg(feature = "migrate")] apply_migrations(&conn, false) .await .expect("Failed to apply migrations"); diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index 9478f6dcb..22c703a10 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -65,10 +65,17 @@ impl MonoStorage { model: mega_refs::Model, txn: Option<&DatabaseTransaction>, ) -> Result<(), MegaError> { - model - .into_active_model() - .insert(&self.build_connection_with_txn(txn)) - .await?; + match txn { + Some(txn) => { + model.into_active_model().insert(txn).await?; + } + None => { + model + .into_active_model() + .insert(self.get_connection()) + .await?; + } + } Ok(()) } @@ -192,8 +199,14 @@ impl MonoStorage { ref_data.reset(mega_refs::Column::RefCommitHash); ref_data.reset(mega_refs::Column::RefTreeHash); ref_data.reset(mega_refs::Column::UpdatedAt); - let conn = self.build_connection_with_txn(txn); - ref_data.update(&conn).await?; + match txn { + Some(txn) => { + ref_data.update(txn).await?; + } + None => { + ref_data.update(self.get_connection()).await?; + } + } Ok(()) } diff --git a/jupiter/src/storage/notification_storage.rs b/jupiter/src/storage/notification_storage.rs index 5adb3c5b1..bfefd1050 100644 --- a/jupiter/src/storage/notification_storage.rs +++ b/jupiter/src/storage/notification_storage.rs @@ -330,10 +330,11 @@ impl NotificationStorage { #[cfg(test)] mod tests { use callisto::notification_event_types; + use jupiter_migrate::apply_migrations; use sea_orm::{ActiveModelTrait, Set}; use super::*; - use crate::{migration::apply_migrations, tests::test_db_connection}; + use crate::tests::test_db_connection; #[tokio::test] async fn test_should_send_logic() { diff --git a/jupiter/src/storage/vault_storage.rs b/jupiter/src/storage/vault_storage.rs index c74b5b971..03af7c35c 100644 --- a/jupiter/src/storage/vault_storage.rs +++ b/jupiter/src/storage/vault_storage.rs @@ -2,8 +2,7 @@ use std::ops::Deref; use callisto::vault::*; use common::errors::MegaError; -use sea_orm::*; -use sea_orm_migration::prelude::OnConflict; +use sea_orm::{sea_query::OnConflict, *}; use crate::storage::base_storage::{BaseStorage, StorageConnector}; diff --git a/jupiter/src/storage/webhook_storage.rs b/jupiter/src/storage/webhook_storage.rs index dd9688636..7721219d1 100644 --- a/jupiter/src/storage/webhook_storage.rs +++ b/jupiter/src/storage/webhook_storage.rs @@ -204,13 +204,11 @@ mod tests { use api_model::common::Pagination; use chrono::Utc; use idgenerator::IdInstance; + use jupiter_migrate::apply_migrations; use tempfile::TempDir; use super::*; - use crate::{ - migration::apply_migrations, - tests::{test_db_connection, test_storage}, - }; + use crate::tests::{test_db_connection, test_storage}; #[test] fn test_normalize_event_types_dedupes() { diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index 34610dca3..648fdbd01 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -5,11 +5,12 @@ use std::{ use common::config::Config; use io_orbit::factory::MegaObjectStorageWrapper; +#[cfg(feature = "migrate")] +use jupiter_migrate::apply_migrations; use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use tracing::log; use crate::{ - migration::apply_migrations, service::{ artifact_service::ArtifactService, buck_service::BuckService, cl_service::CLService, cla_service::ClaService, code_review_service::CodeReviewService, git_service::GitService, @@ -96,6 +97,7 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { audit_storage: AuditStorage { base: base.clone() }, }; + #[cfg(feature = "migrate")] apply_migrations(&connection, true).await.unwrap(); let webhook_service = WebhookService::mock(svc.webhook_storage.clone()); diff --git a/mono/Cargo.toml b/mono/Cargo.toml index ae8ffc48c..f532be715 100644 --- a/mono/Cargo.toml +++ b/mono/Cargo.toml @@ -19,13 +19,11 @@ path = "src/main.rs" api-model = { workspace = true } orion-client = { workspace = true } common = { workspace = true } -callisto = { workspace = true } -jupiter = { workspace = true } +jupiter = { workspace = true, features = ["migrate"] } lettre = { workspace = true } ceres = { workspace = true } vault = { workspace = true } saturn = { workspace = true } -context = { workspace = true } git-internal = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } @@ -83,6 +81,7 @@ mimalloc = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +jupiter-migrate = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] qlean = { workspace = true } diff --git a/mono/Dockerfile b/mono/Dockerfile index 7493c34ad..bb0529f4f 100644 --- a/mono/Dockerfile +++ b/mono/Dockerfile @@ -26,10 +26,10 @@ COPY Cargo.lock ./ COPY api-model/Cargo.toml api-model/ COPY ceres/Cargo.toml ceres/ COPY common/Cargo.toml common/ -COPY context/Cargo.toml context/ COPY io-orbit/Cargo.toml io-orbit/ COPY jupiter/Cargo.toml jupiter/ COPY jupiter/callisto/Cargo.toml jupiter/callisto/ +COPY jupiter-migrate/Cargo.toml jupiter-migrate/ COPY mono/Cargo.toml mono/ COPY orion/Cargo.toml orion/ COPY orion/audit/Cargo.toml orion/audit/ diff --git a/mono/README.md b/mono/README.md index 1856888aa..1febc2678 100644 --- a/mono/README.md +++ b/mono/README.md @@ -1 +1,30 @@ -## Mega Monorepo Engine \ No newline at end of file +# mono + +Mega monorepo server binary: HTTP REST + Git Smart HTTP, SSH Git, and service composition. + +## Entry points + +- Binary: `src/main.rs` → `mono::cli::parse` +- Library: `src/lib.rs` (CLI, bootstrap, API routers, Git protocol) + +## CLI + +```bash +cargo run --bin mono -- service http +cargo run --bin mono -- service ssh +cargo run --bin mono -- service multi http ssh +``` + +Config: `--config path/to/config.toml` or `MEGA_CONFIG` env var. + +## Layout + +| Path | Role | +|------|------| +| `src/bootstrap/` | `AppContext` — storage, vault, config, redis | +| `src/api/` | REST routers, OpenAPI (`utoipa`), Swagger | +| `src/git_protocol/` | Smart HTTP / SSH Git | +| `src/server/` | HTTP and SSH server startup | +| `src/commands/` | CLI subcommands | + +Domain logic is in `ceres`; see [ceres/README.md](../ceres/README.md). diff --git a/mono/src/api/api_common/comment.rs b/mono/src/api/api_common/comment.rs index c09098738..52c361439 100644 --- a/mono/src/api/api_common/comment.rs +++ b/mono/src/api/api_common/comment.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use api_model::common::CommonResult; use axum::{Json, extract::State}; -use callisto::sea_orm_active_enums::{ConvTypeEnum, ReferenceTypeEnum}; use regex::Regex; use crate::api::{MonoApiServiceState, error::ApiError, oauth::model::LoginUser}; @@ -26,17 +25,8 @@ pub async fn check_comment_ref( let username = user.username; for ref_link in links { state - .issue_stg() - .add_reference(source_link, &ref_link, ReferenceTypeEnum::Mention) - .await?; - state - .conv_stg() - .add_conversation( - &ref_link, - &username, - Some(format!("{username} mentioned this on")), - ConvTypeEnum::Mention, - ) + .monorepo() + .add_issue_mention_reference(source_link, &ref_link, &username) .await?; } diff --git a/mono/src/api/api_common/group_permission.rs b/mono/src/api/api_common/group_permission.rs index 05b37d89c..2c103d342 100644 --- a/mono/src/api/api_common/group_permission.rs +++ b/mono/src/api/api_common/group_permission.rs @@ -1,7 +1,6 @@ use anyhow::anyhow; -use callisto::sea_orm_active_enums::ResourceTypeEnum; use ceres::{ - api_service::mono::EffectiveResourcePermission, + application::api_service::mono::EffectiveResourcePermission, model::group::{PermissionValue, ResourceTypeValue, UserEffectivePermissionResponse}, }; use http::StatusCode; @@ -28,7 +27,7 @@ pub async fn resolve_resource_context( state: &MonoApiServiceState, resource_type: &str, resource_id: &str, -) -> Result<(ResourceTypeEnum, ResourceTypeValue, String), ApiError> { +) -> Result<(ResourceTypeValue, String), ApiError> { state .monorepo() .resolve_resource_context(resource_type, resource_id) diff --git a/mono/src/api/api_common/label_assignee.rs b/mono/src/api/api_common/label_assignee.rs index ab2f626ea..0cbabdb2c 100644 --- a/mono/src/api/api_common/label_assignee.rs +++ b/mono/src/api/api_common/label_assignee.rs @@ -1,10 +1,6 @@ -use std::collections::HashSet; - use api_model::common::CommonResult; use axum::{Json, extract::State}; -use callisto::sea_orm_active_enums::ConvTypeEnum; use ceres::model::{change_list::AssigneeUpdatePayload, label::LabelUpdatePayload}; -use jupiter::model::common::LabelAssigneeParams; use crate::api::{MonoApiServiceState, error::ApiError, oauth::model::LoginUser}; @@ -14,54 +10,17 @@ pub async fn label_update( payload: LabelUpdatePayload, item_type: String, ) -> Result>, ApiError> { - let issue_storage = state.issue_stg(); - let LabelUpdatePayload { label_ids, link, item_id, } = payload; - let old_labels = issue_storage - .find_item_exist_labels(payload.item_id) - .await - .unwrap(); - - let old_ids: HashSet = old_labels.iter().map(|l| l.label_id).collect(); - let new_ids: HashSet = label_ids.iter().copied().collect(); - - let to_add: Vec = new_ids.difference(&old_ids).copied().collect(); - let to_remove: Vec = old_ids.difference(&new_ids).copied().collect(); - - let params = LabelAssigneeParams { item_id, item_type }; - - issue_storage - .modify_labels(to_add.clone(), to_remove.clone(), params) + state + .monorepo() + .update_item_labels(&user.username, item_id, &item_type, label_ids, &link) .await?; - let username = user.username; - if !to_remove.is_empty() { - state - .conv_stg() - .add_conversation( - &link, - &username, - Some(format!("{username} removed {to_remove:?}")), - ConvTypeEnum::Label, - ) - .await?; - } - if !to_add.is_empty() { - state - .conv_stg() - .add_conversation( - &link, - &username, - Some(format!("{username} added {to_add:?}")), - ConvTypeEnum::Label, - ) - .await?; - } Ok(Json(CommonResult::success(None))) } @@ -71,53 +30,16 @@ pub async fn assignees_update( payload: AssigneeUpdatePayload, item_type: String, ) -> Result>, ApiError> { - let issue_storage = state.issue_stg(); - let AssigneeUpdatePayload { assignees, link, item_id, } = payload; - let old_models = issue_storage - .find_item_exist_assignees(payload.item_id) - .await - .unwrap(); - - let old_ids: HashSet = old_models.iter().map(|m| m.assignnee_id.clone()).collect(); - let new_ids: HashSet = assignees.iter().cloned().collect(); - - let to_add: Vec = new_ids.difference(&old_ids).cloned().collect(); - let to_remove: Vec = old_ids.difference(&new_ids).cloned().collect(); - - let params = LabelAssigneeParams { item_id, item_type }; - - issue_storage - .modify_assignees(to_add.clone(), to_remove.clone(), params) + state + .monorepo() + .update_item_assignees(&user.username, item_id, &item_type, assignees, &link) .await?; - let username = user.username; - if !to_remove.is_empty() { - state - .conv_stg() - .add_conversation( - &link, - &username, - Some(format!("{username} unassigned {to_remove:?}")), - ConvTypeEnum::Assignee, - ) - .await?; - } - if !to_add.is_empty() { - state - .conv_stg() - .add_conversation( - &link, - &username, - Some(format!("{username} assigned {to_add:?}")), - ConvTypeEnum::Assignee, - ) - .await?; - } Ok(Json(CommonResult::success(None))) } diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index 93c85c69c..91a552f76 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::Result; use axum::{ Json, body::Body, @@ -6,8 +6,7 @@ use axum::{ response::{IntoResponse, Response}, routing::get, }; -use ceres::{api_service::ApiHandler, model::git::TreeQuery}; -use common::errors::MegaError; +use ceres::{application::api_service::ApiHandler, model::git::TreeQuery}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -73,19 +72,13 @@ pub async fn get_blob_file( ) -> Result { let api_handler = state.monorepo(); - let result = api_handler.get_raw_blob_by_hash(&oid).await; + let data = api_handler.get_raw_blob_by_hash(&oid).await?; let file_name = format!("inline; filename=\"{oid}\""); - match result { - Ok(data) => Ok(Response::builder() - .header("Content-Type", "application/octet-stream") - .header("Content-Disposition", file_name) - .body(Body::from(data)) - .unwrap()), - Err(e) => match e { - MegaError::ObjStorageNotFound(_) => Err(ApiError::not_found(anyhow!("error={}", e))), - _ => Err(ApiError::internal(anyhow!("error={}", e))), - }, - } + Ok(Response::builder() + .header("Content-Type", "application/octet-stream") + .header("Content-Disposition", file_name) + .body(Body::from(data)) + .unwrap()) } // Tree Objects Download diff --git a/mono/src/api/error.rs b/mono/src/api/error.rs index 486fa0d52..40d79a1ee 100644 --- a/mono/src/api/error.rs +++ b/mono/src/api/error.rs @@ -1,37 +1,12 @@ use api_model::common::CommonResult; use axum::response::{IntoResponse, Json, Response}; -use common::errors::{BuckError, MegaError}; +use common::errors::{ + MegaError, mega_error_http_status, mega_error_is_client_safe, parse_legacy_http_code, +}; use http::StatusCode; -/// Parse [code:xxx] format from error message. -/// Returns (status_code, clean_message) if found, None otherwise. -fn parse_error_code(err_str: &str) -> Option<(&str, &str)> { - // Find [code:xxx] anywhere in the string - let start = err_str.find("[code:")?; - let code_start = start + 6; // Skip "[code:" - - // Find the closing bracket after [code: - let remaining = &err_str[start..]; - let code_end_relative = remaining.find(']')?; - - // Ensure we have at least one character for the code - if code_end_relative <= 6 { - return None; - } - - let code_end = start + code_end_relative; - let code = &err_str[code_start..code_end]; - - // Validate that code is not empty and contains only valid characters - if code.is_empty() || !code.chars().all(|c| c.is_ascii_digit()) { - return None; - } - - // Extract message after the closing bracket; allow empty messages for compatibility - let msg_start = code_end + 1; - let msg = err_str.get(msg_start..).unwrap_or("").trim_start(); - - Some((code, msg)) +fn status_code_from_u16(code: u16) -> StatusCode { + StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) } #[derive(Debug)] @@ -41,7 +16,6 @@ pub struct ApiError { } impl ApiError { - // Preserve an API that can be constructed from an anyhow::Error (maps to 500 by default) pub fn new(err: impl Into) -> Self { Self { inner: err.into(), @@ -49,7 +23,6 @@ impl ApiError { } } - // Create an ApiError with explicit status code pub fn with_status(status: StatusCode, err: impl Into) -> Self { Self { inner: err.into(), @@ -68,23 +41,29 @@ impl ApiError { pub fn internal(err: impl Into) -> Self { Self::with_status(StatusCode::INTERNAL_SERVER_ERROR, err) } + + #[cfg(test)] + fn status_code(&self) -> StatusCode { + self.status + } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let err_str = self.inner.to_string(); - // Remove [code:xxx] prefix from error message for cleaner display - let err_msg = if let Some((_, msg)) = parse_error_code(&err_str) { + let err_msg = if let Some((_, msg)) = parse_legacy_http_code(&err_str) { msg.to_string() } else { err_str }; - tracing::error!("Application error: {}", err_msg); + if self.status.is_client_error() { + tracing::warn!(status = %self.status, "Client error: {}", err_msg); + } else { + tracing::error!(status = %self.status, "Application error: {}", err_msg); + } - // Only expose detailed error messages for 4xx (client) errors - // For 5xx (server) errors, use generic message to avoid leaking internal details let response_msg = if self.status.is_client_error() { err_msg } else { @@ -100,10 +79,6 @@ impl IntoResponse for ApiError { } } -// Generic From implementation: converts any error to ApiError -// 1. Typed MegaError matching (for type-safe error handling) -// 2. Fallback: parse [code:xxx] format (for backwards compatibility) -// 3. Default: internal server error impl From for ApiError where E: Into, @@ -111,86 +86,59 @@ where fn from(err: E) -> Self { let anyhow_err = err.into(); - // Try typed matching first: check if error is MegaError - // Use downcast_ref (borrowing) instead of downcast (ownership) to preserve error context if let Some(mega_err) = anyhow_err.downcast_ref::() { - // Handle Buck business errors with specific HTTP status codes - if let MegaError::Buck(buck_err) = mega_err { - let status = match buck_err { - BuckError::SessionNotFound(_) | BuckError::FileNotInManifest(_) => { - StatusCode::NOT_FOUND - } - BuckError::SessionExpired => StatusCode::GONE, - BuckError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS, - BuckError::FileSizeExceedsLimit(_, _) => StatusCode::PAYLOAD_TOO_LARGE, - BuckError::FileAlreadyUploaded(_) => StatusCode::CONFLICT, - BuckError::Forbidden(_) => StatusCode::FORBIDDEN, - BuckError::HashMismatch { .. } - | BuckError::ValidationError(_) - | BuckError::InvalidSessionStatus { .. } - | BuckError::FilesNotFullyUploaded { .. } => StatusCode::BAD_REQUEST, - }; - // Use original anyhow_err to preserve stack trace - return ApiError::with_status(status, anyhow_err); - } - - // Handle other MegaError variants - match mega_err { - MegaError::NotFound(_) => return ApiError::not_found(anyhow_err), - MegaError::Db(_) | MegaError::Redis(_) | MegaError::Io(_) => { - // Hide internal details in production, return generic 500 - tracing::error!( - error_type = %match mega_err { - MegaError::Db(_) => "Db", - MegaError::Redis(_) => "Redis", - MegaError::Io(_) => "Io", - _ => "Other", - }, - "Internal error occurred" - ); - tracing::debug!("Internal error: {:?}", mega_err); - return ApiError::internal(anyhow::anyhow!("Internal server error")); - } - // For other MegaError variants, fall through to parse [code:xxx] format - _ => {} + let status = status_code_from_u16(mega_error_http_status(mega_err)); + if !mega_error_is_client_safe(mega_err) { + tracing::error!(error_type = ?mega_err, "Internal error occurred"); + tracing::debug!("Internal error: {:?}", mega_err); + return ApiError::internal(anyhow::anyhow!("Internal server error")); } + return ApiError::with_status(status, anyhow_err); } - // Fallback: parse [code:xxx] format to set proper HTTP status code - // This handles legacy error format and non-MegaError types let err_str = anyhow_err.to_string(); - if let Some((code, _)) = parse_error_code(&err_str) { - return match code { - "400" => ApiError::bad_request(anyhow_err), - "401" => ApiError::with_status(StatusCode::UNAUTHORIZED, anyhow_err), - "403" => ApiError::with_status(StatusCode::FORBIDDEN, anyhow_err), - "404" => ApiError::not_found(anyhow_err), - "409" => ApiError::with_status(StatusCode::CONFLICT, anyhow_err), - "413" => ApiError::with_status(StatusCode::PAYLOAD_TOO_LARGE, anyhow_err), - "416" => ApiError::with_status(StatusCode::RANGE_NOT_SATISFIABLE, anyhow_err), - "500" => ApiError::internal(anyhow_err), - _ => ApiError::internal(anyhow_err), - }; + if let Some((code, _)) = parse_legacy_http_code(&err_str) { + let status = status_code_from_u16(code); + if status.is_server_error() { + return ApiError::internal(anyhow_err); + } + return ApiError::with_status(status, anyhow_err); } - // Default: map to internal server error ApiError::internal(anyhow_err) } } -// Map ceres-style coded errors like "[code:404] message" into ApiError with proper status. -pub(crate) fn map_ceres_error(err: D, ctx: &str) -> ApiError { - let s = err.to_string(); - - // Reuse the shared parse_error_code helper - if let Some((code, msg)) = parse_error_code(&s) { - let error_msg = anyhow::anyhow!(msg.to_string()); - return match code { - "400" => ApiError::bad_request(error_msg), - "404" => ApiError::not_found(error_msg), - _ => ApiError::internal(error_msg), - }; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mega_not_found_maps_to_404() { + let api_err = ApiError::from(MegaError::NotFound("CL not found".into())); + assert_eq!(api_err.status_code(), StatusCode::NOT_FOUND); } - ApiError::internal(anyhow::anyhow!(format!("{}: {}", ctx, s))) + #[test] + fn mega_unavailable_maps_to_503() { + let api_err = ApiError::from(MegaError::Unavailable("Build system is not enabled".into())); + assert_eq!(api_err.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn legacy_other_code_maps_to_503() { + let api_err = ApiError::from(MegaError::Other( + "[code:503] Build system is not enabled".into(), + )); + assert_eq!(api_err.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn git_wrapped_not_found_maps_to_404() { + use git_internal::errors::GitError; + let api_err = ApiError::from(MegaError::Git(GitError::CustomError( + "File not found".into(), + ))); + assert_eq!(api_err.status_code(), StatusCode::NOT_FOUND); + } } diff --git a/mono/src/api/guard/cedar_guard.rs b/mono/src/api/guard/cedar_guard.rs index cb9a9d5b1..efd1a5570 100644 --- a/mono/src/api/guard/cedar_guard.rs +++ b/mono/src/api/guard/cedar_guard.rs @@ -7,14 +7,13 @@ use axum::{ }; use cedar_policy::{Context, EntityId, EntityTypeName, EntityUid}; use common::errors::MegaError; -use http::StatusCode; use once_cell::sync::Lazy; use saturn::{ActionEnum, context::CedarContext, entitystore::EntityStore, util::SaturnEUid}; use crate::api::{ MonoApiServiceState, error::ApiError, - oauth::{BotIdentity, model::LoginUser}, + oauth::{BotAuth, model::LoginUser}, }; // TODO: All users are temporary allowed during development stage @@ -117,10 +116,7 @@ pub async fn cedar_guard( let (action, link) = resolve_cl_action(&request_path).map_err(|e| { tracing::error!("Failed to resolve CL action: {}", e); - ApiError::with_status( - StatusCode::INTERNAL_SERVER_ERROR, - MegaError::Other("Failed to resolve CL action".to_string()), - ) + ApiError::from(e) })?; tracing::debug!("Resolved action: {:?}, link: {}", action, link); @@ -141,8 +137,8 @@ pub async fn cedar_guard( let (mut parts, body) = req.into_parts(); let (principal_type, principal_id) = - if let Ok(bot) = BotIdentity::from_request_parts(&mut parts, &state).await { - ("Bot".to_string(), bot.bot.id.to_string()) + if let Ok(bot) = BotAuth::from_request_parts(&mut parts, &state).await { + ("Bot".to_string(), bot.bot_id.to_string()) } else if let Ok(user) = LoginUser::from_request_parts(&mut parts, &state).await { ("User".to_string(), user.username.clone()) } else { @@ -164,17 +160,16 @@ pub async fn cedar_guard( &principal_id, &action.to_string() ); - ApiError::with_status( - StatusCode::UNAUTHORIZED, - MegaError::Other(format!("Guard Authorization failed: {}", e)), - ) + ApiError::from(MegaError::forbidden(format!( + "Guard Authorization failed: {e}" + ))) })?; let req = Request::from_parts(parts, body); let response = next.run(req).await; if response.status().is_client_error() { - tracing::error!( + tracing::warn!( status = %response.status(), path = %request_path, "Downstream returned a 4xx error" @@ -211,7 +206,7 @@ async fn authorize( cedar_context .is_authorized(&principal, &action, &resource, context) - .map_err(|e| MegaError::Other(format!("Authorization failed: {}", e)))?; + .map_err(|e| MegaError::forbidden(format!("Authorization failed: {e}")))?; Ok(()) } diff --git a/mono/src/api/mod.rs b/mono/src/api/mod.rs index b51da946b..ae49543bc 100644 --- a/mono/src/api/mod.rs +++ b/mono/src/api/mod.rs @@ -5,21 +5,20 @@ use std::{ use axum::extract::FromRef; use ceres::{ - api_service::{ - ApiHandler, cache::GitObjectCache, import_api_service::ImportApiService, - mono::MonoApiService, state::ProtocolApiState, + application::{ + api_service::{ + ApiHandler, + cache::GitObjectCache, + import_api_service::ImportApiService, + mono::{MonoApiService, MonoAppServices}, + }, + artifact::ArtifactApplicationService, + build_trigger::service::BuildTriggerService, }, - application::artifact::ArtifactApplicationService, - build_trigger::service::BuildTriggerService, - protocol::repo::Repo, -}; -use common::errors::ProtocolError; -use jupiter::storage::{ - NotificationStorage, Storage, cl_storage::ClStorage, conversation_storage::ConversationStorage, - dynamic_sidebar_storage::DynamicSidebarStorage, gpg_storage::GpgStorage, - issue_storage::IssueStorage, note_storage::NoteStorage, user_storage::UserStorage, - webhook_storage::WebhookStorage, + transport::{ProtocolApiState, protocol::repo::Repo}, }; +use common::errors::MegaError; +use jupiter::storage::{Storage, user_storage::UserStorage}; use orion_client::OrionBuildClient; use saturn::entitystore::EntityStore; use tower_sessions::MemoryStore; @@ -36,92 +35,53 @@ pub mod router; #[derive(Clone)] pub struct MonoApiServiceState { - pub storage: Storage, - pub git_object_cache: Arc, - pub session_store: Option, - pub listen_addr: String, - pub entity_store: EntityStore, - pub orion_client: Arc, -} - -impl FromRef for MemoryStore { - fn from_ref(_: &MonoApiServiceState) -> Self { - MemoryStore::default() - } -} - -impl FromRef for OAuthApiStore { - fn from_ref(state: &MonoApiServiceState) -> Self { - state.session_store.clone().unwrap() - } -} - -impl FromRef for UserStorage { - fn from_ref(state: &MonoApiServiceState) -> Self { - state.storage.user_storage() - } -} - -impl FromRef for EntityStore { - fn from_ref(state: &MonoApiServiceState) -> Self { - state.entity_store.clone() - } -} - -impl From<&MonoApiServiceState> for MonoApiService { - fn from(state: &MonoApiServiceState) -> Self { - MonoApiService { - storage: state.storage.clone(), - git_object_cache: state.git_object_cache.clone(), - } - } -} - -impl FromRef for ProtocolApiState { - fn from_ref(state: &MonoApiServiceState) -> ProtocolApiState { - ProtocolApiState::new(state.storage.clone(), state.git_object_cache.clone()) - } + services: MonoAppServices, + storage: Storage, + git_object_cache: Arc, + session_store: Option, + listen_addr: String, + entity_store: EntityStore, + orion_client: Arc, } impl MonoApiServiceState { - fn monorepo(&self) -> MonoApiService { - self.into() - } - - fn issue_stg(&self) -> IssueStorage { - self.storage.issue_storage() - } - - fn gpg_stg(&self) -> GpgStorage { - self.storage.gpg_storage() - } - - fn cl_stg(&self) -> ClStorage { - self.storage.cl_storage() - } - - fn user_stg(&self) -> UserStorage { - self.storage.user_storage() + pub fn new( + storage: Storage, + git_object_cache: Arc, + session_store: Option, + listen_addr: String, + entity_store: EntityStore, + orion_client: Arc, + ) -> Self { + Self { + services: MonoAppServices::new(storage.clone(), git_object_cache.clone()), + storage, + git_object_cache, + session_store, + listen_addr, + entity_store, + orion_client, + } } - fn notification_stg(&self) -> NotificationStorage { - self.storage.notification_storage() + pub fn listen_addr(&self) -> &str { + &self.listen_addr } - fn conv_stg(&self) -> ConversationStorage { - self.storage.conversation_storage() + pub(crate) fn lfs_db_storage(&self) -> jupiter::storage::lfs_db_storage::LfsDbStorage { + self.storage.lfs_db_storage() } - fn note_stg(&self) -> NoteStorage { - self.storage.note_storage() + pub(crate) fn lfs_service(&self) -> jupiter::service::lfs_service::LfsService { + self.storage.lfs_service.clone() } - fn webhook_stg(&self) -> WebhookStorage { - self.storage.webhook_storage() + pub fn monorepo(&self) -> MonoApiService { + self.services.monorepo().clone() } - fn dynamic_sidebar_stg(&self) -> DynamicSidebarStorage { - self.storage.dynamic_sidebar_storage() + pub fn services(&self) -> &MonoAppServices { + &self.services } pub fn artifact_app_service(&self) -> ArtifactApplicationService { @@ -136,23 +96,21 @@ impl MonoApiServiceState { ) } - async fn api_handler(&self, path: &Path) -> Result, ProtocolError> { - // Normalize path to ensure it has a root component + pub(crate) async fn api_handler(&self, path: &Path) -> Result, MegaError> { let path = if path.has_root() { path.to_path_buf() } else { PathBuf::from("/").join(path) }; - let import_dir = self.storage.config().monorepo.import_dir.clone(); + let path_str = path + .to_str() + .ok_or_else(|| MegaError::bad_request("Invalid repository path"))?; + + let import_dir = self.monorepo().import_dir(); if path.starts_with(&import_dir) && path != import_dir - && let Some(model) = self - .storage - .git_db_storage() - .find_git_repo_like_path(path.to_str().unwrap()) - .await - .unwrap() + && let Some(model) = self.monorepo().find_git_repo_like_path(path_str).await? { let repo: Repo = model.into(); return Ok(Box::new(ImportApiService { @@ -161,11 +119,43 @@ impl MonoApiServiceState { git_object_cache: self.git_object_cache.clone(), })); } - let ret: Box = Box::::new(self.into()); - // Rust-analyzer cannot infer the type of `ret` correctly and always reports an error. - // Use `.into()` to workaround this issue. - #[allow(clippy::useless_conversion)] - Ok(ret.into()) + Ok(Box::new(self.monorepo()) as Box) + } +} + +impl FromRef for MemoryStore { + fn from_ref(_: &MonoApiServiceState) -> Self { + MemoryStore::default() + } +} + +impl FromRef for OAuthApiStore { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.session_store.clone().unwrap() + } +} + +impl FromRef for UserStorage { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.storage.user_storage() + } +} + +impl FromRef for EntityStore { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.entity_store.clone() + } +} + +impl From<&MonoApiServiceState> for MonoApiService { + fn from(state: &MonoApiServiceState) -> Self { + state.monorepo() + } +} + +impl FromRef for ProtocolApiState { + fn from_ref(state: &MonoApiServiceState) -> ProtocolApiState { + ProtocolApiState::new(state.storage.clone(), state.git_object_cache.clone()) } } diff --git a/mono/src/api/notes/mod.rs b/mono/src/api/notes/mod.rs index 142b36d97..e29c2209f 100644 --- a/mono/src/api/notes/mod.rs +++ b/mono/src/api/notes/mod.rs @@ -1,2 +1 @@ -pub mod model; pub mod note_router; diff --git a/mono/src/api/notes/note_router.rs b/mono/src/api/notes/note_router.rs index ff0d918e2..cdb3237d6 100644 --- a/mono/src/api/notes/note_router.rs +++ b/mono/src/api/notes/note_router.rs @@ -2,15 +2,11 @@ use axum::{ Json, extract::{Path, State}, }; +use ceres::model::note::NoteUpdateRequest; use serde_json::Value; use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::api::{ - MonoApiServiceState, - api_doc::SYNC_NOTES_STATE_TAG, - error::ApiError, - notes::model::{ShowResponse, UpdateRequest}, -}; +use crate::api::{MonoApiServiceState, api_doc::SYNC_NOTES_STATE_TAG, error::ApiError}; pub fn routers() -> OpenApiRouter { OpenApiRouter::new().nest( @@ -25,41 +21,23 @@ pub fn routers() -> OpenApiRouter { get, path = "/{org_slug}/notes/{id}/sync_state", responses( - (status = 200, body = ShowResponse, content_type = "application/json") + (status = 200, body = ceres::model::note::NoteShowResponse, content_type = "application/json") ), tag = SYNC_NOTES_STATE_TAG, )] async fn show_note( state: State, Path(id): Path, -) -> Result, ApiError> { - let note = state.note_stg().get_note_by_id(id.into()).await?; - if note.is_none() { - return Err(ApiError::from(anyhow::anyhow!("Note not found"))); - } - let note = note.unwrap(); - +) -> Result, ApiError> { // TODO: authorize(note, :show?) - - let response = ShowResponse { - public_id: note.public_id, - description_schema_version: note.description_schema_version, - description_state: match ¬e.description_state { - Some(state) if !state.is_empty() => Some(state.clone()), - _ => None, - }, - description_html: match ¬e.description_html { - Some(html) if !html.is_empty() => html.clone(), - _ => String::new(), - }, - }; + let response = state.monorepo().get_note_sync_state(id).await?; Ok(Json(response)) } #[utoipa::path( patch, path = "/{org_slug}/notes/{id}/sync_state", - request_body = UpdateRequest, + request_body = NoteUpdateRequest, responses( (status = 200, body = Value, content_type = "application/json") ), @@ -68,33 +46,12 @@ async fn show_note( async fn update_note( state: State, Path(id): Path, - Json(json): Json, + Json(json): Json, ) -> Result, ApiError> { - // Get the note first - let note = state.note_stg().get_note_by_id(id.into()).await?; - if note.is_none() { - return Err(ApiError::from(anyhow::anyhow!(format!( - "Note with ID {} not found", - id - )))); - } - let note = note.unwrap(); - // TODO: authorize note access (like in show_note) - - // Check schema version compatibility - if json.description_schema_version < note.description_schema_version { - return Err(ApiError::from(anyhow::anyhow!( - "Invalid schema version: provided ({}) is older than current ({})", - json.description_schema_version, - note.description_schema_version - ))); - } - - // Update the note - let _res_id = state - .note_stg() - .update_note( + state + .monorepo() + .update_note_sync_state( id, json.description_html.as_str(), json.description_state.as_str(), diff --git a/mono/src/api/oauth/mod.rs b/mono/src/api/oauth/mod.rs index c07cd0ead..ede071b6e 100644 --- a/mono/src/api/oauth/mod.rs +++ b/mono/src/api/oauth/mod.rs @@ -5,6 +5,8 @@ //! validation as [`AccessTokenUser`], call [`bearer_token_from_authorization_value`] and //! [`login_user_from_mono_access_token`] from that code path instead of the extractor. +use std::ops::Deref; + use axum::{ RequestPartsExt, extract::{FromRef, FromRequestParts}, @@ -15,7 +17,7 @@ use axum_extra::{ TypedHeader, headers::{self, Authorization, authorization::Bearer}, }; -use callisto::{bot_tokens, bots}; +pub use ceres::model::bots::BotIdentity; use common::errors::MegaError; use http::request::Parts; use jupiter::storage::user_storage::UserStorage; @@ -35,12 +37,6 @@ impl IntoResponse for AuthRedirect { (StatusCode::UNAUTHORIZED, "Login first").into_response() } } - -pub struct BotIdentity { - pub bot: bots::Model, - pub token: bot_tokens::Model, -} - pub struct AccessTokenUser(pub LoginUser); /// Authenticated user resolved from a **browser session cookie** (Campsite or Tinyship), @@ -74,7 +70,18 @@ pub async fn login_user_from_mono_access_token( })) } -impl FromRequestParts for BotIdentity +/// Axum extractor for bot bearer tokens (`bot_` prefix). +pub struct BotAuth(pub BotIdentity); + +impl Deref for BotAuth { + type Target = BotIdentity; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromRequestParts for BotAuth where MonoApiServiceState: FromRef, S: Send + Sync, @@ -104,12 +111,9 @@ where // Delegate token validation to Jupiter storage (BotsStorage) let state_ref = MonoApiServiceState::from_ref(state); - let bots_storage = state_ref.storage.bots_storage(); - // BotsStorage::find_bot_by_token is tolerant to presence/absence of the prefix, - // but we pass the original token string here for clarity. - match bots_storage.find_bot_by_token(raw_token).await { - Ok(Some((bot, token))) => Ok(BotIdentity { bot, token }), + match state_ref.monorepo().find_bot_by_token(raw_token).await { + Ok(Some((bot, token))) => Ok(BotAuth(BotIdentity::from_models(bot, token))), Ok(None) => { tracing::warn!("BotIdentity: bot token not found, revoked, or expired"); Err(AuthRedirect) diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index 325fa0bf8..b988fd82d 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -10,8 +10,7 @@ use api_model::common::CommonResult; use axum::{Json, extract::State}; -use serde::Serialize; -use utoipa::ToSchema; +use ceres::model::admin::{AdminListResponse, IsAdminResponse}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -19,16 +18,6 @@ use crate::api::{ error::ApiError, oauth::model::LoginUser, }; -#[derive(Serialize, ToSchema)] -pub struct IsAdminResponse { - pub is_admin: bool, -} - -#[derive(Serialize, ToSchema)] -pub struct AdminListResponse { - pub admins: Vec, -} - /// Build the admin router. pub fn routers() -> OpenApiRouter { OpenApiRouter::new().nest( diff --git a/mono/src/api/router/artifacts_router.rs b/mono/src/api/router/artifacts_router.rs index 65757f539..a648ef624 100644 --- a/mono/src/api/router/artifacts_router.rs +++ b/mono/src/api/router/artifacts_router.rs @@ -15,11 +15,11 @@ use axum::{ http::{HeaderMap, Request, StatusCode}, response::{IntoResponse, Response}, }; +use ceres::application::artifact::ArtifactApplicationService; use chrono::Utc; -use common::errors::MegaError; +use common::errors::mega_error_http_status; use futures::{StreamExt, TryStreamExt}; use http::header; -use jupiter::service::artifact_service::ArtifactService; use percent_encoding::percent_decode_str; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -49,10 +49,6 @@ fn decode_path_segment(segment: &str) -> String { percent_decode_str(segment).decode_utf8_lossy().into_owned() } -fn mega_to_api(err: MegaError) -> ApiError { - ApiError::from(anyhow::Error::from(err)) -} - /// Discover artifact protocol capabilities for a repo (see `docs/artifacts-protocol.md`). #[utoipa::path( get, @@ -100,11 +96,10 @@ pub async fn list_artifact_sets( ) -> Result, ApiError> { let repo = decode_path_segment(&repo); let body = state - .storage - .artifact_service + .artifact_app_service() .list_artifact_sets(&repo, &q) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(Json(body)) } @@ -133,11 +128,10 @@ pub async fn get_artifact_set( let repo = decode_path_segment(&repo); let artifact_set_id = decode_path_segment(&artifact_set_id); let body = state - .storage - .artifact_service + .artifact_app_service() .get_artifact_set_detail(&repo, &artifact_set_id, &q) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(Json(body)) } @@ -167,11 +161,10 @@ pub async fn resolve_artifact_file( ) -> Result, ApiError> { let repo = decode_path_segment(&repo); let body = state - .storage - .artifact_service + .artifact_app_service() .resolve_artifact_file(&repo, &q) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(Json(body)) } @@ -210,9 +203,9 @@ pub async fn download_object( let model = svc .artifact_object_model_for_committed_repo_download(&repo, &oid) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; - let etag = ArtifactService::weak_etag_for_oid_size(&model.oid, model.size_bytes); + let etag = ArtifactApplicationService::weak_etag_for_oid_size(&model.oid, model.size_bytes); let range_hdr = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); if range_hdr.is_none() @@ -236,7 +229,7 @@ pub async fn download_object( let signed_get = svc .artifact_object_signed_get_url(&oid, presign_ttl) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; if let Some(url) = signed_get { if q.mode.as_deref() == Some("link") { @@ -262,18 +255,18 @@ pub async fn download_object( } let len = model.size_bytes.max(0) as u64; - let range_parsed = match ArtifactService::parse_artifact_object_range(range_hdr, len) { + let range_parsed = match ArtifactApplicationService::parse_artifact_object_range(range_hdr, len) + { Ok(v) => v, Err(e) => { - let msg = e.to_string(); - if msg.contains("[code:416]") { + if mega_error_http_status(&e) == 416 { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{len}")) .body(Body::empty()) .map_err(ApiError::internal); } - return Err(mega_to_api(e)); + return Err(ApiError::from(e)); } }; @@ -287,7 +280,7 @@ pub async fn download_object( let stream = svc .get_artifact_object_byte_stream(&oid) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; let mapped = stream.map(|r| r.map_err(std::io::Error::other)); Response::builder() .status(StatusCode::OK) @@ -301,7 +294,7 @@ pub async fn download_object( let stream = svc .get_artifact_object_range_byte_stream(&oid, start, end_exclusive) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; let mapped = stream.map(|r| r.map_err(std::io::Error::other)); let range_len = end_exclusive.saturating_sub(start); let last = end_exclusive.saturating_sub(1); @@ -343,8 +336,8 @@ pub async fn head_artifact_object( let model = svc .artifact_object_model_for_committed_repo_download(&repo, &oid) .await - .map_err(mega_to_api)?; - let etag = ArtifactService::weak_etag_for_oid_size(&model.oid, model.size_bytes); + .map_err(ApiError::from)?; + let etag = ArtifactApplicationService::weak_etag_for_oid_size(&model.oid, model.size_bytes); let content_type = model .content_type .as_deref() @@ -379,11 +372,10 @@ pub async fn batch( Json(req): Json, ) -> Result, ApiError> { let body = state - .storage - .artifact_service + .artifact_app_service() .batch_artifacts(&req) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(Json(body)) } @@ -410,11 +402,10 @@ pub async fn commit( ) -> Result, ApiError> { let repo = decode_path_segment(&repo); let body = state - .storage - .artifact_service + .artifact_app_service() .commit_artifacts(&repo, &req) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(Json(body)) } @@ -467,10 +458,9 @@ pub async fn upload_object_fallback( } state - .storage - .artifact_service + .artifact_app_service() .upload_artifact_object_bytes(&oid, body_bytes) .await - .map_err(mega_to_api)?; + .map_err(ApiError::from)?; Ok(StatusCode::NO_CONTENT) } diff --git a/mono/src/api/router/bot_router.rs b/mono/src/api/router/bot_router.rs index e296ffbc5..71b56a2cb 100644 --- a/mono/src/api/router/bot_router.rs +++ b/mono/src/api/router/bot_router.rs @@ -4,11 +4,12 @@ use axum::{ Json, extract::{Path, State}, }; -use ceres::model::bots::{BotRes, ChangeInstallationStatus, InstallBotReq, InstallationTargetType}; -use chrono::{DateTime, Duration, Utc}; +use ceres::model::bots::{ + BotRes, ChangeInstallationStatus, CreateBotTokenRequest, CreateBotTokenResponse, InstallBotReq, + InstallationTargetType, ListBotTokenItem, +}; +use chrono::{Duration, Utc}; use jupiter::sea_orm::prelude::DateTimeWithTimeZone; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -22,48 +23,13 @@ const MAX_EXPIRES_IN_SECS: i64 = 365 * 24 * 3600 * 10; const MIN_EXPIRES_IN_SECS: i64 = 1; async fn ensure_bot_exists(state: &MonoApiServiceState, bot_id: i64) -> Result<(), ApiError> { - let bot = state - .storage - .bots_storage() - .get_bot_by_id(bot_id) - .await - .map_err(ApiError::from)?; + let bot = state.monorepo().get_bot_by_id(bot_id).await?; if bot.is_none() { return Err(ApiError::not_found(anyhow!("Bot not found"))); } Ok(()) } -/// Request body for creating a new bot token. -#[derive(Deserialize, ToSchema)] -pub struct CreateBotTokenRequest { - /// Human-readable token name for identification. - pub token_name: String, - /// Optional relative expiry in seconds from now. - pub expires_in: Option, -} - -/// Response body when a bot token is created. -/// -/// Note: `token_plain` is only returned once and is never stored in plaintext. -#[derive(Serialize, ToSchema)] -pub struct CreateBotTokenResponse { - pub id: i64, - pub token_name: String, - pub expires_at: Option>, - pub token_plain: String, -} - -/// Item in the list bot tokens response. -#[derive(Serialize, ToSchema)] -pub struct ListBotTokenItem { - pub id: i64, - pub token_name: String, - pub expires_at: Option>, - pub revoked: bool, - pub created_at: DateTime, -} - pub fn routers() -> OpenApiRouter { OpenApiRouter::new().nest( "/bots", @@ -96,18 +62,9 @@ async fn install_bot( Path(id): Path, Json(json): Json, ) -> Result>, ApiError> { - let bot = state - .storage - .bots_storage() - .install_bot( - id, - json.target_type.into(), - json.target_id, - json.installed_by, - ) - .await?; + let bot = state.monorepo().install_bot(id, json).await?; - Ok(Json(CommonResult::success(Some(bot.into())))) + Ok(Json(CommonResult::success(Some(bot)))) } /// Get installed bot @@ -126,14 +83,7 @@ async fn list_installed_bot( state: State, Path(id): Path, ) -> Result>>, ApiError> { - let models = state - .storage - .bots_storage() - .get_installed_bot_by_id(id) - .await? - .into_iter() - .map(|m| m.into()) - .collect(); + let models = state.monorepo().list_installed_bots(id).await?; Ok(Json(CommonResult::success(Some(models)))) } @@ -156,17 +106,11 @@ async fn change_installation_status( Json(json): Json, ) -> Result>, ApiError> { let model = state - .storage - .bots_storage() - .change_installed_bot_status( - id, - json.target_type.into(), - installation_id, - json.status.into(), - ) + .monorepo() + .change_bot_installation_status(id, installation_id, json) .await?; - Ok(Json(CommonResult::success(Some(model.into())))) + Ok(Json(CommonResult::success(Some(model)))) } #[utoipa::path( @@ -187,9 +131,8 @@ async fn uninstall_bot( Json(target_type): Json, ) -> Result>, ApiError> { state - .storage - .bots_storage() - .uninstall_bot(id, target_type.into(), installation_id) + .monorepo() + .uninstall_bot(id, target_type, installation_id) .await?; Ok(Json(CommonResult::success(Some( @@ -240,19 +183,11 @@ async fn create_bot_token( } }; - let (model, token_plain) = state - .storage - .bots_storage() + let resp = state + .monorepo() .generate_bot_token(bot_id, &req.token_name, expires_at) .await?; - let resp = CreateBotTokenResponse { - id: model.id, - token_name: model.token_name, - expires_at: model.expires_at.map(|dt| dt.with_timezone(&Utc)), - token_plain, - }; - Ok(Json(CommonResult::success(Some(resp)))) } @@ -281,18 +216,7 @@ async fn list_bot_tokens( ensure_admin(&state, &user).await?; ensure_bot_exists(&state, bot_id).await?; - let tokens = state.storage.bots_storage().list_bot_tokens(bot_id).await?; - - let items = tokens - .into_iter() - .map(|t| ListBotTokenItem { - id: t.id, - token_name: t.token_name, - expires_at: t.expires_at.map(|dt| dt.with_timezone(&Utc)), - revoked: t.revoked, - created_at: t.created_at.with_timezone(&Utc), - }) - .collect(); + let items = state.monorepo().list_bot_tokens(bot_id).await?; Ok(Json(CommonResult::success(Some(items)))) } @@ -323,11 +247,7 @@ async fn revoke_bot_token( ensure_admin(&state, &user).await?; ensure_bot_exists(&state, bot_id).await?; - state - .storage - .bots_storage() - .revoke_bot_token(bot_id, token_id) - .await?; + state.monorepo().revoke_bot_token(bot_id, token_id).await?; Ok(Json(CommonResult::success(None))) } @@ -357,11 +277,7 @@ async fn revoke_all_bot_tokens( ensure_admin(&state, &user).await?; ensure_bot_exists(&state, bot_id).await?; - state - .storage - .bots_storage() - .revoke_bot_tokens_by_bot(bot_id) - .await?; + state.monorepo().revoke_all_bot_tokens(bot_id).await?; Ok(Json(CommonResult::success(None))) } diff --git a/mono/src/api/router/buck_router.rs b/mono/src/api/router/buck_router.rs index 9c0561d52..8f10a402c 100644 --- a/mono/src/api/router/buck_router.rs +++ b/mono/src/api/router/buck_router.rs @@ -48,19 +48,10 @@ async fn create_session( state: State, Json(payload): Json, ) -> Result>, ApiError> { - // Validate path - let path = payload.path.trim(); - if path.is_empty() { - return Err(ApiError::bad_request(anyhow::anyhow!( - "Path cannot be empty" - ))); - } - let service_resp = state .monorepo() - .create_buck_session(&user.username, path) - .await - .map_err(ApiError::from)?; + .create_buck_session(&user.username, &payload.path) + .await?; let response = SessionResponse { cl_link: service_resp.cl_link, diff --git a/mono/src/api/router/build_trigger_router.rs b/mono/src/api/router/build_trigger_router.rs index 5ce98230f..a960b67a2 100644 --- a/mono/src/api/router/build_trigger_router.rs +++ b/mono/src/api/router/build_trigger_router.rs @@ -8,7 +8,9 @@ use axum::{ Json, extract::{Path, State}, }; -use ceres::build_trigger::{CreateTriggerRequest, ListTriggersParams, TriggerResponse}; +use ceres::application::build_trigger::{ + CreateTriggerRequest, ListTriggersParams, TriggerResponse, +}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ diff --git a/mono/src/api/router/cl_router.rs b/mono/src/api/router/cl_router.rs index b5d1a8bf0..984797dcc 100644 --- a/mono/src/api/router/cl_router.rs +++ b/mono/src/api/router/cl_router.rs @@ -12,7 +12,6 @@ use ceres::model::{ issue::ItemRes, label::LabelUpdatePayload, }; -use common::errors::MegaError; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -150,14 +149,10 @@ async fn fetch_cl_list( state: State, Json(json): Json>, ) -> Result>>, ApiError> { - let (items, total) = state - .cl_stg() - .get_cl_list(json.additional.into(), json.pagination) + let res = state + .monorepo() + .get_cl_list(json.additional, json.pagination) .await?; - let res = CommonPage { - items: items.into_iter().map(|m| m.into()).collect(), - total, - }; Ok(Json(CommonResult::success(Some(res)))) } @@ -252,11 +247,7 @@ async fn cl_files_list( Path(link): Path, state: State, ) -> Result>>, ApiError> { - let cl = state - .cl_stg() - .get_cl(&link) - .await? - .ok_or(MegaError::Other("CL Not Found".to_string()))?; + let cl = state.monorepo().get_cl_model(&link).await?; let stg = state.monorepo(); let old_files = stg.get_commit_blobs(&cl.from_hash).await?; diff --git a/mono/src/api/router/code_review_router.rs b/mono/src/api/router/code_review_router.rs index c90d10518..2d514a008 100644 --- a/mono/src/api/router/code_review_router.rs +++ b/mono/src/api/router/code_review_router.rs @@ -7,7 +7,6 @@ use ceres::model::code_review::{ CodeReviewResponse, CommentReplyRequest, CommentReviewResponse, InitializeCommentRequest, ThreadReviewResponse, ThreadStatusResponse, UpdateCommentRequest, }; -use common::errors::MegaError; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -45,13 +44,9 @@ async fn code_review_comment_list( Path(link): Path, state: State, ) -> Result>, ApiError> { - let comments = state - .storage - .code_review_service - .get_all_comments_by_link(&link) - .await?; + let comments = state.monorepo().get_code_review_comments(&link).await?; - Ok(Json(CommonResult::success(Some(comments.into())))) + Ok(Json(CommonResult::success(Some(comments)))) } /// Initialize a code review comment in a new thread @@ -73,23 +68,11 @@ async fn initialize_code_review_comment( Json(paload): Json, ) -> Result>, ApiError> { let thread = state - .storage - .code_review_service - .create_inline_comment( - &link, - &paload.file_path, - paload.diff_side.into(), - &paload.anchor_commit_sha, - paload.original_line_number, - &paload.normalized_content, - &paload.context_before, - &paload.context_after, - user.username, - paload.content, - ) + .monorepo() + .create_code_review_comment(&link, user.username, paload) .await?; - Ok(Json(CommonResult::success(Some(thread.into())))) + Ok(Json(CommonResult::success(Some(thread)))) } /// Reply to a code review comment @@ -111,17 +94,11 @@ async fn reply_code_review_comment( Json(payload): Json, ) -> Result>, ApiError> { let comment = state - .storage - .code_review_service - .reply_to_comment( - thread_id, - payload.parent_comment_id, - user.username, - payload.content, - ) + .monorepo() + .reply_code_review_comment(thread_id, user.username, payload) .await?; - Ok(Json(CommonResult::success(Some(comment.into())))) + Ok(Json(CommonResult::success(Some(comment)))) } /// Update a code review comment @@ -142,27 +119,12 @@ async fn update_code_review_comment( state: State, Json(payload): Json, ) -> Result>, ApiError> { - // Validate ownership - let comment = state - .storage - .code_review_comment_storage() - .find_comment_by_id(comment_id) - .await? - .ok_or_else(|| ApiError::from(MegaError::Other("Comment not found".to_string())))?; - - if comment.user_name != user.username { - return Err(ApiError::from(MegaError::Other( - "Cannot update others' comments".to_string(), - ))); - } - let comment = state - .storage - .code_review_service - .update_comment(comment_id, payload.content) + .monorepo() + .update_code_review_comment(comment_id, &user.username, payload) .await?; - Ok(Json(CommonResult::success(Some(comment.into())))) + Ok(Json(CommonResult::success(Some(comment)))) } /// Resolve a code review thread @@ -182,12 +144,11 @@ async fn resolve_code_review_thread( state: State, ) -> Result>, ApiError> { let thread = state - .storage - .code_review_service - .resolve_thread(thread_id) + .monorepo() + .resolve_code_review_thread(thread_id) .await?; - Ok(Json(CommonResult::success(Some(thread.into())))) + Ok(Json(CommonResult::success(Some(thread)))) } /// Reopen a code review thread @@ -207,12 +168,11 @@ async fn reopen_code_review_thread( state: State, ) -> Result>, ApiError> { let thread = state - .storage - .code_review_service - .reopen_thread(thread_id) + .monorepo() + .reopen_code_review_thread(thread_id) .await?; - Ok(Json(CommonResult::success(Some(thread.into())))) + Ok(Json(CommonResult::success(Some(thread)))) } /// Delete a code review thread and its comments @@ -232,9 +192,8 @@ async fn delete_code_review_thread( state: State, ) -> Result>, ApiError> { state - .storage - .code_review_service - .delete_thread(thread_id) + .monorepo() + .delete_code_review_thread(thread_id) .await?; Ok(Json(CommonResult::success(None))) @@ -257,24 +216,9 @@ async fn delete_code_review_comment( Path(comment_id): Path, state: State, ) -> Result>, ApiError> { - // Validate ownership - let comment = state - .storage - .code_review_comment_storage() - .find_comment_by_id(comment_id) - .await? - .ok_or_else(|| ApiError::from(MegaError::Other("Comment not found".to_string())))?; - - if comment.user_name != user.username { - return Err(ApiError::from(MegaError::Other( - "Cannot update others' comments".to_string(), - ))); - } - state - .storage - .code_review_service - .delete_comment(comment_id) + .monorepo() + .delete_code_review_comment(comment_id, &user.username) .await?; Ok(Json(CommonResult::success(None))) diff --git a/mono/src/api/router/commit_router.rs b/mono/src/api/router/commit_router.rs index b76242bbb..df002f3e9 100644 --- a/mono/src/api/router/commit_router.rs +++ b/mono/src/api/router/commit_router.rs @@ -7,19 +7,15 @@ use axum::{ }; use ceres::model::{ change_list::MuiTreeNode, - commit::{CommitBindingResponse, CommitFilesChangedPage, CommitHistoryParams, CommitSummary}, + commit::{ + CommitBindingResponse, CommitFilesChangedPage, CommitHistoryParams, CommitSummary, + UpdateCommitBindingRequest, + }, }; -use serde::{Deserialize, Serialize}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{MonoApiServiceState, api_doc::CODE_PREVIEW, error::ApiError}; -#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] -pub struct UpdateCommitBindingRequest { - pub username: Option, - pub is_anonymous: bool, -} - pub fn routers() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(update_commit_binding)) @@ -49,31 +45,12 @@ async fn update_commit_binding( Path(sha): Path, Json(request): Json, ) -> Result>, ApiError> { - let commit_binding_storage = state.storage.commit_binding_storage(); - - // Derive final username from request (ignore username when explicitly anonymous) - let final_username = if request.is_anonymous { - None - } else { - request.username.as_ref().and_then(|u| { - let t = u.trim(); - if t.is_empty() || t.eq_ignore_ascii_case("anonymous") { - None - } else { - Some(t.to_string()) - } - }) - }; - - // Update binding with simplified schema (no author_email) - commit_binding_storage - .upsert_binding(&sha, final_username.clone(), final_username.is_none()) - .await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; + let response = state + .monorepo() + .upsert_commit_binding(&sha, request.username.clone(), request.is_anonymous) + .await?; - Ok(Json(CommonResult::success(Some(CommitBindingResponse { - username: final_username, - })))) + Ok(Json(CommonResult::success(Some(response)))) } /// List commit history with optional refs, path filter, author filter, and pagination. @@ -114,16 +91,12 @@ async fn list_commit_history( )) })?; - let repo_selector = if let Ok(Some(model)) = state - .storage - .git_db_storage() - .find_git_repo_like_path(path_str) - .await - { - PathBuf::from(model.repo_path) - } else { - abs_path.clone() - }; + let repo_selector = + if let Some(model) = state.monorepo().find_git_repo_like_path(path_str).await? { + PathBuf::from(model.repo_path) + } else { + abs_path.clone() + }; // Create handler using the repository selector (repo root), not the subdirectory. let handler = state.api_handler(&repo_selector).await?; diff --git a/mono/src/api/router/conv_router.rs b/mono/src/api/router/conv_router.rs index 85e6cdda7..72993fe5c 100644 --- a/mono/src/api/router/conv_router.rs +++ b/mono/src/api/router/conv_router.rs @@ -41,8 +41,8 @@ async fn comment_reactions( Json(payload): Json, ) -> Result>, ApiError> { state - .conv_stg() - .add_reactions( + .monorepo() + .add_comment_reaction( Some(payload.content), comment_id, &payload.comment_type, @@ -70,8 +70,8 @@ async fn delete_comment_reaction( state: State, ) -> Result>, ApiError> { state - .conv_stg() - .delete_reaction(&id, &user.username) + .monorepo() + .delete_comment_reaction(&id, &user.username) .await?; Ok(Json(CommonResult::success(None))) } @@ -92,7 +92,7 @@ async fn delete_comment( Path(comment_id): Path, state: State, ) -> Result>, ApiError> { - state.conv_stg().remove_conversation(comment_id).await?; + state.monorepo().remove_conversation(comment_id).await?; Ok(Json(CommonResult::success(None))) } @@ -116,7 +116,7 @@ async fn edit_comment( Json(payload): Json, ) -> Result>, ApiError> { state - .conv_stg() + .monorepo() .update_comment(comment_id, Some(payload.content)) .await?; Ok(Json(CommonResult::success(None))) diff --git a/mono/src/api/router/dynamic_sidebar_router.rs b/mono/src/api/router/dynamic_sidebar_router.rs index 39367e99d..c44e9eaea 100644 --- a/mono/src/api/router/dynamic_sidebar_router.rs +++ b/mono/src/api/router/dynamic_sidebar_router.rs @@ -34,13 +34,7 @@ pub fn routers() -> OpenApiRouter { async fn sidebar_menu_list( state: State, ) -> Result>, ApiError> { - let items: SidebarMenuListRes = state - .dynamic_sidebar_stg() - .get_sidebars() - .await? - .into_iter() - .map(|m| m.into()) - .collect(); + let items = state.monorepo().list_sidebars().await?; Ok(Json(CommonResult::success(Some(items)))) } @@ -59,7 +53,7 @@ async fn new_sidebar( Json(json): Json, ) -> Result>, ApiError> { let res = state - .dynamic_sidebar_stg() + .monorepo() .new_sidebar( json.public_id, json.label, @@ -68,7 +62,7 @@ async fn new_sidebar( json.order_index, ) .await?; - Ok(Json(CommonResult::success(Some(res.into())))) + Ok(Json(CommonResult::success(Some(res)))) } /// Update sidebar menu @@ -90,7 +84,7 @@ async fn update_sidebar_by_id( Json(json): Json, ) -> Result>, ApiError> { let res = state - .dynamic_sidebar_stg() + .monorepo() .update_sidebar( id, json.public_id, @@ -101,7 +95,7 @@ async fn update_sidebar_by_id( ) .await?; - Ok(Json(CommonResult::success(Some(res.into())))) + Ok(Json(CommonResult::success(Some(res)))) } /// Sync sidebar menus @@ -126,14 +120,9 @@ async fn sync_sidebar( state: State, Json(payloads): Json>, ) -> Result>>, ApiError> { - let res = state - .dynamic_sidebar_stg() - .sync_sidebar(payloads.into_iter().map(|item| item.into()).collect()) - .await?; + let res = state.monorepo().sync_sidebars(payloads).await?; - Ok(Json(CommonResult::success(Some( - res.into_iter().map(|item| item.into()).collect(), - )))) + Ok(Json(CommonResult::success(Some(res)))) } /// Delete sidebar menu @@ -152,6 +141,6 @@ async fn delete_sidebar_by_id( state: State, Path(id): Path, ) -> Result>, ApiError> { - let res = state.dynamic_sidebar_stg().delete_sidebar(id).await?; - Ok(Json(CommonResult::success(Some(res.into())))) + let res = state.monorepo().delete_sidebar(id).await?; + Ok(Json(CommonResult::success(Some(res)))) } diff --git a/mono/src/api/router/gpg_router.rs b/mono/src/api/router/gpg_router.rs index 1575056f4..2943204c3 100644 --- a/mono/src/api/router/gpg_router.rs +++ b/mono/src/api/router/gpg_router.rs @@ -1,6 +1,5 @@ use api_model::common::CommonResult; use axum::{Json, extract::State}; -use callisto::gpg_key::Model; use ceres::model::gpg::{GpgKey, NewGpgRequest, RemoveGpgRequest}; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -31,7 +30,7 @@ async fn remove_gpg( ) -> Result>, ApiError> { // let uid = "exampleid".to_string(); let uid = user.campsite_user_id.clone(); - state.gpg_stg().remove_gpg_key(uid, req.key_id).await?; + state.monorepo().remove_gpg_key(uid, req.key_id).await?; Ok(Json(CommonResult::success(None))) } @@ -52,7 +51,7 @@ async fn add_gpg( // let uid = "exampleid".to_string(); let uid = user.campsite_user_id.clone(); println!("Adding GPG key for user: {}", req.gpg_content.clone()); - state.gpg_stg().add_gpg_key(uid, req.gpg_content).await?; + state.monorepo().add_gpg_key(uid, req.gpg_content).await?; Ok(Json(CommonResult::success(None))) } @@ -70,19 +69,7 @@ async fn list_gpg( ) -> Result>>, ApiError> { // let uid = "exampleid".to_string(); let uid = user.campsite_user_id; - let raw_keys = state.gpg_stg().list_user_gpg(uid.clone()).await; - - let res: Vec = raw_keys - .into_iter() - .flatten() - .map(|k: Model| GpgKey { - user_id: uid.clone(), - key_id: k.key_id, - fingerprint: k.fingerprint, - created_at: k.created_at.and_utc(), - expires_at: k.expires_at.map(|dt| dt.and_utc()), - }) - .collect(); + let res = state.monorepo().list_user_gpg_keys(uid).await?; Ok(Json(CommonResult::success(Some(res)))) } diff --git a/mono/src/api/router/group_router.rs b/mono/src/api/router/group_router.rs index fea25d1ad..2dcdb1c7c 100644 --- a/mono/src/api/router/group_router.rs +++ b/mono/src/api/router/group_router.rs @@ -1,5 +1,5 @@ use anyhow::anyhow; -use api_model::common::{CommonPage, CommonResult, PageParams, Pagination}; +use api_model::common::{CommonPage, CommonResult, PageParams}; use axum::{ Json, extract::{Path, State}, @@ -10,9 +10,6 @@ use ceres::model::group::{ ResourcePermissionResponse, SetPermissionsRequest, UpdateGroupRequest, UserEffectivePermissionResponse, UserGroupsResponse, }; -use jupiter::model::group_dto::{ - CreateGroupPayload, ResourcePermissionBinding, UpdateGroupPayload, -}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -66,38 +63,7 @@ async fn create_group( ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; - let name = req.name.trim(); - if name.is_empty() { - tracing::warn!( - actor = %user.username, - "group.create rejected: empty group name" - ); - return Err(ApiError::bad_request(anyhow!( - "Group name must not be empty" - ))); - } - if name.len() > 255 { - tracing::warn!( - actor = %user.username, - "group.create rejected: name too long" - ); - return Err(ApiError::bad_request(anyhow!( - "Group name must not exceed 255 characters" - ))); - } - - let description = req - .description - .map(|item| item.trim().to_string()) - .and_then(|item| if item.is_empty() { None } else { Some(item) }); - - let group = state - .monorepo() - .create_group(CreateGroupPayload { - name: name.to_string(), - description, - }) - .await?; + let group = state.monorepo().create_group(req).await?; Ok(Json(CommonResult::success(Some(group.into())))) } @@ -120,7 +86,6 @@ async fn list_groups( Json(json): Json>, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - validate_pagination(&json.pagination)?; let (items, total) = state.monorepo().list_groups(json.pagination).await?; let items = items.into_iter().map(Into::into).collect(); @@ -193,40 +158,13 @@ async fn update_group( ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; - let name = req.name.trim(); - if name.is_empty() { - tracing::warn!( - actor = %user.username, - group_id, - "group.update rejected: empty group name" - ); - return Err(ApiError::bad_request(anyhow!( - "Group name must not be empty" - ))); - } - if name.len() > 255 { - tracing::warn!( - actor = %user.username, - group_id, - "group.update rejected: name too long" - ); - return Err(ApiError::bad_request(anyhow!( - "Group name must not exceed 255 characters" - ))); - } - - let description = req - .description - .map(|item| item.trim().to_string()) - .and_then(|item| if item.is_empty() { None } else { Some(item) }); - let updated = state .monorepo() .update_group( group_id, - UpdateGroupPayload { - name: name.to_string(), - description, + UpdateGroupRequest { + name: req.name, + description: req.description, }, ) .await?; @@ -288,16 +226,6 @@ async fn add_group_members( Json(req): Json, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - if req.usernames.is_empty() { - tracing::warn!( - actor = %user.username, - group_id, - "group.members.add rejected: empty usernames" - ); - return Err(ApiError::bad_request(anyhow!( - "usernames must not be empty" - ))); - } let members = state .monorepo() @@ -364,7 +292,6 @@ async fn list_group_members( Json(json): Json>, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - validate_pagination(&json.pagination)?; let (items, total) = state .monorepo() @@ -402,21 +329,12 @@ async fn set_resource_permissions( Json(req): Json, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - let (resource_type, _, resource_id) = + let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; - let permissions = req - .permissions - .into_iter() - .map(|item| ResourcePermissionBinding { - group_id: item.group_id, - permission: item.permission.into(), - }) - .collect(); - let saved = state .monorepo() - .set_resource_permission(resource_type, &resource_id, permissions) + .set_resource_permission(resource_type_value.into(), &resource_id, req.permissions) .await?; let saved = saved.into_iter().map(Into::into).collect(); @@ -445,12 +363,12 @@ async fn get_resource_permissions( Path((resource_type, resource_id)): Path<(String, String)>, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - let (resource_type, _, resource_id) = + let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; let permissions = state .monorepo() - .get_resource_permissions(resource_type, &resource_id) + .get_resource_permissions(resource_type_value.into(), &resource_id) .await?; let permissions = permissions.into_iter().map(Into::into).collect(); @@ -481,21 +399,12 @@ async fn update_resource_permissions( Json(req): Json, ) -> Result>>, ApiError> { ensure_admin(&state, &user).await?; - let (resource_type, _, resource_id) = + let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; - let permissions = req - .permissions - .into_iter() - .map(|item| ResourcePermissionBinding { - group_id: item.group_id, - permission: item.permission.into(), - }) - .collect(); - let updated = state .monorepo() - .update_resource_permissions(resource_type, &resource_id, permissions) + .update_resource_permissions(resource_type_value.into(), &resource_id, req.permissions) .await?; let updated = updated.into_iter().map(Into::into).collect(); @@ -524,12 +433,12 @@ async fn delete_resource_permissions( Path((resource_type, resource_id)): Path<(String, String)>, ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; - let (resource_type, resource_type_value, resource_id) = + let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; let deleted_count = state .monorepo() - .delete_resource_permissions(resource_type, &resource_id) + .delete_resource_permissions(resource_type_value.into(), &resource_id) .await?; Ok(Json(CommonResult::success(Some( @@ -593,12 +502,12 @@ async fn get_user_effective_permission( Path((username, resource_type, resource_id)): Path<(String, String, String)>, ) -> Result>, ApiError> { ensure_admin(&state, &user).await?; - let (resource_type, resource_type_value, resource_id) = + let (resource_type_value, resource_id) = resolve_resource_context(&state, resource_type.as_str(), &resource_id).await?; let effective = state .monorepo() - .get_user_effective_permission(&username, resource_type, &resource_id) + .get_user_effective_permission(&username, resource_type_value.into(), &resource_id) .await?; let response = build_user_effective_permission_response( username, @@ -609,19 +518,3 @@ async fn get_user_effective_permission( Ok(Json(CommonResult::success(Some(response)))) } - -fn validate_pagination(pagination: &Pagination) -> Result<(), ApiError> { - if pagination.page == 0 { - tracing::warn!("invalid pagination.page: {}", pagination.page); - return Err(ApiError::bad_request(anyhow!( - "pagination.page must be >= 1" - ))); - } - if pagination.per_page == 0 { - tracing::warn!("invalid pagination.per_page: {}", pagination.per_page); - return Err(ApiError::bad_request(anyhow!( - "pagination.per_page must be >= 1" - ))); - } - Ok(()) -} diff --git a/mono/src/api/router/issue_router.rs b/mono/src/api/router/issue_router.rs index b5e9e3429..0dd43c0e5 100644 --- a/mono/src/api/router/issue_router.rs +++ b/mono/src/api/router/issue_router.rs @@ -3,14 +3,12 @@ use axum::{ Json, extract::{Path, Query, State}, }; -use callisto::sea_orm_active_enums::ConvTypeEnum; use ceres::model::{ change_list::{AssigneeUpdatePayload, ListPayload}, - conversation::ContentPayload, + conversation::{ContentPayload, ConvType}, issue::{IssueDetailRes, IssueSuggestions, ItemRes, NewIssue, QueryPayload}, label::LabelUpdatePayload, }; -use jupiter::service::issue_service::IssueService; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ @@ -49,11 +47,11 @@ async fn fetch_issue_list( Json(json): Json>, ) -> Result>>, ApiError> { let (items, total) = state - .issue_stg() - .get_issue_list(json.additional.into(), json.pagination) + .monorepo() + .get_issue_list(json.additional, json.pagination) .await?; Ok(Json(CommonResult::success(Some(CommonPage { - items: items.into_iter().map(|m| m.into()).collect(), + items, total, })))) } @@ -75,11 +73,10 @@ async fn issue_detail( Path(link): Path, state: State, ) -> Result>, ApiError> { - let issue_service: IssueService = state.storage.issue_service.clone(); - let issue_details: IssueDetailRes = issue_service + let issue_details = state + .monorepo() .get_issue_details(&link, user.username) - .await? - .into(); + .await?; Ok(Json(CommonResult::success(Some(issue_details)))) } @@ -99,17 +96,16 @@ async fn new_issue( Json(json): Json, ) -> Result>, ApiError> { let res = state - .issue_stg() + .monorepo() .save_issue(&user.username, &json.title) - .await - .unwrap(); - let _ = state - .conv_stg() + .await?; + state + .monorepo() .add_conversation( &res.link, &user.username, Some(json.description), - ConvTypeEnum::Comment, + ConvType::Comment, ) .await?; Ok(Json(CommonResult::success(None))) @@ -132,14 +128,14 @@ async fn close_issue( Path(link): Path, state: State, ) -> Result>, ApiError> { - state.issue_stg().close_issue(&link).await?; + state.monorepo().close_issue(&link).await?; state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(format!("{} closed this", user.username)), - ConvTypeEnum::Closed, + ConvType::Closed, ) .await?; Ok(Json(CommonResult::success(None))) @@ -162,14 +158,14 @@ async fn reopen_issue( Path(link): Path, state: State, ) -> Result>, ApiError> { - state.issue_stg().reopen_issue(&link).await?; + state.monorepo().reopen_issue(&link).await?; state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(format!("{} reopen this", user.username)), - ConvTypeEnum::Closed, + ConvType::Closed, ) .await?; Ok(Json(CommonResult::success(None))) @@ -195,12 +191,12 @@ async fn save_comment( Json(payload): Json, ) -> Result>, ApiError> { state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(payload.content.clone()), - ConvTypeEnum::Comment, + ConvType::Comment, ) .await?; api_common::comment::check_comment_ref(user, state, &payload.content, &link).await @@ -262,8 +258,8 @@ async fn edit_title( Json(payload): Json, ) -> Result>, ApiError> { state - .issue_stg() - .edit_title(&link, &payload.content) + .monorepo() + .edit_issue_title(&link, &payload.content) .await?; Ok(Json(CommonResult::success(None))) } @@ -282,14 +278,9 @@ async fn issue_suggester( Query(payload): Query, state: State, ) -> Result>>, ApiError> { - let (issues, mrs) = state - .storage - .issue_service - .get_suggestions(&payload.query) + let res = state + .monorepo() + .get_issue_suggestions(&payload.query) .await?; - let mut res: Vec = issues.into_iter().map(|m| m.into()).collect(); - let mut mr_list: Vec = mrs.into_iter().map(|m| m.into()).collect(); - res.append(&mut mr_list); - res.sort(); Ok(Json(CommonResult::success(Some(res)))) } diff --git a/mono/src/api/router/label_router.rs b/mono/src/api/router/label_router.rs index a53431f3c..0d407b1ab 100644 --- a/mono/src/api/router/label_router.rs +++ b/mono/src/api/router/label_router.rs @@ -35,11 +35,11 @@ async fn fetch_label_list( Json(json): Json>, ) -> Result>>, ApiError> { let (items, total) = state - .issue_stg() + .monorepo() .list_labels_by_page(json.pagination, &json.additional) .await?; Ok(Json(CommonResult::success(Some(CommonPage { - items: items.into_iter().map(|m| m.into()).collect(), + items, total, })))) } @@ -59,11 +59,11 @@ async fn new_label( state: State, Json(json): Json, ) -> Result>, ApiError> { - let stg = state.issue_stg().clone(); - let res = stg + let res = state + .monorepo() .new_label(&json.name, &json.color, &json.description) .await?; - Ok(Json(CommonResult::success(Some(res.into())))) + Ok(Json(CommonResult::success(Some(res)))) } /// Fetch label details @@ -82,6 +82,6 @@ async fn fetch_label( state: State, Path(id): Path, ) -> Result>, ApiError> { - let label = state.issue_stg().get_label_by_id(id).await?; - Ok(Json(CommonResult::success(label.map(|m| m.into())))) + let label = state.monorepo().get_label_by_id(id).await?; + Ok(Json(CommonResult::success(label))) } diff --git a/mono/src/api/router/lfs_router.rs b/mono/src/api/router/lfs_router.rs index 42f2d2e51..31388336d 100644 --- a/mono/src/api/router/lfs_router.rs +++ b/mono/src/api/router/lfs_router.rs @@ -150,7 +150,7 @@ pub async fn list_locks( Query(query): Query, ) -> Result, (StatusCode, String)> { let result: Result = - handler::lfs_retrieve_lock(state.storage.lfs_db_storage(), query).await; + handler::lfs_retrieve_lock(state.lfs_db_storage(), query).await; match result { Ok(lock_list) => { let body = serde_json::to_string(&lock_list).unwrap_or_default(); @@ -191,7 +191,7 @@ pub async fn list_locks_for_verification( state: State, Json(json): Json, ) -> Result, (StatusCode, String)> { - let result = handler::lfs_verify_lock(state.storage.lfs_db_storage(), json).await; + let result = handler::lfs_verify_lock(state.lfs_db_storage(), json).await; match result { Ok(lock_list) => { let body = serde_json::to_string(&lock_list).unwrap_or_default(); @@ -227,7 +227,7 @@ pub async fn create_lock( state: State, Json(json): Json, ) -> Result, (StatusCode, String)> { - let result = handler::lfs_create_lock(state.storage.lfs_db_storage(), json).await; + let result = handler::lfs_create_lock(state.lfs_db_storage(), json).await; match result { Ok(lock) => { let lock_response = LockResponse { @@ -272,7 +272,7 @@ pub async fn delete_lock( Path(id): Path, Json(json): Json, ) -> Result { - let result = handler::lfs_delete_lock(state.storage.lfs_db_storage(), &id, json).await; + let result = handler::lfs_delete_lock(state.lfs_db_storage(), &id, json).await; match result { Ok(lock) => { @@ -314,8 +314,7 @@ pub async fn lfs_process_batch( state: State, Json(json): Json, ) -> Result, (StatusCode, String)> { - let result = - handler::lfs_process_batch(&state.storage.lfs_service, json, &state.listen_addr).await; + let result = handler::lfs_process_batch(&state.lfs_service(), json, state.listen_addr()).await; match result { Ok(res) => { @@ -355,7 +354,7 @@ pub async fn lfs_download_object( state: State, Path(oid): Path, ) -> Result { - let result = handler::lfs_download_object(state.storage.lfs_service.clone(), oid.clone()).await; + let result = handler::lfs_download_object(state.lfs_service(), oid.clone()).await; match result { Ok(byte_stream) => Ok(Response::builder() .header("Content-Type", LFS_STREAM_CONTENT_TYPE) @@ -408,7 +407,7 @@ pub async fn lfs_upload_object( .await .unwrap(); - let result = handler::lfs_upload_object(&state.storage.lfs_service, &req_obj, body_bytes).await; + let result = handler::lfs_upload_object(&state.lfs_service(), &req_obj, body_bytes).await; match result { Ok(_) => Ok(Response::builder() .header("Content-Type", LFS_CONTENT_TYPE) diff --git a/mono/src/api/router/permission_router.rs b/mono/src/api/router/permission_router.rs index 6d188949d..19d917509 100644 --- a/mono/src/api/router/permission_router.rs +++ b/mono/src/api/router/permission_router.rs @@ -45,12 +45,12 @@ async fn get_my_permission( ) -> Result>, ApiError> { let actor = user.username; - let (db_resource_type, resource_type_value, normalized_id) = + let (resource_type_value, normalized_id) = resolve_resource_context(&state, &resource_type, &resource_id).await?; let effective = state .monorepo() - .get_user_effective_permission(&actor, db_resource_type, &normalized_id) + .get_user_effective_permission(&actor, resource_type_value.into(), &normalized_id) .await?; let response = build_user_effective_permission_response( diff --git a/mono/src/api/router/preview_router.rs b/mono/src/api/router/preview_router.rs index 7cd2cc1cd..03f6e929f 100644 --- a/mono/src/api/router/preview_router.rs +++ b/mono/src/api/router/preview_router.rs @@ -32,11 +32,10 @@ async fn upsert_commit_binding( } }); state - .storage - .commit_binding_storage() - .upsert_binding(commit_id, final_username.clone(), final_username.is_none()) - .await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to save commit binding: {}", e))) + .monorepo() + .upsert_commit_binding(commit_id, final_username.clone(), final_username.is_none()) + .await?; + Ok(()) } pub fn routers() -> OpenApiRouter { @@ -297,15 +296,13 @@ async fn path_can_be_cloned( state: State, ) -> Result>, ApiError> { let path: PathBuf = query.path.clone().into(); - let import_dir = state.storage.config().monorepo.import_dir.clone(); + let import_dir = state.monorepo().import_dir(); + let path_str = path.to_str().unwrap(); let res = if path.starts_with(&import_dir) { - state - .storage - .git_db_storage() - .find_git_repo_exact_match(path.to_str().unwrap()) - .await - .unwrap() - .is_some() + match state.monorepo().find_git_repo_like_path(path_str).await? { + Some(model) => model.repo_path == path_str, + None => false, + } } else { // any path under monorepo can be cloned true diff --git a/mono/src/api/router/repo_router.rs b/mono/src/api/router/repo_router.rs index fa4e606de..a1cc9a16b 100644 --- a/mono/src/api/router/repo_router.rs +++ b/mono/src/api/router/repo_router.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; use api_model::common::CommonResult; use axum::{Json, extract::State}; -use ceres::{api_service::mono::MonoServiceLogic, model::change_list::CloneRepoPayload}; +use ceres::{ + application::api_service::mono::MonoServiceLogic, model::change_list::CloneRepoPayload, +}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ diff --git a/mono/src/api/router/reviewer_router.rs b/mono/src/api/router/reviewer_router.rs index be4b40a7f..fdc5a9625 100644 --- a/mono/src/api/router/reviewer_router.rs +++ b/mono/src/api/router/reviewer_router.rs @@ -3,10 +3,12 @@ use axum::{ Json, extract::{Path, State}, }; -use callisto::sea_orm_active_enums::{ConvTypeEnum, MergeStatusEnum}; -use ceres::model::change_list::{ - ChangeReviewStatePayload, ChangeReviewerStatePayload, ReviewerInfo, ReviewerPayload, - ReviewersResponse, +use ceres::model::{ + change_list::{ + ChangeReviewStatePayload, ChangeReviewerStatePayload, MergeStatus, ReviewerPayload, + ReviewersResponse, + }, + conversation::ConvType, }; use common::errors::MegaError; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -46,8 +48,7 @@ async fn add_reviewers( Json(payload): Json, ) -> Result>, ApiError> { state - .storage - .reviewer_storage() + .monorepo() .add_reviewers(&link, payload.reviewer_usernames.clone()) .await?; @@ -61,7 +62,7 @@ async fn add_reviewers( for reviewer in payload.reviewer_usernames { state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, @@ -69,7 +70,7 @@ async fn add_reviewers( "{} assigned a new reviewer {}", user.username, reviewer )), - ConvTypeEnum::Comment, + ConvType::Comment, ) .await?; } @@ -96,8 +97,7 @@ async fn remove_reviewers( Json(payload): Json, ) -> Result>, ApiError> { state - .storage - .reviewer_storage() + .monorepo() .remove_reviewers(&link, &payload.reviewer_usernames) .await?; @@ -111,12 +111,12 @@ async fn remove_reviewers( for reviewer in &payload.reviewer_usernames { state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(format!("{} removed reviewer {}", user.username, reviewer)), - ConvTypeEnum::Comment, + ConvType::Comment, ) .await?; } @@ -139,22 +139,9 @@ async fn list_reviewers( Path(link): Path, state: State, ) -> Result>, ApiError> { - let reviewers = state - .storage - .reviewer_storage() - .list_reviewers(&link) - .await? - .into_iter() - .map(|r| ReviewerInfo { - username: r.username, - approved: r.approved, - system_required: r.system_required, - }) - .collect(); + let reviewers = state.monorepo().list_reviewers(&link).await?; - Ok(Json(CommonResult::success(Some(ReviewersResponse { - result: reviewers, - })))) + Ok(Json(CommonResult::success(Some(reviewers)))) } /// Change the reviewer approval state @@ -176,28 +163,24 @@ async fn reviewer_approve( state: State, Json(payload): Json, ) -> Result>, ApiError> { - let res = state.cl_stg().get_cl(&link).await?; - let model = res.ok_or(MegaError::Other("CL Not Found".to_string()))?; - - if model.status == MergeStatusEnum::Draft { + if state.monorepo().cl_merge_status(&link).await? == MergeStatus::Draft { return Err(ApiError::from(MegaError::Other( ERR_CL_NOT_READY_FOR_REVIEW.to_owned(), ))); } state - .storage - .reviewer_storage() + .monorepo() .reviewer_change_state(&link, &user.username, payload.approved) .await?; state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(format!("{} approved the CL", user.username)), - ConvTypeEnum::Approve, + ConvType::Approve, ) .await?; @@ -224,11 +207,7 @@ async fn review_resolve( Path(link): Path, Json(payload): Json, ) -> Result>, ApiError> { - let res = state - .storage - .reviewer_storage() - .is_reviewer(&link, &user.username) - .await?; + let res = state.monorepo().is_reviewer(&link, &user.username).await?; if !res { return Err(ApiError::from(MegaError::Other( @@ -237,18 +216,17 @@ async fn review_resolve( } state - .storage - .conversation_storage() + .monorepo() .change_review_state(&link, &payload.conversation_id, payload.resolved) .await?; state - .conv_stg() + .monorepo() .add_conversation( &link, &user.username, Some(format!("{} resolved a review", user.username)), - ConvTypeEnum::Comment, + ConvType::Comment, ) .await?; diff --git a/mono/src/api/router/tag_router.rs b/mono/src/api/router/tag_router.rs index 5b5ad8f91..1de6d4be1 100644 --- a/mono/src/api/router/tag_router.rs +++ b/mono/src/api/router/tag_router.rs @@ -1,19 +1,13 @@ -use std::path::Path as StdPath; - -use anyhow::anyhow; use api_model::common::{CommonResult, PageParams}; use axum::{ Json, extract::{Path, State}, }; use ceres::model::tag::{CreateTagRequest, DeleteTagResponse, TagListResponse, TagResponse}; +use common::errors::MegaError; use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::api::{ - MonoApiServiceState, - api_doc::TAG_MANAGE, - error::{ApiError, map_ceres_error}, -}; +use crate::api::{MonoApiServiceState, api_doc::TAG_MANAGE, error::ApiError}; pub fn routers() -> OpenApiRouter { OpenApiRouter::new() @@ -23,119 +17,16 @@ pub fn routers() -> OpenApiRouter { .routes(routes!(delete_tag)) } -// Note: query-based path_context is intentionally removed for tag APIs; repo selection is -// resolved from router context (MonoApiServiceState) or request body for create if needed. - /// Resolve a target string (possibly "HEAD" or a commit hash) to an actual commit SHA. -/// If target_opt is Some and not "HEAD", return it directly. If it's None or "HEAD", -/// resolve to the repository's current HEAD/default branch commit. async fn resolve_target_commit_id( state: &MonoApiServiceState, path_context: Option<&str>, target_opt: Option<&str>, ) -> Result { - // if caller provided a specific non-"HEAD" target, use it directly - if let Some(t) = target_opt - && t != "HEAD" - && !t.is_empty() - { - return Ok(t.to_string()); - } - - let import_dir = state.storage.config().monorepo.import_dir.clone(); - if let Some(path) = path_context { - let std_path = StdPath::new(path); - if std_path.starts_with(&import_dir) && std_path != StdPath::new(&import_dir) { - // find repo model (longest-prefix match) - if let Some(repo_model) = state - .storage - .git_db_storage() - .find_git_repo_like_path(path) - .await - .map_err(|e| ApiError::from(anyhow!("Database error: {}", e)))? - { - let git = state.storage.git_db_storage(); - // try default branch ref - if let Ok(Some(r)) = git.get_default_ref(repo_model.id).await { - return Ok(r.ref_git_id); - } - // fallback: any import ref for repo - if let Ok(refs) = git.get_ref(repo_model.id).await - && let Some(r) = refs.into_iter().next() - { - return Ok(r.ref_git_id); - } - return Ok("HEAD".to_string()); - } - // If db lookup did not find a repo despite prefix, fall through to mono logic - } else { - // path is outside import_dir → mono - let mono = state.storage.mono_storage(); - let resolved_path = path_context.unwrap_or("/"); - if let Ok(Some(r)) = mono.get_main_ref(resolved_path).await { - return Ok(r.ref_commit_hash); - } - if let Ok(Some(root_ref)) = mono.get_main_ref("/").await { - return Ok(root_ref.ref_commit_hash); - } - return Ok("HEAD".to_string()); - } - } - - // Default fallback: try mono root ref - let mono = state.storage.mono_storage(); - if let Ok(Some(root_ref)) = mono.get_main_ref("/").await { - return Ok(root_ref.ref_commit_hash); - } - Ok("HEAD".to_string()) -} - -// Validate tag name against a conservative subset of Git ref rules. -fn validate_tag_name(name: &str) -> Result<(), ApiError> { - // Basic checks that don't require iterating characters - if name.is_empty() { - return Err(ApiError::bad_request(anyhow!("Tag name must not be empty"))); - } - - if name.len() > 255 { - return Err(ApiError::bad_request(anyhow!("Tag name is too long"))); - } - - if name.contains("..") || name.contains("@{") { - return Err(ApiError::bad_request(anyhow!( - "Tag name contains reserved sequence '..' or '@{{'" - ))); - } - - if name.contains("//") { - return Err(ApiError::bad_request(anyhow!( - "Tag name must not contain '//'" - ))); - } - - if name.ends_with(".lock") { - return Err(ApiError::bad_request(anyhow!( - "Tag name must not end with '.lock'" - ))); - } - - // Single-pass character validation: forbidden chars, NUL, control chars - let forbidden = [' ', '~', '^', ':', '?', '*', '[', '\\']; - for c in name.chars() { - if forbidden.contains(&c) { - return Err(ApiError::bad_request(anyhow!(format!( - "Tag name '{}' contains forbidden character '{}'", - name, c - )))); - } - if c == '\0' || c.is_control() { - return Err(ApiError::bad_request(anyhow!( - "Tag name contains invalid control characters" - ))); - } - } - - Ok(()) + Ok(state + .monorepo() + .resolve_target_commit_id(path_context, target_opt) + .await?) } /// Create Tag @@ -147,7 +38,9 @@ fn validate_tag_name(name: &str) -> Result<(), ApiError> { content_type = "application/json" ), responses( - (status = 201, body = CommonResult, content_type = "application/json") + (status = 201, body = CommonResult, content_type = "application/json"), + (status = 400, description = "Invalid tag name or request", content_type = "application/json"), + (status = 404, description = "Target commit not found", content_type = "application/json"), ), tag = TAG_MANAGE )] @@ -155,26 +48,20 @@ async fn create_tag( State(state): State, Json(req): Json, ) -> Result>, ApiError> { - // We ignore query path_context for tag creation; use request target commit directly. - validate_tag_name(&req.name)?; - // Resolve target commit: if caller provided a target, use it; otherwise resolve using optional path_context. let resolved_target = if let Some(t) = req.target.as_deref() { if t != "HEAD" && !t.is_empty() { t.to_string() } else { - // fallback: resolve using provided path_context if any resolve_target_commit_id(&state, req.path_context.as_deref(), None).await? } } else { resolve_target_commit_id(&state, req.path_context.as_deref(), None).await? }; - // dispatch to repo-specific handler via ApiHandler using path_context if provided let repo_path_ref = req.path_context.as_deref().unwrap_or("/"); let api = state .api_handler(std::path::Path::new(repo_path_ref)) - .await - .map_err(|e| map_ceres_error(e, "Failed to resolve api handler"))?; + .await?; let tag_info = api .create_tag( @@ -186,7 +73,7 @@ async fn create_tag( req.message.clone(), ) .await - .map_err(|e| map_ceres_error(e, "Failed to create tag"))?; + .map_err(MegaError::from)?; let response = TagResponse { name: tag_info.name, @@ -210,7 +97,6 @@ async fn create_tag( ), tag = TAG_MANAGE )] - async fn list_tags( State(state): State, Json(json): Json>, @@ -223,12 +109,11 @@ async fn list_tags( }; let api = state .api_handler(std::path::Path::new(repo_path_ref)) - .await - .map_err(|e| map_ceres_error(e, "Failed to resolve api handler"))?; + .await?; let (tags, total) = api .list_tags(Some(repo_path_ref.to_string()), pagination) .await - .map_err(|e| map_ceres_error(e, "Failed to list tags"))?; + .map_err(MegaError::from)?; let tag_responses: Vec = tags .into_iter() .map(|t| TagResponse { @@ -264,15 +149,12 @@ async fn get_tag( Path(name): Path, ) -> Result>, ApiError> { let repo_path = "/".to_string(); - let api = state - .api_handler(std::path::Path::new(&repo_path)) - .await - .map_err(|e| map_ceres_error(e, "Failed to resolve api handler"))?; + let api = state.api_handler(std::path::Path::new(&repo_path)).await?; match api .get_tag(Some(repo_path.clone()), name.clone()) .await - .map_err(|e| map_ceres_error(e, "Failed to get tag"))? + .map_err(MegaError::from)? { Some(t) => { let response = TagResponse { @@ -286,10 +168,7 @@ async fn get_tag( }; Ok(Json(CommonResult::success(Some(response)))) } - None => Err(ApiError::not_found(anyhow!(format!( - "Tag '{}' not found", - name - )))), + None => Err(MegaError::NotFound(format!("Tag '{name}' not found")).into()), } } @@ -307,14 +186,11 @@ async fn delete_tag( State(state): State, Path(name): Path, ) -> Result>, ApiError> { - let repo_path = "/".to_string(); // use root for delete operations by default - let api = state - .api_handler(std::path::Path::new(&repo_path)) - .await - .map_err(|e| map_ceres_error(e, "Failed to resolve api handler"))?; + let repo_path = "/".to_string(); + let api = state.api_handler(std::path::Path::new(&repo_path)).await?; api.delete_tag(Some(repo_path.clone()), name.clone()) .await - .map_err(|e| map_ceres_error(e, "Failed to delete tag"))?; + .map_err(MegaError::from)?; let response = DeleteTagResponse { deleted_tag: name.clone(), diff --git a/mono/src/api/router/user_router.rs b/mono/src/api/router/user_router.rs index eb8b5cb24..9018db574 100644 --- a/mono/src/api/router/user_router.rs +++ b/mono/src/api/router/user_router.rs @@ -7,7 +7,6 @@ use axum::{ use ceres::model::{ notification::{ NotificationEventTypeInfo, UpdateUserNotificationConfig, UserNotificationConfig, - UserNotificationPreferenceItem, }, user::{ AddSSHKey, ClaContentRes, ClaSignStatusRes, ListSSHKey, ListToken, UpdateClaContentPayload, @@ -79,7 +78,7 @@ async fn add_key( json.title }; state - .user_stg() + .monorepo() .save_ssh_key( user.username, &title, @@ -108,7 +107,7 @@ async fn remove_key( Path(key_id): Path, ) -> Result>, ApiError> { state - .user_stg() + .monorepo() .delete_ssh_key(user.username, key_id) .await?; Ok(Json(CommonResult::success(None))) @@ -127,10 +126,8 @@ async fn list_key( user: LoginUser, state: State, ) -> Result>>, ApiError> { - let res = state.user_stg().list_user_ssh(user.username).await?; - Ok(Json(CommonResult::success(Some( - res.into_iter().map(|x| x.into()).collect(), - )))) + let res = state.monorepo().list_user_ssh_keys(user.username).await?; + Ok(Json(CommonResult::success(Some(res)))) } /// Generate Token For http push @@ -146,7 +143,7 @@ async fn generate_token( user: LoginUser, state: State, ) -> Result>, ApiError> { - let res = state.user_stg().generate_token(user.username).await?; + let res = state.monorepo().generate_user_token(user.username).await?; Ok(Json(CommonResult::success(Some(res)))) } @@ -167,7 +164,10 @@ async fn remove_token( state: State, Path(key_id): Path, ) -> Result>, ApiError> { - state.user_stg().delete_token(user.username, key_id).await?; + state + .monorepo() + .delete_user_token(user.username, key_id) + .await?; Ok(Json(CommonResult::success(None))) } @@ -184,9 +184,8 @@ async fn list_token( user: LoginUser, state: State, ) -> Result>>, ApiError> { - let data = state.user_stg().list_token(user.username).await?; - let res = data.into_iter().map(|x| x.into()).collect(); - Ok(Json(CommonResult::success(Some(res)))) + let data = state.monorepo().list_user_tokens(user.username).await?; + Ok(Json(CommonResult::success(Some(data)))) } /// List supported notification event types @@ -200,19 +199,7 @@ async fn list_notification_types( _user: LoginUser, state: State, ) -> Result>>, ApiError> { - let types = state - .notification_stg() - .list_event_types() - .await? - .into_iter() - .map(|t| NotificationEventTypeInfo { - code: t.code, - category: t.category, - description: t.description, - system_required: t.system_required, - default_enabled: t.default_enabled, - }) - .collect(); + let types = state.monorepo().list_notification_event_types().await?; Ok(Json(CommonResult::success(Some(types)))) } @@ -228,34 +215,12 @@ async fn get_notification_config( user: LoginUser, state: State, ) -> Result>, ApiError> { - state - .notification_stg() - .upsert_user_settings(&user.username, &user.email) + let config = state + .monorepo() + .get_user_notification_config(&user.username, &user.email) .await?; - let settings = state - .notification_stg() - .get_user_settings(&user.username) - .await? - .ok_or_else(|| MegaError::Other("user settings missing".to_string()))?; - - let prefs = state - .notification_stg() - .list_user_preferences(&user.username) - .await? - .into_iter() - .map(|p| UserNotificationPreferenceItem { - event_type_code: p.event_type_code, - enabled: p.enabled, - }) - .collect(); - - Ok(Json(CommonResult::success(Some(UserNotificationConfig { - enabled: settings.enabled, - delivery_mode: settings.delivery_mode, - email: settings.email, - preferences: prefs, - })))) + Ok(Json(CommonResult::success(Some(config)))) } /// Update current user's notification config @@ -272,31 +237,10 @@ async fn update_notification_config( Json(payload): Json, ) -> Result>, ApiError> { state - .notification_stg() - .upsert_user_settings(&user.username, &user.email) + .monorepo() + .update_user_notification_config(&user.username, &user.email, payload) .await?; - if let Some(enabled) = payload.enabled { - state - .notification_stg() - .set_global_enabled(&user.username, enabled) - .await?; - } - if let Some(mode) = payload.delivery_mode { - state - .notification_stg() - .set_delivery_mode(&user.username, &mode) - .await?; - } - if let Some(items) = payload.preferences { - for item in items { - state - .notification_stg() - .set_user_preference(&user.username, &item.event_type_code, item.enabled) - .await?; - } - } - Ok(Json(CommonResult::success(None))) } diff --git a/mono/src/api/router/webhook_router.rs b/mono/src/api/router/webhook_router.rs index d788fb415..98e8afc97 100644 --- a/mono/src/api/router/webhook_router.rs +++ b/mono/src/api/router/webhook_router.rs @@ -3,16 +3,7 @@ use axum::{ Json, extract::{Path, Query, State}, }; -use callisto::sea_orm_active_enums::WebhookEventTypeEnum; -use chrono::Utc; -use jupiter::{ - idgenerator::IdInstance, - sea_orm::ActiveEnum, - service::webhook_service::{encrypt_webhook_secret, validate_webhook_target_url}, - storage::webhook_storage::WebhookWithEventTypes, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use ceres::model::webhook::{CreateWebhookRequest, ListWebhooksQuery, WebhookResponse}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{MonoApiServiceState, api_doc::WEBHOOK_TAG, error::ApiError}; @@ -24,61 +15,6 @@ pub fn routers() -> OpenApiRouter { .routes(routes!(delete_webhook)) } -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateWebhookRequest { - pub target_url: String, - pub secret: String, - /// Event types: "cl.created", "cl.updated", "cl.merged", "cl.closed", "cl.reopened", "cl.comment.created", "*" - pub event_types: Vec, - pub path_filter: Option, - pub active: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct WebhookResponse { - pub id: i64, - pub target_url: String, - pub event_types: Vec, - pub path_filter: Option, - pub active: bool, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Deserialize)] -pub struct ListWebhooksQuery { - pub page: Option, - pub per_page: Option, -} - -impl From for WebhookResponse { - fn from(value: WebhookWithEventTypes) -> Self { - let m = value.webhook; - Self { - id: m.id, - target_url: m.target_url, - event_types: value - .event_types - .into_iter() - .map(|e| e.to_value()) - .collect(), - path_filter: m.path_filter, - active: m.active, - created_at: m.created_at.to_string(), - updated_at: m.updated_at.to_string(), - } - } -} - -fn parse_event_types(raw: Vec) -> Result, ApiError> { - raw.into_iter() - .map(|s| { - WebhookEventTypeEnum::try_from_value(&s) - .map_err(|_| ApiError::bad_request(anyhow::anyhow!("invalid event type: {s}"))) - }) - .collect() -} - /// Create a webhook #[utoipa::path( post, @@ -93,32 +29,8 @@ async fn create_webhook( state: State, Json(payload): Json, ) -> Result>, ApiError> { - validate_webhook_target_url(&payload.target_url).map_err(ApiError::bad_request)?; - if payload.secret.is_empty() { - return Err(ApiError::bad_request(anyhow::anyhow!( - "webhook secret cannot be empty" - ))); - } - let encrypted_secret = encrypt_webhook_secret(&payload.secret).map_err(ApiError::internal)?; - let event_types = parse_event_types(payload.event_types)?; - - let now = Utc::now().naive_utc(); - let model = callisto::mega_webhook::Model { - id: IdInstance::next_id(), - target_url: payload.target_url, - secret: encrypted_secret, - event_types: serde_json::to_string(&event_types).unwrap_or_else(|_| "[]".to_string()), - path_filter: payload.path_filter, - active: payload.active.unwrap_or(true), - created_at: now, - updated_at: now, - }; - - let created = state - .webhook_stg() - .create_webhook(model, event_types) - .await?; - Ok(Json(CommonResult::success(Some(created.into())))) + let created = state.monorepo().create_webhook(payload).await?; + Ok(Json(CommonResult::success(Some(created)))) } /// List webhooks @@ -139,8 +51,7 @@ async fn list_webhooks( Query(query): Query, ) -> Result>>, ApiError> { let pagination = build_webhook_pagination(query)?; - let (webhooks, total) = state.webhook_stg().list_webhooks(pagination).await?; - let items: Vec = webhooks.into_iter().map(|w| w.into()).collect(); + let (items, total) = state.monorepo().list_webhooks(pagination).await?; Ok(Json(CommonResult::success(Some(CommonPage { total, items, @@ -162,7 +73,7 @@ async fn delete_webhook( state: State, Path(id): Path, ) -> Result>, ApiError> { - state.webhook_stg().delete_webhook(id).await?; + state.monorepo().delete_webhook(id).await?; Ok(Json(CommonResult::success(None))) } diff --git a/context/src/lib.rs b/mono/src/bootstrap/mod.rs similarity index 70% rename from context/src/lib.rs rename to mono/src/bootstrap/mod.rs index 5467ad574..9ed2dd475 100644 --- a/context/src/lib.rs +++ b/mono/src/bootstrap/mod.rs @@ -2,25 +2,16 @@ use std::sync::Arc; use jupiter::redis::{ConnectionManager, init_connection}; -/// This is the main application context for the Mono application. -/// It holds shared state and configuration for the application. -/// Including database connections, configuration settings, encrypted vault functions, etc. +/// Main application context for the Mono application. #[derive(Clone)] pub struct AppContext { - /// The storage sub-context for the from jupiter abstract layer. pub storage: jupiter::storage::Storage, - - /// The vault core for managing encrypted data. pub vault: vault::integration::vault_core::VaultCore, - - /// The configuration settings for the application. pub config: Arc, - pub connection: ConnectionManager, } impl AppContext { - /// Creates a new application context with the given configuration. pub async fn new(config: common::config::Config) -> Self { let config = Arc::new(config); diff --git a/mono/src/commands/service/http.rs b/mono/src/commands/service/http.rs index cfa2deac4..30ceb8594 100644 --- a/mono/src/commands/service/http.rs +++ b/mono/src/commands/service/http.rs @@ -1,10 +1,12 @@ use clap::{ArgMatches, Args, Command, FromArgMatches}; use common::errors::MegaResult; -use context::AppContext; -use crate::server::{ - CommonHttpOptions, - http_server::{self}, +use crate::{ + bootstrap::AppContext, + server::{ + CommonHttpOptions, + http_server::{self}, + }, }; pub fn cli() -> Command { diff --git a/mono/src/commands/service/mod.rs b/mono/src/commands/service/mod.rs index d01b71d09..b5bbed91f 100644 --- a/mono/src/commands/service/mod.rs +++ b/mono/src/commands/service/mod.rs @@ -6,7 +6,8 @@ use clap::{ArgMatches, Command}; use common::{config::Config, errors::MegaResult}; -use context::AppContext; + +use crate::bootstrap::AppContext; pub mod http; pub mod multi; diff --git a/mono/src/commands/service/multi.rs b/mono/src/commands/service/multi.rs index 93e34bb66..28c9974f9 100644 --- a/mono/src/commands/service/multi.rs +++ b/mono/src/commands/service/multi.rs @@ -1,11 +1,13 @@ use clap::{ArgMatches, Args, Command, FromArgMatches, ValueEnum}; use common::errors::MegaResult; -use context::AppContext; -use crate::server::{ - CommonHttpOptions, - http_server::{self}, - ssh_server::{self, SshCustom, SshOptions}, +use crate::{ + bootstrap::AppContext, + server::{ + CommonHttpOptions, + http_server::{self}, + ssh_server::{self, SshCustom, SshOptions}, + }, }; #[derive(Debug, PartialEq, Clone, ValueEnum)] diff --git a/mono/src/commands/service/ssh.rs b/mono/src/commands/service/ssh.rs index 33385703d..b6ceb18a8 100644 --- a/mono/src/commands/service/ssh.rs +++ b/mono/src/commands/service/ssh.rs @@ -1,8 +1,10 @@ use clap::{ArgMatches, Args, Command, FromArgMatches}; use common::errors::MegaResult; -use context::AppContext; -use crate::server::ssh_server::{SshOptions, start_server}; +use crate::{ + bootstrap::AppContext, + server::ssh_server::{SshOptions, start_server}, +}; pub fn cli() -> Command { SshOptions::augment_args_for_update(Command::new("ssh").about("Start Git SSH server")) diff --git a/mono/src/email/mod.rs b/mono/src/email/mod.rs index 277f81bd6..32fa54cb5 100644 --- a/mono/src/email/mod.rs +++ b/mono/src/email/mod.rs @@ -6,6 +6,8 @@ use lettre::{ transport::smtp::authentication::Credentials, }; +use crate::notification::EmailMailer; + #[async_trait] pub trait Mailer: Send + Sync { async fn send_html( @@ -32,6 +34,19 @@ impl Mailer for NoopMailer { } } +#[async_trait] +impl EmailMailer for NoopMailer { + async fn send_html( + &self, + to: &str, + subject: &str, + html: &str, + text: Option<&str>, + ) -> Result<(), MegaError> { + Mailer::send_html(self, to, subject, html, text).await + } +} + pub struct SmtpMailer { enabled: bool, from: String, @@ -133,6 +148,19 @@ impl Mailer for SmtpMailer { } } +#[async_trait] +impl EmailMailer for SmtpMailer { + async fn send_html( + &self, + to: &str, + subject: &str, + html: &str, + text: Option<&str>, + ) -> Result<(), MegaError> { + Mailer::send_html(self, to, subject, html, text).await + } +} + #[cfg(test)] mod tests { use common::config::MailConfig; diff --git a/mono/src/git_protocol/http.rs b/mono/src/git_protocol/http.rs index 40a711f04..244873b1c 100644 --- a/mono/src/git_protocol/http.rs +++ b/mono/src/git_protocol/http.rs @@ -8,11 +8,13 @@ use axum::{ use base64::Engine; use bytes::{Bytes, BytesMut}; use ceres::{ - api_service::state::ProtocolApiState, - pack::into_pack_byte_stream, - protocol::{PushUserInfo, ServiceType, SmartSession, TransportProtocol, smart}, + infra::pack_stream::into_pack_byte_stream, + transport::{ + ProtocolApiState, + protocol::{PushUserInfo, ServiceType, SmartSession, TransportProtocol, smart}, + }, }; -use common::errors::ProtocolError; +use common::errors::{ProtocolError, mega_to_protocol_error}; use futures::{TryStreamExt, stream}; use http::header::AUTHORIZATION; use tokio::io::AsyncReadExt; @@ -96,9 +98,10 @@ async fn git_receive_pack_auth( return Ok(false); }; - let Some(user) = - login_user_from_mono_access_token(&state.storage.user_storage(), &token).await? - else { + let user = login_user_from_mono_access_token(&state.storage.user_storage(), &token) + .await + .map_err(mega_to_protocol_error)?; + let Some(user) = user else { return Ok(false); }; diff --git a/mono/src/git_protocol/mod.rs b/mono/src/git_protocol/mod.rs index c3e6bcd90..0e8daf71d 100644 --- a/mono/src/git_protocol/mod.rs +++ b/mono/src/git_protocol/mod.rs @@ -1,6 +1,7 @@ use serde::Deserialize; pub mod http; +pub mod protocol_error; pub mod ssh; #[derive(Deserialize, Debug)] diff --git a/mono/src/git_protocol/protocol_error.rs b/mono/src/git_protocol/protocol_error.rs new file mode 100644 index 000000000..141825a77 --- /dev/null +++ b/mono/src/git_protocol/protocol_error.rs @@ -0,0 +1,46 @@ +use api_model::common::CommonResult; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use common::errors::{ProtocolError, protocol_error_http_status, protocol_error_is_client_safe}; + +pub fn into_response(err: ProtocolError) -> Response { + let status = StatusCode::from_u16(protocol_error_http_status(&err)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let err_msg = err.to_string(); + + if status.is_client_error() { + tracing::warn!(status = %status, "Protocol client error: {}", err_msg); + } else { + tracing::error!(status = %status, "Protocol error: {}", err_msg); + } + + let response_msg = if protocol_error_is_client_safe(&err) { + err_msg + } else { + "Something went wrong".to_owned() + }; + + (status, Json(CommonResult::::failed(&response_msg))).into_response() +} + +/// User-visible message for SSH stderr / channel data. +pub fn ssh_error_message(err: &ProtocolError) -> String { + if protocol_error_is_client_safe(err) { + format!("fatal: {err}\n") + } else { + "fatal: something went wrong\n".to_owned() + } +} + +/// Non-zero SSH exit status for protocol failures. +pub fn ssh_exit_status(err: &ProtocolError) -> u32 { + match protocol_error_http_status(err) { + 404 => 1, + 401 | 403 => 126, + 400 | 413 => 2, + _ => 1, + } +} diff --git a/mono/src/git_protocol/ssh.rs b/mono/src/git_protocol/ssh.rs index 70df46117..2c14493bb 100644 --- a/mono/src/git_protocol/ssh.rs +++ b/mono/src/git_protocol/ssh.rs @@ -2,15 +2,18 @@ use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; use bytes::{Bytes, BytesMut}; use ceres::{ - api_service::state::ProtocolApiState, + infra::pack_stream::into_pack_byte_stream, lfs::lfs_structs::Link, - pack::into_pack_byte_stream, - protocol::{ - ServiceType, SmartSession, TransportProtocol, - smart::{self}, + transport::{ + ProtocolApiState, + protocol::{ + ServiceType, SmartSession, TransportProtocol, + smart::{self}, + }, }, }; use chrono::{DateTime, Duration, Utc}; +use common::errors::ProtocolError; use futures::{StreamExt, stream}; use russh::{ Channel, ChannelId, @@ -19,7 +22,10 @@ use russh::{ }; use tokio::{io::AsyncReadExt, sync::Mutex}; -use crate::git_protocol::http::search_subsequence; +use crate::git_protocol::{ + http::search_subsequence, + protocol_error::{ssh_error_message, ssh_exit_status}, +}; type ClientMap = HashMap<(usize, ChannelId), Channel>; @@ -88,11 +94,16 @@ impl server::Handler for SshServer { SmartSession::new(PathBuf::from(&path), service_type, TransportProtocol::Ssh); match command[0] { "git-upload-pack" | "git-receive-pack" => { - // TODO handler ProtocolError - let res = smart_protocol.git_info_refs(&self.state).await.unwrap(); - self.smart_protocol = Some(smart_protocol); - session.data(channel, res.to_vec())?; - session.channel_success(channel)?; + match smart_protocol.git_info_refs(&self.state).await { + Ok(res) => { + self.smart_protocol = Some(smart_protocol); + session.data(channel, res.to_vec())?; + session.channel_success(channel)?; + } + Err(err) => { + Self::send_protocol_error(session, channel, err)?; + } + } } //Note that currently mega does not support pure ssh to transfer files, still relay on the https server. //see https://github.com/git-lfs/git-lfs/blob/main/docs/proposals/ssh_adapter.md for more details about pure ssh file transfer. @@ -164,7 +175,7 @@ impl server::Handler for SshServer { let service_type = smart_protocol.service_type; match service_type { ServiceType::UploadPack => { - self.handle_upload_pack(channel, data, session).await; + self.handle_upload_pack(channel, data, session).await?; } ServiceType::ReceivePack => { self.data_combined.extend_from_slice(data); @@ -182,7 +193,7 @@ impl server::Handler for SshServer { if let Some(smart_protocol) = self.smart_protocol.as_mut() && smart_protocol.service_type == ServiceType::ReceivePack { - self.handle_receive_pack(channel, session).await; + self.handle_receive_pack(channel, session).await?; }; { @@ -196,38 +207,62 @@ impl server::Handler for SshServer { } impl SshServer { - async fn handle_upload_pack(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) { + fn send_protocol_error( + session: &mut Session, + channel: ChannelId, + err: ProtocolError, + ) -> Result<(), anyhow::Error> { + session.data(channel, ssh_error_message(&err).into_bytes())?; + session.exit_status_request(channel, ssh_exit_status(&err))?; + Ok(()) + } + + async fn handle_upload_pack( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), anyhow::Error> { let smart_protocol = self.smart_protocol.as_mut().unwrap(); - let (mut send_pack_data, buf) = smart_protocol + let (mut send_pack_data, buf) = match smart_protocol .git_upload_pack(&self.state, &mut Bytes::copy_from_slice(data)) .await - .unwrap(); + { + Ok(v) => v, + Err(err) => { + Self::send_protocol_error(session, channel, err)?; + return Ok(()); + } + }; tracing::info!("buf is {:?}", buf); session - .data(channel, String::from_utf8(buf.to_vec()).unwrap()) - .unwrap(); + .data(channel, String::from_utf8(buf.to_vec())?) + .map_err(anyhow::Error::from)?; while let Some(chunk) = send_pack_data.next().await { let mut reader = chunk.as_slice(); loop { let mut temp = BytesMut::new(); temp.reserve(65500); - let length = reader.read_buf(&mut temp).await.unwrap(); + let length = reader.read_buf(&mut temp).await?; if length == 0 { break; } let bytes_out = smart_protocol.build_side_band_format(temp, length); - session.data(channel, bytes_out.to_vec()).unwrap(); + session.data(channel, bytes_out.to_vec())?; } } - session - .data(channel, smart::PKT_LINE_END_MARKER.to_vec()) - .unwrap(); + session.data(channel, smart::PKT_LINE_END_MARKER.to_vec())?; + Ok(()) } - async fn handle_receive_pack(&mut self, channel: ChannelId, session: &mut Session) { + async fn handle_receive_pack( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), anyhow::Error> { let smart_protocol = self.smart_protocol.as_mut().unwrap(); let data = self.data_combined.split().freeze(); let mut data_stream = Box::pin(stream::once(async move { @@ -244,19 +279,26 @@ impl SshServer { let remaining_bytes = Bytes::copy_from_slice(&chunk[pos..]); let remaining_stream = stream::once(async { Ok(remaining_bytes) }).chain(data_stream); - report_status = smart_protocol + report_status = match smart_protocol .git_receive_pack_stream( &self.state, commands, into_pack_byte_stream(remaining_stream), ) .await - .unwrap(); + { + Ok(status) => status, + Err(err) => { + Self::send_protocol_error(session, channel, err)?; + return Ok(()); + } + }; break; } } tracing::info!("report status: {:?}", report_status); - session.data(channel, report_status.to_vec()).unwrap(); + session.data(channel, report_status.to_vec())?; + Ok(()) } } diff --git a/mono/src/lib.rs b/mono/src/lib.rs index 51c6b5efa..dfe755a4d 100644 --- a/mono/src/lib.rs +++ b/mono/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod bootstrap; pub mod cli; mod commands; pub mod email; diff --git a/mono/src/main.rs b/mono/src/main.rs index 7be3badca..dfa10e9a9 100644 --- a/mono/src/main.rs +++ b/mono/src/main.rs @@ -3,15 +3,6 @@ //! //! And this is the main entry point for the application. -mod cli; -mod commands; - -pub mod api; -pub mod email; -pub mod git_protocol; -pub mod notification; -use mono::server; - #[cfg(not(target_os = "windows"))] #[global_allocator] static GLOBAL_ALLOCATOR: jemallocator::Jemalloc = jemallocator::Jemalloc; @@ -21,11 +12,7 @@ static GLOBAL_ALLOCATOR: jemallocator::Jemalloc = jemallocator::Jemalloc; static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; fn main() { - // Parse the command line arguments - let result = cli::parse(None); - - // If there was an error, print it - if let Err(e) = result { + if let Err(e) = mono::cli::parse(None) { panic!("{}", e); } } diff --git a/mono/src/notification/mod.rs b/mono/src/notification/mod.rs index 6c15b69b1..8a203250a 100644 --- a/mono/src/notification/mod.rs +++ b/mono/src/notification/mod.rs @@ -1,3 +1,6 @@ -pub mod dispatcher; pub mod triggers; -pub use dispatcher::EmailDispatcher; + +pub use ceres::application::notification::{ + EVENT_CL_COMMENT_CREATED, EmailDispatcher, EmailMailer, ensure_cl_comment_event_type, + on_cl_comment_created, +}; diff --git a/mono/src/notification/triggers.rs b/mono/src/notification/triggers.rs index d33f0d5f8..ce1925b5c 100644 --- a/mono/src/notification/triggers.rs +++ b/mono/src/notification/triggers.rs @@ -1,265 +1 @@ -use std::collections::HashSet; - -use callisto::notification_event_types; -use common::errors::MegaError; -use jupiter::{ - sea_orm::{ActiveModelTrait, Set}, - storage::{ - cl_reviewer_storage::ClReviewerStorage, cl_storage::ClStorage, - notification_storage::NotificationStorage, - }, -}; -pub const EVENT_CL_COMMENT_CREATED: &str = "cl.comment.created"; - -/// Ensure the core event types exist in DB -/// -/// currently does not seed event types in migrations -/// upsert the event type at first use. -async fn ensure_event_type_exists(stg: &NotificationStorage) -> Result<(), MegaError> { - if stg - .get_event_type(EVENT_CL_COMMENT_CREATED) - .await? - .is_some() - { - return Ok(()); - } - - let now = chrono::Utc::now().naive_utc(); - notification_event_types::ActiveModel { - code: Set(EVENT_CL_COMMENT_CREATED.to_owned()), - category: Set("cl".to_owned()), - description: Set("New comment on a Change List".to_owned()), - system_required: Set(false), - default_enabled: Set(true), - created_at: Set(now), - updated_at: Set(now), - } - .insert(stg.db()) - .await?; - - Ok(()) -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -/// Trigger: a new comment is created on a Change List -/// -/// Behavior: -/// - recipients: CL author + all reviewers -/// - exclude actor -/// - respect user preferences via `should_send` -/// - enqueue email job (outbox) and let the background dispatcher deliver it -pub async fn on_cl_comment_created( - notif_stg: &NotificationStorage, - cl_stg: &ClStorage, - reviewer_stg: &ClReviewerStorage, - actor_username: &str, - cl_link: &str, - comment_text: &str, -) -> Result<(), MegaError> { - ensure_event_type_exists(notif_stg).await?; - - let cl: callisto::mega_cl::Model = cl_stg - .get_cl(cl_link) - .await? - .ok_or_else(|| MegaError::NotFound(format!("CL {cl_link} not found")))?; - - let reviewers = reviewer_stg.list_reviewers(cl_link).await?; - - let mut recipients: HashSet = HashSet::new(); - recipients.insert(cl.username); - for r in reviewers { - recipients.insert(r.username); - } - recipients.remove(actor_username); - - for username in recipients { - // should_send returns false if user settings are missing or globally disabled - if !notif_stg - .should_send(&username, EVENT_CL_COMMENT_CREATED) - .await? - { - continue; - } - - let settings = match notif_stg.get_user_settings(&username).await? { - Some(s) => s, - None => continue, - }; - - let subject = format!("New comment on CL {}", cl_link); - let body_text = format!( - "{} commented on {}: {}", - actor_username, cl_link, comment_text - ); - let body_html = format!( - "

{} commented on {}:

{}

", - actor_username, - cl_link, - escape_html(comment_text) - ); - - notif_stg - .enqueue_email_job( - &username, - &settings.email, - EVENT_CL_COMMENT_CREATED, - &subject, - &body_html, - Some(&body_text), - ) - .await?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use callisto::{email_jobs, mega_cl, mega_cl_reviewer}; - use jupiter::{ - migration::apply_migrations, - sea_orm::{ColumnTrait, EntityTrait, QueryFilter}, - storage::base_storage::{BaseStorage, StorageConnector}, - tests::test_db_connection, - }; - use tempfile::TempDir; - - use super::*; - - #[tokio::test] - async fn test_on_cl_comment_created_enqueues_jobs_for_author_and_reviewers() { - let dir = TempDir::new().unwrap(); - let db = test_db_connection(dir.path()).await; - apply_migrations(&db, true).await.unwrap(); - - let base = BaseStorage::new(Arc::new(db.clone())); - let notif = NotificationStorage::new(Arc::new(db.clone())); - let cl_stg = ClStorage { base: base.clone() }; - let reviewer_stg = ClReviewerStorage { base: base.clone() }; - - // Create CL (author = alice) - let now = chrono::Utc::now().naive_utc(); - mega_cl::ActiveModel { - id: Set(1), - link: Set("CL1".to_string()), - title: Set("t".to_string()), - merge_date: Set(None), - status: Set(callisto::sea_orm_active_enums::MergeStatusEnum::Open), - path: Set("/".to_string()), - from_hash: Set("a".to_string()), - to_hash: Set("b".to_string()), - created_at: Set(now), - updated_at: Set(now), - username: Set("alice".to_string()), - base_branch: Set("main".to_string()), - } - .insert(&db) - .await - .unwrap(); - - // Create reviewer bob - mega_cl_reviewer::ActiveModel { - id: Set(1), - cl_link: Set("CL1".to_string()), - username: Set("bob".to_string()), - approved: Set(false), - system_required: Set(false), - created_at: Set(now), - updated_at: Set(now), - } - .insert(&db) - .await - .unwrap(); - - // Only notify users who have settings rows - notif - .upsert_user_settings("alice", "alice@example.com") - .await - .unwrap(); - notif - .upsert_user_settings("bob", "bob@example.com") - .await - .unwrap(); - notif - .upsert_user_settings("carol", "carol@example.com") - .await - .unwrap(); - - // SUppose the actor is carol, should notify alice and bob but not carol - on_cl_comment_created(¬if, &cl_stg, &reviewer_stg, "carol", "CL1", "hello") - .await - .unwrap(); - - let jobs = email_jobs::Entity::find().all(&db).await.unwrap(); - assert_eq!(jobs.len(), 2); - - let alice_job = email_jobs::Entity::find() - .filter(email_jobs::Column::Username.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")) - .one(&db) - .await - .unwrap(); - assert!(bob_job.is_some()); - } - - #[tokio::test] - async fn test_on_cl_comment_created_respects_should_send() { - let dir = TempDir::new().unwrap(); - let db = test_db_connection(dir.path()).await; - apply_migrations(&db, true).await.unwrap(); - - let base = BaseStorage::new(Arc::new(db.clone())); - let notif = NotificationStorage::new(Arc::new(db.clone())); - let cl_stg = ClStorage { base: base.clone() }; - let reviewer_stg = ClReviewerStorage { base: base.clone() }; - let now = chrono::Utc::now().naive_utc(); - - mega_cl::ActiveModel { - id: Set(1), - link: Set("CL2".to_string()), - title: Set("t".to_string()), - merge_date: Set(None), - status: Set(callisto::sea_orm_active_enums::MergeStatusEnum::Open), - path: Set("/".to_string()), - from_hash: Set("a".to_string()), - to_hash: Set("b".to_string()), - created_at: Set(now), - updated_at: Set(now), - username: Set("alice".to_string()), - base_branch: Set("main".to_string()), - } - .insert(&db) - .await - .unwrap(); - - notif - .upsert_user_settings("alice", "alice@example.com") - .await - .unwrap(); - // disable globally - notif.set_global_enabled("alice", false).await.unwrap(); - - on_cl_comment_created(¬if, &cl_stg, &reviewer_stg, "bob", "CL2", "hello") - .await - .unwrap(); - - let jobs = email_jobs::Entity::find().all(&db).await.unwrap(); - assert_eq!(jobs.len(), 0); - } -} +pub use ceres::application::notification::{EVENT_CL_COMMENT_CREATED, on_cl_comment_created}; diff --git a/mono/src/server/http_server.rs b/mono/src/server/http_server.rs index c1d57bd38..b03a9eb97 100644 --- a/mono/src/server/http_server.rs +++ b/mono/src/server/http_server.rs @@ -11,11 +11,10 @@ use axum::{ routing::any, }; use ceres::{ - api_service::{cache::GitObjectCache, state::ProtocolApiState}, - application::artifact::ArtifactApplicationService, + application::{api_service::cache::GitObjectCache, artifact::ArtifactApplicationService}, + transport::ProtocolApiState, }; use common::errors::ProtocolError; -use context::AppContext; use http::{HeaderName, HeaderValue, Method}; use orion_client::OrionBuildClient; use saturn::entitystore::EntityStore; @@ -29,7 +28,7 @@ use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; use utoipa_swagger_ui::SwaggerUi; -use super::super::notification::EmailDispatcher; +use super::super::notification::{EmailDispatcher, EmailMailer}; use crate::{ api::{ MonoApiServiceState, @@ -42,7 +41,8 @@ use crate::{ }, router::lfs_router, }, - email::{Mailer, NoopMailer, SmtpMailer}, + bootstrap::AppContext, + email::{NoopMailer, SmtpMailer}, git_protocol::InfoRefsParams, server::{CommonHttpOptions, trace_context}, }; @@ -122,7 +122,7 @@ fn spawn_cleanup_task(ctx: AppContext, token: CancellationToken) -> Option JoinHandle<()> { // Build a mailer (Default to NoopMailer if config missing or invalid) let cfg = ctx.storage.config(); - let mailer: Arc = if let Some(mail_cfg) = &cfg.mail { + let mailer: Arc = if let Some(mail_cfg) = &cfg.mail { match SmtpMailer::new(mail_cfg) { Ok(m) => Arc::new(m), Err(e) => { @@ -384,9 +384,10 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router { prefix: "git-object-rkyv:v1".to_string(), }); - let api_state = MonoApiServiceState { - storage: storage.clone(), - session_store: Some(match oauth_config.api_store_backend { + let api_state = MonoApiServiceState::new( + storage.clone(), + git_object_cache, + Some(match oauth_config.api_store_backend { common::config::OauthApiStoreBackend::Campsite => { OAuthApiStore::Campsite(CampsiteApiStore::new(oauth_config.campsite_api_domain)) } @@ -394,11 +395,10 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router { OAuthApiStore::Tinyship(TinyshipApiStore::new(oauth_config.tinyship_api_domain)) } }), - listen_addr: format!("http://{host}:{port}"), - entity_store: EntityStore::new(), - git_object_cache, - orion_client: Arc::new(OrionBuildClient::new(storage.config().build.clone())), - }; + format!("http://{host}:{port}"), + EntityStore::new(), + Arc::new(OrionBuildClient::new(storage.config().build.clone())), + ); let origins: Vec = oauth_config .allowed_cors_origins @@ -432,8 +432,16 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router { "/{*path}", any({ let api_state = api_state.clone(); - move |req: Request| { - handle_smart_protocol(req, Arc::new(ProtocolApiState::from_ref(&api_state))) + move |req: Request| async move { + match handle_smart_protocol( + req, + Arc::new(ProtocolApiState::from_ref(&api_state)), + ) + .await + { + Ok(response) => response, + Err(err) => crate::git_protocol::protocol_error::into_response(err), + } } }), ) diff --git a/mono/src/server/ssh_server.rs b/mono/src/server/ssh_server.rs index bf4fce1b3..b2ca5e59d 100644 --- a/mono/src/server/ssh_server.rs +++ b/mono/src/server/ssh_server.rs @@ -1,9 +1,8 @@ use std::{collections::HashMap, net::SocketAddr, str::FromStr, sync::Arc}; use bytes::BytesMut; -use ceres::api_service::{cache::GitObjectCache, state::ProtocolApiState}; +use ceres::{application::api_service::cache::GitObjectCache, transport::ProtocolApiState}; use clap::Args; -use context::AppContext; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use russh::{ Preferred, @@ -13,7 +12,7 @@ use russh::{ use tokio::sync::Mutex; use vault::integration::vault_core::VaultCoreInterface; -use crate::{git_protocol::ssh::SshServer, server::CommonHttpOptions}; +use crate::{bootstrap::AppContext, git_protocol::ssh::SshServer, server::CommonHttpOptions}; #[derive(Args, Clone, Debug)] pub struct SshOptions { diff --git a/orion-server/Dockerfile b/orion-server/Dockerfile index 8a486d067..eeb58544d 100644 --- a/orion-server/Dockerfile +++ b/orion-server/Dockerfile @@ -29,10 +29,10 @@ COPY Cargo.lock ./ COPY api-model/Cargo.toml api-model/ COPY ceres/Cargo.toml ceres/ COPY common/Cargo.toml common/ -COPY context/Cargo.toml context/ COPY io-orbit/Cargo.toml io-orbit/ COPY jupiter/Cargo.toml jupiter/ COPY jupiter/callisto/Cargo.toml jupiter/callisto/ +COPY jupiter-migrate/Cargo.toml jupiter-migrate/ COPY mono/Cargo.toml mono/ COPY orion/Cargo.toml orion/ COPY orion/audit/Cargo.toml orion/audit/ diff --git a/vault/Cargo.toml b/vault/Cargo.toml index e5c31534b..7b1c5b614 100644 --- a/vault/Cargo.toml +++ b/vault/Cargo.toml @@ -8,7 +8,7 @@ name = "vault" path = "src/lib.rs" [dependencies] -jupiter = { workspace = true } +jupiter = { path = "../jupiter", default-features = false, features = ["migrate"] } common = { workspace = true } async-trait = { workspace = true }