diff --git a/build.rs b/build.rs index 7821ca5..db1ed43 100644 --- a/build.rs +++ b/build.rs @@ -1,29 +1,73 @@ -fn main() -> Result<(), Box> { - tonic_prost_build::configure() - .build_client(true) - .build_server(false) - .compile_protos(&["proto/email/email.proto"], &["proto/email"])?; +use std::fs; +use std::path::{Path, PathBuf}; +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); + let out_dir = PathBuf::from(std::env::var("OUT_DIR")?); + fs::create_dir_all(&out_dir)?; + let email_dir = manifest_dir.join("proto/email"); + let email_protos = proto_files(&email_dir)?; + for proto in &email_protos { + println!("cargo:rerun-if-changed={}", proto.display()); + } tonic_prost_build::configure() .build_client(true) .build_server(false) - .compile_protos( - &[ - "proto/git/oid.proto", - "proto/git/tagger.proto", - "proto/git/repository.proto", - "proto/git/commit.proto", - "proto/git/branch.proto", - "proto/git/tag.proto", - "proto/git/tree.proto", - "proto/git/diff.proto", - "proto/git/merge.proto", - "proto/git/blame.proto", - "proto/git/archive.proto", - "proto/git/pack.proto", - ], - &["proto/git"], - )?; + .out_dir(&out_dir) + .compile_protos(&email_protos, &[email_dir])?; + + let git_dir = manifest_dir.join("proto/git"); + let git_protos = proto_files(&git_dir)?; + for proto in &git_protos { + println!("cargo:rerun-if-changed={}", proto.display()); + } + tonic_prost_build::configure() + .build_client(true) + .build_server(false) + .type_attribute( + ".", + "#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]", + ) + .extern_path(".google.protobuf.Timestamp", "crate::pb::Timestamp") + .out_dir(&out_dir) + .compile_protos(&git_protos, &[git_dir])?; + + let this_dir = manifest_dir.join("proto/this"); + let this_protos = proto_files(&this_dir)?; + for proto in &this_protos { + println!("cargo:rerun-if-changed={}", proto.display()); + } + tonic_prost_build::configure() + .build_client(false) + .build_server(true) + .out_dir(&out_dir) + .compile_protos(&this_protos, &[this_dir])?; + + let im_dir = manifest_dir.join("proto/this/im"); + let im_protos = proto_files(&im_dir)?; + for proto in &im_protos { + println!("cargo:rerun-if-changed={}", proto.display()); + } + tonic_prost_build::configure() + .build_client(false) + .build_server(true) + .out_dir(&out_dir) + .compile_protos(&im_protos, &[im_dir])?; Ok(()) } + +fn proto_files(proto_dir: &Path) -> Result, Box> { + let mut files = fs::read_dir(proto_dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + + files.retain(|path| path.extension().is_some_and(|ext| ext == "proto")); + files.sort(); + + if files.is_empty() { + return Err(format!("no .proto files found in {}", proto_dir.display()).into()); + } + + Ok(files) +} diff --git a/cache/mod.rs b/cache/mod.rs index bc918ad..2db170f 100644 --- a/cache/mod.rs +++ b/cache/mod.rs @@ -18,10 +18,10 @@ pub struct AppCache { } impl AppCache { - pub fn from_config(config: &AppConfig) -> AppResult { + pub async fn from_config(config: &AppConfig) -> AppResult { let cap = config.lru_default_capacity()?; let ttl = Duration::from_secs(config.lru_default_ttl_secs()?); - let l2 = AppRedis::from_config(config)?; + let l2 = AppRedis::from_config(config).await?; let key_prefix = config.redis_key_prefix()?; Ok(Self { l1: LruTtlCache::new(cap, ttl), @@ -31,17 +31,18 @@ impl AppCache { }) } - pub fn get(&self, key: &str) -> Option { + pub async fn get(&self, key: &str) -> Option { if let Some(json) = self.l1.get(&key.to_string()) { return serde_json::from_str(&json).ok(); } let full_key = self.full_key(key); - let mut conn = self.l2.get_connection().ok()?; + let mut conn = self.l2.get_connection(); let json: String = Cmd::new() .arg("GET") .arg(&full_key) - .query::>(&mut *conn.inner_mut()) + .query_async::>(&mut conn) + .await .ok()??; let value: T = serde_json::from_str(&json).ok()?; @@ -49,46 +50,86 @@ impl AppCache { Some(value) } - pub fn set(&self, key: &str, value: &T, ttl: Option) -> AppResult<()> { + pub async fn get_l2_only(&self, key: &str) -> Option { + let full_key = self.full_key(key); + let mut conn = self.l2.get_connection(); + let json: String = Cmd::new() + .arg("GET") + .arg(&full_key) + .query_async::>(&mut conn) + .await + .ok()??; + + serde_json::from_str(&json).ok() + } + + pub async fn set( + &self, + key: &str, + value: &T, + ttl: Option, + ) -> AppResult<()> { let json = serde_json::to_string(value)?; let full_key = self.full_key(key); let ttl_duration = ttl.unwrap_or(self.default_ttl); let ttl_secs = ttl_duration.as_secs() as usize; - let mut conn = self.l2.get_connection()?; + let mut conn = self.l2.get_connection(); Cmd::new() .arg("SETEX") .arg(&full_key) .arg(ttl_secs) .arg(&json) - .query::<()>(&mut *conn.inner_mut())?; + .query_async::<()>(&mut conn) + .await?; self.l1.insert_with_ttl(key.to_string(), json, ttl_duration); Ok(()) } - pub fn delete(&self, key: &str) -> AppResult<()> { - self.l1.remove(&key.to_string()); + pub async fn set_l2_only( + &self, + key: &str, + value: &T, + ttl: Option, + ) -> AppResult<()> { + let json = serde_json::to_string(value)?; let full_key = self.full_key(key); - let mut conn = self.l2.get_connection()?; + let ttl_duration = ttl.unwrap_or(self.default_ttl); + let ttl_secs = ttl_duration.as_secs() as usize; + let mut conn = self.l2.get_connection(); Cmd::new() - .arg("DEL") + .arg("SETEX") .arg(&full_key) - .query::<()>(&mut *conn.inner_mut())?; + .arg(ttl_secs) + .arg(&json) + .query_async::<()>(&mut conn) + .await?; Ok(()) } - pub fn exists(&self, key: &str) -> bool { + pub async fn delete(&self, key: &str) -> AppResult<()> { + self.l1.remove(&key.to_string()); + let full_key = self.full_key(key); + let mut conn = self.l2.get_connection(); + Cmd::new() + .arg("DEL") + .arg(&full_key) + .query_async::<()>(&mut conn) + .await?; + Ok(()) + } + + pub async fn exists(&self, key: &str) -> bool { if self.l1.get(&key.to_string()).is_some() { return true; } let full_key = self.full_key(key); - if let Ok(mut conn) = self.l2.get_connection() { - return Cmd::new() - .arg("EXISTS") - .arg(&full_key) - .query(&mut *conn.inner_mut()) - .unwrap_or(false); - } - false + let mut conn = self.l2.get_connection(); + Cmd::new() + .arg("EXISTS") + .arg(&full_key) + .query_async::(&mut conn) + .await + .unwrap_or(false) } fn full_key(&self, key: &str) -> String { diff --git a/cache/redis.rs b/cache/redis.rs index e022d46..36f544d 100644 --- a/cache/redis.rs +++ b/cache/redis.rs @@ -1,15 +1,13 @@ use crate::config::AppConfig; -use crate::error::AppError; -use crate::error::AppResult; -use r2d2::Pool; +use crate::error::{AppError, AppResult}; +use futures_util::future::BoxFuture; use redis::cluster::ClusterClient; -use redis::{Client, ConnectionLike, RedisError}; -use std::time::Duration; +use redis::{Client, FromRedisValue}; #[derive(Clone)] enum RedisBackend { - Single(Pool), - Cluster(Pool), + Single(redis::aio::ConnectionManager), + Cluster(redis::cluster_async::ClusterConnection), } #[derive(Clone)] @@ -18,100 +16,87 @@ pub struct AppRedis { } impl AppRedis { - pub fn from_config(config: &AppConfig) -> AppResult { + pub async fn from_config(config: &AppConfig) -> AppResult { let backend = if config.redis_cluster_enabled()? { let nodes = config.redis_cluster_nodes()?; let cluster_client = ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::>())?; - let pool = Self::build_pool(config, cluster_client)?; - RedisBackend::Cluster(pool) + let conn = cluster_client.get_async_connection().await?; + RedisBackend::Cluster(conn) } else { let url = config .redis_url()? .ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?; let client = Client::open(url.as_str())?; - let pool = Self::build_pool(config, client)?; - RedisBackend::Single(pool) + let conn = client.get_connection_manager().await?; + RedisBackend::Single(conn) }; Ok(Self { backend }) } - fn build_pool(config: &AppConfig, manager: M) -> AppResult> { - let max_conn = config.redis_max_connections()?; - let min_conn = config.redis_min_connections()?; - let idle_timeout = config.redis_idle_timeout()?; - let conn_timeout = config.redis_connection_timeout()?; - - Ok(r2d2::Builder::new() - .max_size(max_conn) - .min_idle(Some(min_conn)) - .idle_timeout(Some(Duration::from_secs(idle_timeout))) - .connection_timeout(Duration::from_secs(conn_timeout)) - .build(manager)?) - } - - pub fn get_connection(&self) -> Result { + pub fn get_connection(&self) -> RedisConnection { match &self.backend { - RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single), - RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster), + RedisBackend::Single(cm) => RedisConnection::Single(cm.clone()), + RedisBackend::Cluster(cc) => RedisConnection::Cluster(cc.clone()), } } } -#[allow(clippy::large_enum_variant)] -pub enum PooledRedisConnection { - Single(r2d2::PooledConnection), - Cluster(r2d2::PooledConnection), +pub enum RedisConnection { + Single(redis::aio::ConnectionManager), + Cluster(redis::cluster_async::ClusterConnection), } -impl PooledRedisConnection { - pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike { +impl redis::aio::ConnectionLike for RedisConnection { + fn req_packed_command<'a>( + &'a mut self, + cmd: &'a redis::Cmd, + ) -> BoxFuture<'a, redis::RedisResult> { match self { - PooledRedisConnection::Single(conn) => conn, - PooledRedisConnection::Cluster(conn) => conn, - } - } -} - -impl ConnectionLike for PooledRedisConnection { - fn req_packed_command(&mut self, cmd: &[u8]) -> Result { - match self { - PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd), - PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd), + Self::Single(c) => Box::pin(c.req_packed_command(cmd)), + Self::Cluster(c) => Box::pin(c.req_packed_command(cmd)), } } - fn req_packed_commands( - &mut self, - cmd: &[u8], + fn req_packed_commands<'a>( + &'a mut self, + cmd: &'a redis::Pipeline, offset: usize, count: usize, - ) -> Result, RedisError> { + ) -> BoxFuture<'a, redis::RedisResult>> { match self { - PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count), - PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count), + Self::Single(c) => Box::pin(c.req_packed_commands(cmd, offset, count)), + Self::Cluster(c) => Box::pin(c.req_packed_commands(cmd, offset, count)), } } fn get_db(&self) -> i64 { match self { - PooledRedisConnection::Single(conn) => conn.get_db(), - PooledRedisConnection::Cluster(conn) => conn.get_db(), - } - } - - fn check_connection(&mut self) -> bool { - match self { - PooledRedisConnection::Single(conn) => conn.check_connection(), - PooledRedisConnection::Cluster(conn) => conn.check_connection(), - } - } - - fn is_open(&self) -> bool { - match self { - PooledRedisConnection::Single(conn) => conn.is_open(), - PooledRedisConnection::Cluster(conn) => conn.is_open(), + Self::Single(c) => c.get_db(), + Self::Cluster(c) => c.get_db(), + } + } +} + +impl RedisConnection { + pub async fn query_async( + &mut self, + cmd: &mut redis::Cmd, + ) -> redis::RedisResult { + match self { + Self::Single(c) => cmd.query_async(c).await, + Self::Cluster(c) => cmd.query_async(c).await, + } + } + + pub async fn query_pipeline_async( + &mut self, + pipe: &mut redis::Pipeline, + ) -> redis::RedisResult { + match self { + Self::Single(c) => pipe.query_async(c).await, + Self::Cluster(c) => pipe.query_async(c).await, } } } diff --git a/config/rpc.rs b/config/rpc.rs index 516aafa..4dda1ce 100644 --- a/config/rpc.rs +++ b/config/rpc.rs @@ -27,4 +27,8 @@ impl AppConfig { pub fn rpc_default_timeout_secs(&self) -> AppResult { self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10) } + + pub fn email_rpc_addr(&self) -> AppResult> { + self.get_env::("APP_EMAIL_RPC_ADDR") + } } diff --git a/docs/adr/000-adr-template.md b/docs/adr/000-adr-template.md new file mode 100644 index 0000000..153663f --- /dev/null +++ b/docs/adr/000-adr-template.md @@ -0,0 +1,50 @@ +# ADR-NNN: 标题 / Title + +## 状态 / Status + +**Accepted** | Superseded | Deprecated + +**日期 / Date**: YYYY-MM-DD + +## 背景 / Context + +描述问题背景和驱动因素。 + +Describe the context and driving factors. + +## 决策 / Decision + +描述做出的决策。 + +Describe the decision that was made. + +## 考虑的方案 / Considered Options + +1. **方案 A** — 描述 / Description +2. **方案 B** — 描述 / Description +3. **方案 C** — 描述 / Description + +## 后果 / Consequences + +### 正面 / Positive + +- 优点 1 / Advantage 1 +- 优点 2 / Advantage 2 + +### 负面 / Negative + +- 缺点 1 / Disadvantage 1 +- 缺点 2 / Disadvantage 2 + +### 风险 / Risks + +- 风险 1 / Risk 1 +- 风险 2 / Risk 2 + +## 相关决策 / Related Decisions + +- [ADR-XXX](xxx-related-decision.md) + +## 参考 / References + +- [链接 / Link](url) diff --git a/docs/adr/001-choice-of-web-framework.md b/docs/adr/001-choice-of-web-framework.md new file mode 100644 index 0000000..20091b7 --- /dev/null +++ b/docs/adr/001-choice-of-web-framework.md @@ -0,0 +1,48 @@ +# ADR-001: 选择 Actix-web 作为 Web 框架 / Choice of Actix-web as Web Framework + +## 状态 / Status + +**Accepted** + +**日期 / Date**: 2024-01-01 + +## 背景 / Context + +appks 是一个协作开发平台后端,需要一个高性能、可靠的 Rust Web 框架来处理 HTTP 请求、WebSocket 连接和中间件。 + +appks is a collaborative development platform backend that needs a high-performance, reliable Rust web framework for HTTP requests, WebSocket connections, and middleware. + +## 决策 / Decision + +选择 **Actix-web** 作为 Web 框架。 + +Chose **Actix-web** as the web framework. + +## 考虑的方案 / Considered Options + +1. **Actix-web** — 成熟的 actor 模型框架,性能优异 +2. **Axum** — Tokio 生态新兴框架,Tower 集成 +3. **Rocket** — 易用性优先的框架 + +## 后果 / Consequences + +### 正面 / Positive + +- 优异的性能表现 / Excellent performance +- 成熟的生态系统 / Mature ecosystem +- 良好的 WebSocket 支持 / Good WebSocket support +- 活跃的社区维护 / Active community maintenance + +### 负面 / Negative + +- 学习曲线较陡 / Steeper learning curve +- Actor 模型需要适应 / Actor model requires adaptation + +### 风险 / Risks + +- 框架版本升级可能带来 breaking changes / Framework upgrades may bring breaking changes + +## 参考 / References + +- [Actix-web 官方文档](https://actix.rs/) +- [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/) diff --git a/docs/adr/002-two-tier-caching.md b/docs/adr/002-two-tier-caching.md new file mode 100644 index 0000000..e310929 --- /dev/null +++ b/docs/adr/002-two-tier-caching.md @@ -0,0 +1,49 @@ +# ADR-002: 两级缓存架构 / Two-Tier Caching Architecture + +## 状态 / Status + +**Accepted** + +**日期 / Date**: 2024-01-01 + +## 背景 / Context + +平台需要缓存机制来减少数据库负载,提高响应速度。需要在性能和一致性之间取得平衡。 + +The platform needs a caching mechanism to reduce database load and improve response times. A balance between performance and consistency is required. + +## 决策 / Decision + +采用 **两级缓存架构**:L1 (内存 LRU-TTL) + L2 (Redis)。 + +Adopted **two-tier caching**: L1 (in-memory LRU-TTL) + L2 (Redis). + +## 考虑的方案 / Considered Options + +1. **纯 Redis** — 简单但网络延迟高 +2. **纯内存缓存** — 快但不跨实例共享 +3. **两级缓存** — 兼顾速度和共享 + +## 后果 / Consequences + +### 正面 / Positive + +- L1 提供极低延迟 / L1 provides ultra-low latency +- L2 提供跨实例共享 / L2 provides cross-instance sharing +- 减少数据库负载 / Reduces database load + +### 负面 / Negative + +- 缓存一致性复杂 / Cache consistency is complex +- 内存占用增加 / Increased memory usage + +### 风险 / Risks + +- 缓存雪崩 / Cache avalanche +- 缓存穿透 / Cache penetration + +## 实现细节 / Implementation Details + +- **L1**: `DashMap + Mutex`, TTL 5 分钟 +- **L2**: Redis via r2d2, TTL 可配置 +- **策略**: L1 miss → L2 miss → 数据库查询 diff --git a/docs/adr/003-nats-for-messaging.md b/docs/adr/003-nats-for-messaging.md new file mode 100644 index 0000000..bc0ff48 --- /dev/null +++ b/docs/adr/003-nats-for-messaging.md @@ -0,0 +1,45 @@ +# ADR-003: 使用 NATS JetStream 作为消息队列 / NATS JetStream for Messaging + +## 状态 / Status + +**Accepted** + +**日期 / Date**: 2024-01-01 + +## 背景 / Context + +平台的实时 IM 系统需要可靠的消息传递机制,支持发布/订阅模式和消息持久化。 + +The platform's real-time IM system needs reliable messaging with pub/sub patterns and message persistence. + +## 决策 / Decision + +使用 **NATS JetStream** 作为消息队列。 + +Use **NATS JetStream** as the message queue. + +## 考虑的方案 / Considered Options + +1. **NATS JetStream** — 轻量级、高性能 +2. **Apache Kafka** — 高吞吐但运维复杂 +3. **RabbitMQ** — 功能丰富但性能较低 + +## 后果 / Consequences + +### 正面 / Positive + +- 轻量级部署 / Lightweight deployment +- 高性能消息传递 / High-performance messaging +- 内置持久化 / Built-in persistence +- 良好的 Rust 客户端支持 / Good Rust client support + +### 负面 / Negative + +- 生态系统不如 Kafka 成熟 / Ecosystem less mature than Kafka +- 监控工具有限 / Limited monitoring tools + +## 实现细节 / Implementation Details + +- Publisher: 发布事件到 JetStream +- Subscriber: 订阅并处理事件 +- Stream prefix: 可配置的流前缀 diff --git a/docs/adr/004-etcd-for-discovery.md b/docs/adr/004-etcd-for-discovery.md new file mode 100644 index 0000000..a7dc7b0 --- /dev/null +++ b/docs/adr/004-etcd-for-discovery.md @@ -0,0 +1,46 @@ +# ADR-004: 使用 etcd 进行服务发现 / etcd for Service Discovery + +## 状态 / Status + +**Accepted** + +**日期 / Date**: 2024-01-01 + +## 背景 / Context + +平台依赖多个外部 gRPC 微服务(Git、Email),需要动态发现机制来连接这些服务。 + +The platform depends on multiple external gRPC microservices (Git, Email) and needs dynamic discovery to connect to them. + +## 决策 / Decision + +使用 **etcd** 进行服务发现和注册。 + +Use **etcd** for service discovery and registration. + +## 考虑的方案 / Considered Options + +1. **etcd** — Kubernetes 生态标准,强一致性 +2. **Consul** — 功能丰富但较重 +3. **ZooKeeper** — 经典但运维复杂 +4. **静态配置** — 简单但不灵活 + +## 后果 / Consequences + +### 正面 / Positive + +- 与 Kubernetes 生态一致 / Aligned with Kubernetes ecosystem +- 强一致性保证 / Strong consistency guarantees +- 租约机制支持健康检查 / Lease mechanism supports health checks +- Watch 机制支持实时更新 / Watch mechanism supports real-time updates + +### 负面 / Negative + +- 额外的基础设施依赖 / Additional infrastructure dependency +- 运维复杂度增加 / Increased operational complexity + +## 实现细节 / Implementation Details + +- **注册**: `register.rs` — 自注册 + 租约保活 +- **发现**: `discovery.rs` — Watch 动态连接 +- **客户端**: tonic/prost gRPC 客户端 diff --git a/docs/adr/005-error-handling-strategy.md b/docs/adr/005-error-handling-strategy.md new file mode 100644 index 0000000..b8ec13f --- /dev/null +++ b/docs/adr/005-error-handling-strategy.md @@ -0,0 +1,68 @@ +# ADR-005: 统一错误处理策略 / Unified Error Handling Strategy + +## 状态 / Status + +**Accepted** + +**日期 / Date**: 2024-01-01 + +## 背景 / Context + +平台需要统一的错误处理机制,确保错误信息对用户友好,同时保留足够的调试信息。 + +The platform needs a unified error handling mechanism that provides user-friendly error messages while retaining sufficient debugging information. + +## 决策 / Decision + +使用 **AppError 枚举 + AppResult 类型别名** 作为统一错误处理策略。 + +Use **AppError enum + AppResult type alias** as the unified error handling strategy. + +## 考虑的方案 / Considered Options + +1. **自定义枚举 (AppError)** — 类型安全、可扩展 +2. **anyhow** — 简单但类型信息丢失 +3. **thiserror + 手动实现** — 灵活但工作量大 + +## 后果 / Consequences + +### 正面 / Positive + +- 类型安全的错误处理 / Type-safe error handling +- 统一的错误响应格式 / Unified error response format +- 便于错误分类和监控 / Easy error classification and monitoring +- 与 actix-web 集成良好 / Good integration with actix-web + +### 负面 / Negative + +- 需要维护错误枚举 / Need to maintain error enum +- 新增错误类型需要更新枚举 / New error types require enum updates + +## 实现细节 / Implementation Details + +```rust +// AppError 定义 +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("User not found")] + UserNotFound, + #[error("Invalid password")] + InvalidPassword, + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + // ... +} + +// AppResult 类型别名 +pub type AppResult = Result; +``` + +## 错误码映射 / Error Code Mapping + +| Postgres Code | 含义 | HTTP Status | +|---|---|---| +| 23505 | 唯一约束违反 | 409 Conflict | +| 23503 | 外键约束违反 | 400 Bad Request | +| 23514 | 检查约束违反 | 400 Bad Request | +| 23502 | 非空约束违反 | 400 Bad Request | +| 23P01 | 排他约束违反 | 409 Conflict | diff --git a/error.rs b/error.rs index de1984f..fc58595 100644 --- a/error.rs +++ b/error.rs @@ -14,9 +14,6 @@ pub enum AppError { #[error("redis error: {0}")] Redis(#[from] redis::RedisError), - #[error("r2d2 error: {0}")] - R2d2(#[from] r2d2::Error), - #[error("json error: {0}")] Json(#[from] serde_json::Error), @@ -131,6 +128,7 @@ impl actix_web::ResponseError for AppError { | AppError::InvalidEmailCode | AppError::RsaDecodeError | AppError::RsaGenerationError => StatusCode::BAD_REQUEST, + AppError::Database(e) => db_error_status_code(e), AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR, } @@ -139,6 +137,7 @@ impl actix_web::ResponseError for AppError { fn error_response(&self) -> HttpResponse { let status = self.status_code(); let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR { + tracing::error!(?self, "internal server error"); "internal server error".to_string() } else { self.to_string() @@ -146,3 +145,25 @@ impl actix_web::ResponseError for AppError { HttpResponse::build(status).json(serde_json::json!({ "error": message })) } } + +fn db_error_status_code(e: &sqlx::Error) -> actix_web::http::StatusCode { + use actix_web::http::StatusCode; + match e { + sqlx::Error::Database(db_err) => { + match db_err.code().as_ref().map(|c| c.as_ref()) { + // unique_violation + Some("23505") => StatusCode::CONFLICT, + // foreign_key_violation + Some("23503") => StatusCode::CONFLICT, + // check_violation + Some("23514") => StatusCode::BAD_REQUEST, + // not_null_violation + Some("23502") => StatusCode::BAD_REQUEST, + // exclusion_violation + Some("23P01") => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} diff --git a/etcd/discovery.rs b/etcd/discovery.rs index e63771c..b7a3a87 100644 --- a/etcd/discovery.rs +++ b/etcd/discovery.rs @@ -5,18 +5,151 @@ use uuid::Uuid; use crate::error::{AppError, AppResult}; use crate::pb::{EmailClient, RepoClient}; -use super::types::ServiceInstance; -use super::{EtcdRegistry, EtcdRegistryInner}; +use super::types::{GitksPeerInfo, ServiceInstance}; +use super::{EtcdRegistry, EtcdRegistryInner, storage_name_to_uuid}; + +/// etcd prefix where gitks nodes register themselves (gitks::cluster::ClusterManager). +const GITKS_NODES_PREFIX: &str = "/gitks/nodes/"; impl EtcdRegistry { pub async fn start_discovery(&self) -> AppResult<()> { - self.load_initial("git").await?; - self.load_initial("mail").await?; - self.spawn_watch("git"); - self.spawn_watch("mail"); + // Discover gitks nodes from gitks's own etcd prefix. + self.load_gitks_nodes().await?; + self.spawn_gitks_watch(); + + // Discover mail services from appks's service prefix. + if self.email_client.is_none() { + self.load_initial("mail").await?; + self.spawn_watch("mail"); + } Ok(()) } + // Section: gitks node discovery (from /gitks/nodes/) + + async fn load_gitks_nodes(&self) -> AppResult<()> { + let resp = { + let mut client = self.inner.client.lock().await; + client + .get(GITKS_NODES_PREFIX, Some(GetOptions::new().with_prefix())) + .await + .map_err(|e| { + AppError::Config(format!("etcd get {GITKS_NODES_PREFIX} failed: {e}")) + })? + }; + + for kv in resp.kvs() { + let key = kv.key_str().unwrap_or_default(); + let value = kv.value_str().unwrap_or_default(); + if let Ok(peer) = serde_json::from_str::(value) { + Self::upsert_gitks_node(&self.inner, key, &peer); + } else { + tracing::warn!(key = key, "failed to parse gitks peer info from etcd"); + } + } + + tracing::info!( + prefix = GITKS_NODES_PREFIX, + count = self.inner.git_nodes.len(), + "gitks node discovery complete" + ); + Ok(()) + } + + fn spawn_gitks_watch(&self) { + let inner = self.inner.clone(); + + tokio::spawn(async move { + loop { + match Self::gitks_watch_loop(&inner).await { + Ok(()) => break, + Err(e) => { + tracing::warn!(error = %e, "gitks etcd watch disconnected, retrying in 3s"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + } + } + }); + } + + async fn gitks_watch_loop(inner: &EtcdRegistryInner) -> AppResult<()> { + let mut stream = { + let mut client = inner.client.lock().await; + client + .watch(GITKS_NODES_PREFIX, Some(WatchOptions::new().with_prefix())) + .await + .map_err(|e| { + AppError::Config(format!("etcd watch {GITKS_NODES_PREFIX} failed: {e}")) + })? + }; + + while let Some(resp) = stream.next().await { + let resp = + resp.map_err(|e| AppError::Config(format!("gitks watch stream error: {e}")))?; + + for event in resp.events() { + let Some(kv) = event.kv() else { continue }; + let key = kv.key_str().unwrap_or_default(); + + match event.event_type() { + etcd_client::EventType::Put => { + let value = kv.value_str().unwrap_or_default(); + if let Ok(peer) = serde_json::from_str::(value) { + Self::upsert_gitks_node(inner, key, &peer); + tracing::info!( + storage_name = %peer.storage_name, + grpc_addr = %peer.grpc_addr, + "gitks node upserted" + ); + } + } + etcd_client::EventType::Delete => { + let storage_name = key.strip_prefix(GITKS_NODES_PREFIX).unwrap_or(&key); + let node_id = storage_name_to_uuid(storage_name); + inner.git_nodes.remove(&node_id); + tracing::info!(storage_name = storage_name, "gitks node removed"); + } + } + } + } + Ok(()) + } + + fn upsert_gitks_node(inner: &EtcdRegistryInner, key: &str, peer: &GitksPeerInfo) { + let node_id = storage_name_to_uuid(&peer.storage_name); + + if peer.grpc_addr.is_empty() { + tracing::warn!( + storage_name = %peer.storage_name, + key = key, + "gitks peer has empty grpc_addr, skipping" + ); + return; + } + + match RepoClient::lazy_connect(&peer.grpc_addr) { + Ok(client) => { + inner.git_nodes.insert(node_id, client); + tracing::debug!( + storage_name = %peer.storage_name, + node_id = %node_id, + grpc_addr = %peer.grpc_addr, + "gitks node connected" + ); + } + Err(e) => { + tracing::error!( + storage_name = %peer.storage_name, + grpc_addr = %peer.grpc_addr, + error = %e, + "gitks node connect failed" + ); + } + } + } + + // Section: mail service discovery (from appks's own etcd prefix) + async fn load_initial(&self, service: &str) -> AppResult<()> { let prefix = self.service_prefix(service); let resp = { @@ -38,7 +171,7 @@ impl EtcdRegistry { tracing::info!( service = service, prefix = prefix.as_str(), - "etcd initial discovery complete" + "etcd mail discovery complete" ); Ok(()) } @@ -66,7 +199,7 @@ impl EtcdRegistry { } async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> { - let (mut watcher, mut stream) = { + let mut stream = { let mut client = inner.client.lock().await; client .watch(prefix, Some(WatchOptions::new().with_prefix())) @@ -74,8 +207,6 @@ impl EtcdRegistry { .map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))? }; - let _keep = &mut watcher; - while let Some(resp) = stream.next().await { let resp = resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?; @@ -89,12 +220,12 @@ impl EtcdRegistry { let value = kv.value_str().unwrap_or_default(); if let Ok(instance) = serde_json::from_str::(value) { Self::upsert_instance(inner, service, key, &instance); - tracing::info!(service = service, key = key, "etcd service upserted"); + tracing::info!(service = service, key = key, "mail service upserted"); } } etcd_client::EventType::Delete => { Self::remove_instance(inner, service, key); - tracing::info!(service = service, key = key, "etcd service removed"); + tracing::info!(service = service, key = key, "mail service removed"); } } } @@ -123,20 +254,17 @@ impl EtcdRegistry { let addr = instance.addr.clone(); match service { - "git" => match RepoClient::lazy_connect(&addr) { - Ok(client) => { - inner.git_nodes.insert(node_id, client); - } - Err(e) => { - tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed"); - } - }, "mail" => match EmailClient::lazy_connect(&addr) { Ok(client) => { inner.mail_nodes.insert(node_id, client); } Err(e) => { - tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed"); + tracing::error!( + key = key, + addr = addr.as_str(), + error = %e, + "mail client connect failed" + ); } }, _ => {} @@ -148,9 +276,6 @@ impl EtcdRegistry { return; }; match service { - "git" => { - inner.git_nodes.remove(&node_id); - } "mail" => { inner.mail_nodes.remove(&node_id); } diff --git a/etcd/mod.rs b/etcd/mod.rs index 6934c0c..b8ef8a8 100644 --- a/etcd/mod.rs +++ b/etcd/mod.rs @@ -2,7 +2,7 @@ mod discovery; mod register; mod types; -pub use types::ServiceInstance; +pub use types::{GitksPeerInfo, ServiceInstance}; use std::sync::Arc; use std::sync::atomic::AtomicI64; @@ -19,6 +19,7 @@ use crate::pb::{EmailClient, RepoClient}; #[derive(Clone)] pub struct EtcdRegistry { pub(crate) inner: Arc, + email_client: Option, } pub(crate) struct EtcdRegistryInner { @@ -38,7 +39,7 @@ impl EtcdRegistry { let opts = etcd_client::ConnectOptions::new() .with_connect_timeout(std::time::Duration::from_secs(timeout)); - let client = Client::connect(&endpoints, Some(opts)) + let client = Client::connect(endpoints, Some(opts)) .await .map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?; @@ -54,6 +55,25 @@ impl EtcdRegistry { let key_prefix = config.etcd_key_prefix()?; + let email_client = match config.email_rpc_addr()? { + Some(addr) if !addr.is_empty() => match EmailClient::lazy_connect(&addr) { + Ok(client) => { + tracing::info!(addr = %addr, "email client connected via APP_EMAIL_RPC_ADDR"); + Some(client) + } + Err(e) => { + tracing::error!(addr = %addr, error = %e, "email client connect via APP_EMAIL_RPC_ADDR failed"); + None + } + }, + _ => { + tracing::info!( + "APP_EMAIL_RPC_ADDR not set, will fall back to etcd discovery for email" + ); + None + } + }; + Ok(Self { inner: Arc::new(EtcdRegistryInner { client: Mutex::new(client), @@ -63,6 +83,7 @@ impl EtcdRegistry { mail_nodes: DashMap::new(), lease_id: AtomicI64::new(0), }), + email_client, }) } @@ -75,6 +96,9 @@ impl EtcdRegistry { } pub fn get_email_client(&self) -> Option { + if let Some(ref client) = self.email_client { + return Some(client.clone()); + } self.inner .mail_nodes .iter() @@ -85,4 +109,18 @@ impl EtcdRegistry { pub fn has_git_nodes(&self) -> bool { !self.inner.git_nodes.is_empty() } + + /// Sort available gitks node UUIDs for deterministic selection. + pub fn git_node_ids_sorted(&self) -> Vec { + let mut ids: Vec = self.git_node_ids(); + ids.sort(); + ids + } +} + +/// Derive a deterministic UUID from a gitks storage_name. +/// Uses UUID v5 with DNS namespace so the same storage_name always +/// maps to the same UUID across all appks instances. +pub fn storage_name_to_uuid(storage_name: &str) -> Uuid { + Uuid::new_v5(&Uuid::NAMESPACE_DNS, storage_name.as_bytes()) } diff --git a/etcd/types.rs b/etcd/types.rs index f26a6e2..26d0161 100644 --- a/etcd/types.rs +++ b/etcd/types.rs @@ -8,3 +8,19 @@ pub struct ServiceInstance { #[serde(default)] pub metadata: HashMap, } + +/// Information about a gitks peer node, registered in etcd under /gitks/nodes/. +/// Mirrors gitks::cluster::types::PeerInfo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitksPeerInfo { + /// Logical storage name (e.g. "node-a", "default") + pub storage_name: String, + /// ractor_cluster TCP address (e.g. "10.0.1.4:4697") + #[serde(default)] + pub cluster_addr: String, + /// gRPC service address (e.g. "http://10.0.1.4:50051") + pub grpc_addr: String, + /// Software version + #[serde(default)] + pub version: String, +} diff --git a/gen_openapi.rs b/gen_openapi.rs index dc24712..2802e0e 100644 --- a/gen_openapi.rs +++ b/gen_openapi.rs @@ -2,10 +2,13 @@ use appks::api::openapi::OpenApiDoc; use utoipa::OpenApi; fn main() { + println!("Generating OpenAPI documentation..."); let json = OpenApiDoc::openapi().to_pretty_json(); if let Ok(json) = json { if let Err(e) = std::fs::write("openapi.json", json) { - eprintln!("{}", e); + println!("Failed to write OpenAPI documentation. {}", e); + } else { + println!("OpenAPI documentation generated successfully."); } } } diff --git a/grpc/auth.rs b/grpc/auth.rs new file mode 100644 index 0000000..06620a6 --- /dev/null +++ b/grpc/auth.rs @@ -0,0 +1,53 @@ +use tonic::{Request, Response, Status}; + +use crate::pb::im::internal_auth_service_server::InternalAuthService as InternalAuthServiceTrait; +use crate::pb::im::{AuthenticateRequest, AuthenticateResponse}; +use crate::service::internal_auth::InternalAuthService; + +pub struct InternalAuthGrpcService { + service: InternalAuthService, +} + +impl InternalAuthGrpcService { + pub fn new(service: InternalAuthService) -> Self { + Self { service } + } +} + +#[tonic::async_trait] +impl InternalAuthServiceTrait for InternalAuthGrpcService { + async fn authenticate( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + if req.api_key.is_empty() { + return Ok(Response::new(AuthenticateResponse { + authenticated: false, + service_name: String::new(), + service_id: String::new(), + scopes: vec![], + expires_at: 0, + })); + } + + match self.service.verify_api_key(&req.api_key).await { + Ok(Some(identity)) => Ok(Response::new(AuthenticateResponse { + authenticated: true, + service_name: identity.service_name, + service_id: identity.service_id, + scopes: identity.scopes, + expires_at: identity.expires_at, + })), + Ok(None) => Ok(Response::new(AuthenticateResponse { + authenticated: false, + service_name: String::new(), + service_id: String::new(), + scopes: vec![], + expires_at: 0, + })), + Err(e) => Err(Status::internal(format!("auth verification failed: {e}"))), + } + } +} diff --git a/grpc/channel.rs b/grpc/channel.rs new file mode 100644 index 0000000..43d8fd3 --- /dev/null +++ b/grpc/channel.rs @@ -0,0 +1,443 @@ +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::models::channels::ChannelStats; +use crate::models::common::{ChannelKind, ChannelType, Visibility}; +use crate::models::workspaces::Workspace; +use crate::pb::im::channel_service_server::ChannelService; +use crate::pb::im::{ + CreateCategoryRequest, CreateCategoryResponse, CreateChannelRequest, CreateChannelResponse, + DeleteCategoryRequest, DeleteCategoryResponse, DeleteChannelRequest, DeleteChannelResponse, + GetChannelRequest, GetChannelResponse, GetChannelStatsRequest, GetChannelStatsResponse, + ListCategoriesRequest, ListCategoriesResponse, ListChannelsRequest, ListChannelsResponse, + UpdateCategoryRequest, UpdateCategoryResponse, UpdateChannelRequest, UpdateChannelResponse, +}; +use crate::service::im::categories::{CreateCategoryParams, UpdateCategoryParams}; +use crate::service::im::channels::{ChannelListFilters, CreateChannelParams, UpdateChannelParams}; +use crate::service::im::session::ImSession; +use crate::service::AppService; + +pub struct ChannelGrpcService { + service: AppService, +} + +impl ChannelGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } + + fn system_session() -> ImSession { + ImSession::new(Uuid::nil()) + } + + fn parse_uuid(s: &str, field: &str) -> Result { + Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}"))) + } + + async fn resolve_workspace_name(&self, workspace_id: Uuid) -> Result { + Workspace::find_by_id(self.service.ctx.db.reader(), workspace_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .map(|w| w.name) + .ok_or_else(|| Status::not_found("workspace not found")) + } + + fn to_proto_timestamp(dt: chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + } + } + + fn model_channel_to_proto(c: crate::models::channels::Channel) -> crate::pb::im::Channel { + let channel_type = match c.channel_type { + ChannelType::Public => crate::pb::im::ChannelType::Public, + ChannelType::Private => crate::pb::im::ChannelType::Private, + ChannelType::Direct => crate::pb::im::ChannelType::Direct, + ChannelType::Group => crate::pb::im::ChannelType::Group, + ChannelType::Repo => crate::pb::im::ChannelType::Repo, + ChannelType::System => crate::pb::im::ChannelType::System, + ChannelType::Unknown => crate::pb::im::ChannelType::Unspecified, + }; + + let channel_kind = match c.channel_kind { + ChannelKind::Text => crate::pb::im::ChannelKind::Text, + ChannelKind::Voice => crate::pb::im::ChannelKind::Voice, + ChannelKind::Stage => crate::pb::im::ChannelKind::Stage, + ChannelKind::Forum => crate::pb::im::ChannelKind::Forum, + ChannelKind::Announcement => crate::pb::im::ChannelKind::Announcement, + ChannelKind::Unknown => crate::pb::im::ChannelKind::Unspecified, + }; + + let visibility = match c.visibility { + Visibility::Public => crate::pb::im::Visibility::Public, + Visibility::Private => crate::pb::im::Visibility::Private, + Visibility::Internal => crate::pb::im::Visibility::Internal, + Visibility::Workspace => crate::pb::im::Visibility::Workspace, + Visibility::Protected => crate::pb::im::Visibility::Protected, + Visibility::Hidden => crate::pb::im::Visibility::Hidden, + Visibility::Secret => crate::pb::im::Visibility::Secret, + Visibility::Unknown => crate::pb::im::Visibility::Unspecified, + }; + + crate::pb::im::Channel { + id: c.id.to_string(), + workspace_id: c.workspace_id.to_string(), + category_id: c.category_id.map(|id| id.to_string()), + parent_channel_id: c.parent_channel_id.map(|id| id.to_string()), + name: c.name, + topic: c.topic, + description: c.description, + channel_type: channel_type.into(), + channel_kind: channel_kind.into(), + visibility: visibility.into(), + position: c.position.unwrap_or(0), + nsfw: c.nsfw, + read_only: c.read_only, + archived: c.archived, + created_by: Some(c.created_by.to_string()), + rate_limit_per_user: c.rate_limit_per_user, + archived_at: c.archived_at.map(Self::to_proto_timestamp), + last_message_id: c.last_message_id.map(|id| id.to_string()), + last_message_at: c.last_message_at.map(Self::to_proto_timestamp), + created_at: Some(Self::to_proto_timestamp(c.created_at)), + updated_at: Some(Self::to_proto_timestamp(c.updated_at)), + } + } + + fn model_category_to_proto( + c: crate::models::channels::ChannelCategory, + ) -> crate::pb::im::ChannelCategory { + crate::pb::im::ChannelCategory { + id: c.id.to_string(), + workspace_id: c.workspace_id.to_string(), + name: c.name, + position: c.position, + collapsed: c.collapsed, + created_at: Some(Self::to_proto_timestamp(c.created_at)), + updated_at: Some(Self::to_proto_timestamp(c.updated_at)), + } + } + + fn model_stats_to_proto(s: ChannelStats) -> crate::pb::im::ChannelStats { + crate::pb::im::ChannelStats { + channel_id: s.channel_id.to_string(), + members_count: s.members_count as i32, + messages_count: s.messages_count as i32, + threads_count: s.threads_count as i32, + reactions_count: s.reactions_count as i32, + mentions_count: s.mentions_count as i32, + files_count: s.files_count as i32, + last_activity_at: s.last_activity_at.map(Self::to_proto_timestamp), + updated_at: Some(Self::to_proto_timestamp(s.updated_at)), + } + } + + async fn resolve_category_workspace(&self, category_id: Uuid) -> Result { + let workspace_id: Uuid = sqlx::query_scalar( + "SELECT workspace_id FROM channel_category WHERE id = $1", + ) + .bind(category_id) + .fetch_optional(self.service.ctx.db.reader()) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("category not found"))?; + + self.resolve_workspace_name(workspace_id).await + } +} + +#[tonic::async_trait] +impl ChannelService for ChannelGrpcService { + async fn get_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let session = Self::system_session(); + + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let wk_name = self.resolve_workspace_name(channel.workspace_id).await?; + let channel = self + .service + .im + .channel_get(&session, &wk_name, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(GetChannelResponse { + channel: Some(Self::model_channel_to_proto(channel)), + })) + } + + async fn list_channels( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let session = Self::system_session(); + + let channel_type = req.channel_type() + .as_str_name() + .strip_prefix("CHANNEL_TYPE_") + .map(|s| s.to_lowercase()) + .filter(|s| s != "unspecified"); + let channel_kind = req.channel_kind() + .as_str_name() + .strip_prefix("CHANNEL_KIND_") + .map(|s| s.to_lowercase()) + .filter(|s| s != "unspecified"); + + let filters = ChannelListFilters { + channel_type, + channel_kind, + category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()), + archived: None, + }; + + let channels = self + .service + .im + .channel_list( + &session, + &req.workspace_name, + filters, + req.limit as i64, + req.offset as i64, + ) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let total = channels.len() as i32; + let proto_channels: Vec<_> = channels + .into_iter() + .map(Self::model_channel_to_proto) + .collect(); + + Ok(Response::new(ListChannelsResponse { + channels: proto_channels, + total, + })) + } + + async fn create_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let session = Self::system_session(); + + let params = CreateChannelParams { + name: req.name, + topic: req.topic, + description: req.description, + channel_type: req.channel_type, + channel_kind: req.channel_kind, + visibility: req.visibility, + category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()), + parent_channel_id: req.parent_channel_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()), + nsfw: None, + rate_limit_per_user: req.rate_limit_per_user, + }; + + let channel = self + .service + .im + .channel_create(&session, &req.workspace_name, params, Uuid::nil()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateChannelResponse { + channel: Some(Self::model_channel_to_proto(channel)), + })) + } + + async fn update_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let session = Self::system_session(); + + let existing = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + let wk_name = self.resolve_workspace_name(existing.workspace_id).await?; + + let params = UpdateChannelParams { + name: req.name, + topic: req.topic, + description: req.description, + visibility: req.visibility, + category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()), + position: req.position, + nsfw: req.nsfw, + rate_limit_per_user: req.rate_limit_per_user, + archived: req.archived, + read_only: req.read_only, + }; + + let channel = self + .service + .im + .channel_update(&session, &wk_name, channel_id, params, Uuid::nil()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateChannelResponse { + channel: Some(Self::model_channel_to_proto(channel)), + })) + } + + async fn delete_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let session = Self::system_session(); + + let existing = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + let wk_name = self.resolve_workspace_name(existing.workspace_id).await?; + + self.service + .im + .channel_delete(&session, &wk_name, channel_id, Uuid::nil()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteChannelResponse {})) + } + + async fn get_channel_stats( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + + let stats = sqlx::query_as::<_, ChannelStats>( + "SELECT * FROM channel_stats WHERE channel_id = $1", + ) + .bind(channel_id) + .fetch_optional(self.service.ctx.db.reader()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + match stats { + Some(s) => Ok(Response::new(GetChannelStatsResponse { + stats: Some(Self::model_stats_to_proto(s)), + })), + None => Err(Status::not_found("Channel stats not found")), + } + } + + async fn list_categories( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let session = Self::system_session(); + + let categories = self + .service + .im + .category_list(&session, &req.workspace_name) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_categories: Vec<_> = categories + .into_iter() + .map(Self::model_category_to_proto) + .collect(); + + Ok(Response::new(ListCategoriesResponse { + categories: proto_categories, + })) + } + + async fn create_category( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let session = Self::system_session(); + + let params = CreateCategoryParams { + name: req.name, + position: req.position, + }; + + let category = self + .service + .im + .category_create(&session, &req.workspace_name, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateCategoryResponse { + category: Some(Self::model_category_to_proto(category)), + })) + } + + async fn update_category( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let category_id = Self::parse_uuid(&req.category_id, "category_id")?; + let session = Self::system_session(); + let wk_name = self.resolve_category_workspace(category_id).await?; + + let params = UpdateCategoryParams { + name: req.name, + position: req.position, + collapsed: req.collapsed, + }; + + let category = self + .service + .im + .category_update(&session, &wk_name, category_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateCategoryResponse { + category: Some(Self::model_category_to_proto(category)), + })) + } + + async fn delete_category( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let category_id = Self::parse_uuid(&req.category_id, "category_id")?; + let session = Self::system_session(); + let wk_name = self.resolve_category_workspace(category_id).await?; + + self.service + .im + .category_delete(&session, &wk_name, category_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteCategoryResponse {})) + } +} diff --git a/grpc/channel_settings.rs b/grpc/channel_settings.rs new file mode 100644 index 0000000..9154935 --- /dev/null +++ b/grpc/channel_settings.rs @@ -0,0 +1,1282 @@ +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::pb::im::channel_audit_service_server::ChannelAuditService; +use crate::pb::im::channel_invitation_service_server::ChannelInvitationService; +use crate::pb::im::channel_repo_link_service_server::ChannelRepoLinkService; +use crate::pb::im::channel_role_service_server::ChannelRoleService; +use crate::pb::im::channel_slash_command_service_server::ChannelSlashCommandService; +use crate::pb::im::channel_webhook_service_server::ChannelWebhookService; +use crate::pb::im::custom_emoji_service_server::CustomEmojiService; +use crate::pb::im::forum_tag_service_server::ForumTagService; +use crate::pb::im::im_integration_service_server::ImIntegrationService; +use crate::pb::im::stage_service_server::StageService; +use crate::pb::im::voice_service_server::VoiceService; +use crate::pb::im::{ + AcceptInvitationRequest, AcceptInvitationResponse, CreateChannelRoleRequest, + CreateChannelRoleResponse, CreateCustomEmojiRequest, CreateCustomEmojiResponse, + CreateForumTagRequest, CreateForumTagResponse, CreateIntegrationRequest, + CreateIntegrationResponse, CreateInvitationRequest, CreateInvitationResponse, + CreateRepoLinkRequest, CreateRepoLinkResponse, CreateSlashCommandRequest, + CreateSlashCommandResponse, CreateStageRequest, CreateStageResponse, CreateWebhookRequest, + CreateWebhookResponse, DeleteChannelRoleRequest, DeleteChannelRoleResponse, + DeleteCustomEmojiRequest, DeleteCustomEmojiResponse, DeleteForumTagRequest, + DeleteForumTagResponse, DeleteIntegrationRequest, DeleteIntegrationResponse, + DeleteRepoLinkRequest, DeleteRepoLinkResponse, DeleteSlashCommandRequest, + DeleteSlashCommandResponse, DeleteStageRequest, DeleteStageResponse, DeleteWebhookRequest, + DeleteWebhookResponse, GetStageRequest, GetStageResponse, ListChannelEventsRequest, + ListChannelEventsResponse, ListChannelRolesRequest, ListChannelRolesResponse, + ListCustomEmojisRequest, ListCustomEmojisResponse, ListForumTagsRequest, + ListForumTagsResponse, ListIntegrationsRequest, ListIntegrationsResponse, + ListInvitationsRequest, ListInvitationsResponse, ListRepoLinksRequest, + ListRepoLinksResponse, ListSlashCommandsRequest, ListSlashCommandsResponse, + ListVoiceParticipantsRequest, ListVoiceParticipantsResponse, ListWebhooksRequest, + ListWebhooksResponse, RevokeInvitationRequest, RevokeInvitationResponse, + UpdateChannelRoleRequest, UpdateChannelRoleResponse, UpdateForumTagRequest, + UpdateForumTagResponse, UpdateIntegrationRequest, UpdateIntegrationResponse, + UpdateSlashCommandRequest, UpdateSlashCommandResponse, UpdateStageRequest, + UpdateStageResponse, UpdateVoiceStateRequest, UpdateVoiceStateResponse, + UpdateWebhookRequest, UpdateWebhookResponse, +}; +use crate::service::im::channel_roles::{CreateChannelRoleParams, UpdateChannelRoleParams}; +use crate::service::im::custom_emojis::CreateCustomEmojiParams; +use crate::service::im::forum_tags::{CreateForumTagParams, UpdateForumTagParams}; +use crate::service::im::integrations::{CreateIntegrationParams, UpdateIntegrationParams}; +use crate::service::im::invitations::CreateInvitationParams; +use crate::service::im::repo_links::CreateRepoLinkParams; +use crate::service::im::session::ImSession; +use crate::service::im::slash_commands::{CreateSlashCommandParams, UpdateSlashCommandParams}; +use crate::service::im::stages::{CreateStageParams, UpdateStageParams}; +use crate::service::im::voice::UpdateVoiceStateParams; +use crate::service::im::webhooks::{CreateWebhookParams, UpdateWebhookParams}; +use crate::service::AppService; + +fn to_proto_ts(dt: chrono::DateTime) -> Option { + Some(prost_types::Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + }) +} + +fn parse_uuid(s: &str, field: &str) -> Result { + Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}"))) +} + +fn system_session() -> ImSession { + ImSession::new(Uuid::nil()) +} + +// Section: ChannelRoleGrpcService + +pub struct ChannelRoleGrpcService { + service: AppService, +} + +impl ChannelRoleGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +#[tonic::async_trait] +impl ChannelRoleService for ChannelRoleGrpcService { + async fn list_channel_roles( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let roles = self + .service + .im + .channel_role_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_roles = roles + .into_iter() + .map(|r| crate::pb::im::ChannelRole { + id: r.id.to_string(), + channel_id: r.channel_id.to_string(), + name: r.name, + permissions: r.permissions.iter().map(|p| p.to_string()).collect(), + assignable: r.assignable, + created_at: to_proto_ts(r.created_at), + updated_at: to_proto_ts(r.updated_at), + }) + .collect(); + + Ok(Response::new(ListChannelRolesResponse { + roles: proto_roles, + })) + } + + async fn create_channel_role( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateChannelRoleParams { + name: req.name, + description: None, + permissions: req.permissions, + assignable: req.assignable, + }; + + let r = self + .service + .im + .channel_role_create(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateChannelRoleResponse { + role: Some(crate::pb::im::ChannelRole { + id: r.id.to_string(), + channel_id: r.channel_id.to_string(), + name: r.name, + permissions: r.permissions.iter().map(|p| p.to_string()).collect(), + assignable: r.assignable, + created_at: to_proto_ts(r.created_at), + updated_at: to_proto_ts(r.updated_at), + }), + })) + } + + async fn update_channel_role( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let role_id = parse_uuid(&req.role_id, "role_id")?; + let session = system_session(); + + let params = UpdateChannelRoleParams { + name: req.name, + description: None, + permissions: if req.permissions.is_empty() { + None + } else { + Some(req.permissions) + }, + assignable: req.assignable, + }; + + let r = self + .service + .im + .channel_role_update(&session, role_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateChannelRoleResponse { + role: Some(crate::pb::im::ChannelRole { + id: r.id.to_string(), + channel_id: r.channel_id.to_string(), + name: r.name, + permissions: r.permissions.iter().map(|p| p.to_string()).collect(), + assignable: r.assignable, + created_at: to_proto_ts(r.created_at), + updated_at: to_proto_ts(r.updated_at), + }), + })) + } + + async fn delete_channel_role( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let role_id = parse_uuid(&req.role_id, "role_id")?; + let session = system_session(); + + self.service + .im + .channel_role_delete(&session, role_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteChannelRoleResponse {})) + } +} + +// Section: ChannelInvitationGrpcService + +pub struct ChannelInvitationGrpcService { + service: AppService, +} + +impl ChannelInvitationGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn invitation_to_proto(inv: crate::models::channels::ChannelInvitation) -> crate::pb::im::ChannelInvitation { + let status = if inv.accepted_at.is_some() { + "accepted" + } else if inv.revoked_at.is_some() { + "revoked" + } else { + "pending" + }; + + crate::pb::im::ChannelInvitation { + id: inv.id.to_string(), + channel_id: inv.channel_id.to_string(), + invited_by: inv.invited_by.to_string(), + invited_user_id: inv.invited_user_id.map(|id| id.to_string()).unwrap_or_default(), + role: inv.role.to_string(), + status: status.to_string(), + created_at: to_proto_ts(inv.created_at), + updated_at: inv.accepted_at.or(inv.revoked_at).map(to_proto_ts).flatten().or_else(|| to_proto_ts(inv.created_at)), + } +} + +#[tonic::async_trait] +impl ChannelInvitationService for ChannelInvitationGrpcService { + async fn list_invitations( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let invitations = self + .service + .im + .invitation_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListInvitationsResponse { + invitations: invitations.into_iter().map(invitation_to_proto).collect(), + })) + } + + async fn create_invitation( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let invited_user_id = parse_uuid(&req.invited_user_id, "invited_user_id")?; + let session = system_session(); + + let params = CreateInvitationParams { + invited_user_id: Some(invited_user_id), + email: None, + role: Some(req.role), + expires_in_hours: None, + }; + + let inv = self + .service + .im + .invitation_create(&session, channel_id, Uuid::nil(), params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateInvitationResponse { + invitation: Some(invitation_to_proto(inv)), + })) + } + + async fn accept_invitation( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let invitation_id = parse_uuid(&req.invitation_id, "invitation_id")?; + let session = system_session(); + + let inv = self + .service + .im + .invitation_accept(&session, invitation_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(AcceptInvitationResponse { + invitation: Some(invitation_to_proto(inv)), + })) + } + + async fn revoke_invitation( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let invitation_id = parse_uuid(&req.invitation_id, "invitation_id")?; + let session = system_session(); + + self.service + .im + .invitation_revoke(&session, invitation_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(RevokeInvitationResponse {})) + } +} + +// Section: ChannelWebhookGrpcService + +pub struct ChannelWebhookGrpcService { + service: AppService, +} + +impl ChannelWebhookGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn webhook_to_proto(wh: crate::models::channels::ChannelWebhook) -> crate::pb::im::ChannelWebhook { + crate::pb::im::ChannelWebhook { + id: wh.id.to_string(), + channel_id: wh.channel_id.to_string(), + name: wh.name, + url: wh.url, + secret: wh.secret_ciphertext.unwrap_or_default(), + events: wh.events.iter().map(|e| e.to_string()).collect(), + active: wh.active, + created_at: to_proto_ts(wh.created_at), + updated_at: to_proto_ts(wh.updated_at), + } +} + +#[tonic::async_trait] +impl ChannelWebhookService for ChannelWebhookGrpcService { + async fn list_webhooks( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let webhooks = self + .service + .im + .webhook_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListWebhooksResponse { + webhooks: webhooks.into_iter().map(webhook_to_proto).collect(), + })) + } + + async fn create_webhook( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateWebhookParams { + name: req.name, + url: req.url, + secret: req.secret, + events: req.events, + }; + + let wh = self + .service + .im + .webhook_create(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateWebhookResponse { + webhook: Some(webhook_to_proto(wh)), + })) + } + + async fn update_webhook( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let webhook_id = parse_uuid(&req.webhook_id, "webhook_id")?; + let session = system_session(); + + let params = UpdateWebhookParams { + name: req.name, + url: req.url, + secret: req.secret, + events: if req.events.is_empty() { + None + } else { + Some(req.events) + }, + active: req.active, + }; + + let wh = self + .service + .im + .webhook_update(&session, webhook_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateWebhookResponse { + webhook: Some(webhook_to_proto(wh)), + })) + } + + async fn delete_webhook( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let webhook_id = parse_uuid(&req.webhook_id, "webhook_id")?; + let session = system_session(); + + self.service + .im + .webhook_delete(&session, webhook_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteWebhookResponse {})) + } +} + +// Section: ChannelSlashCommandGrpcService + +pub struct ChannelSlashCommandGrpcService { + service: AppService, +} + +impl ChannelSlashCommandGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn slash_command_to_proto(cmd: crate::models::channels::ChannelSlashCommand) -> crate::pb::im::ChannelSlashCommand { + crate::pb::im::ChannelSlashCommand { + id: cmd.id.to_string(), + channel_id: cmd.channel_id.map(|id| id.to_string()).unwrap_or_default(), + command: cmd.command, + description: cmd.description.unwrap_or_default(), + request_url: cmd.request_url, + scopes: cmd.scopes.iter().map(|s| s.to_string()).collect(), + created_at: to_proto_ts(cmd.created_at), + updated_at: to_proto_ts(cmd.updated_at), + } +} + +#[tonic::async_trait] +impl ChannelSlashCommandService for ChannelSlashCommandGrpcService { + async fn list_slash_commands( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let commands = self + .service + .im + .slash_command_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListSlashCommandsResponse { + commands: commands.into_iter().map(slash_command_to_proto).collect(), + })) + } + + async fn create_slash_command( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateSlashCommandParams { + command: req.command, + description: Some(req.description), + request_url: req.request_url, + secret: None, + scopes: req.scopes, + }; + + let cmd = self + .service + .im + .slash_command_create(&session, channel_id, Uuid::nil(), params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateSlashCommandResponse { + command: Some(slash_command_to_proto(cmd)), + })) + } + + async fn update_slash_command( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let command_id = parse_uuid(&req.command_id, "command_id")?; + let session = system_session(); + + let params = UpdateSlashCommandParams { + command: None, + description: req.description, + request_url: req.request_url, + secret: None, + scopes: if req.scopes.is_empty() { + None + } else { + Some(req.scopes) + }, + enabled: None, + }; + + let cmd = self + .service + .im + .slash_command_update(&session, command_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateSlashCommandResponse { + command: Some(slash_command_to_proto(cmd)), + })) + } + + async fn delete_slash_command( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let command_id = parse_uuid(&req.command_id, "command_id")?; + let session = system_session(); + + self.service + .im + .slash_command_delete(&session, command_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteSlashCommandResponse {})) + } +} + +// Section: ChannelRepoLinkGrpcService + +pub struct ChannelRepoLinkGrpcService { + service: AppService, +} + +impl ChannelRepoLinkGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn repo_link_to_proto(link: crate::models::channels::ChannelRepoLink) -> crate::pb::im::ChannelRepoLink { + crate::pb::im::ChannelRepoLink { + id: link.id.to_string(), + channel_id: link.channel_id.to_string(), + repo_id: link.repo_id.to_string(), + link_type: link.link_type.to_string(), + events: link.notify_events.iter().map(|e| e.to_string()).collect(), + created_at: to_proto_ts(link.created_at), + updated_at: to_proto_ts(link.updated_at), + } +} + +#[tonic::async_trait] +impl ChannelRepoLinkService for ChannelRepoLinkGrpcService { + async fn list_repo_links( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let links = self + .service + .im + .repo_link_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListRepoLinksResponse { + links: links.into_iter().map(repo_link_to_proto).collect(), + })) + } + + async fn create_repo_link( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let repo_id = parse_uuid(&req.repo_id, "repo_id")?; + let session = system_session(); + + let params = CreateRepoLinkParams { + repo_id, + link_type: req.link_type, + notify_events: req.events, + }; + + let link = self + .service + .im + .repo_link_create(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateRepoLinkResponse { + link: Some(repo_link_to_proto(link)), + })) + } + + async fn delete_repo_link( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let link_id = parse_uuid(&req.link_id, "link_id")?; + let session = system_session(); + + self.service + .im + .repo_link_delete(&session, link_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteRepoLinkResponse {})) + } +} + +// Section: ImIntegrationGrpcService + +pub struct ImIntegrationGrpcService { + service: AppService, +} + +impl ImIntegrationGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn integration_to_proto(integ: crate::models::channels::ImIntegration) -> crate::pb::im::ImIntegration { + crate::pb::im::ImIntegration { + id: integ.id.to_string(), + channel_id: integ.internal_channel_id.map(|id| id.to_string()).unwrap_or_default(), + provider: integ.provider.to_string(), + external_channel_id: integ.external_channel_id.unwrap_or_default(), + sync_direction: integ.sync_direction.to_string(), + active: integ.enabled, + created_at: to_proto_ts(integ.created_at), + updated_at: to_proto_ts(integ.updated_at), + } +} + +#[tonic::async_trait] +impl ImIntegrationService for ImIntegrationGrpcService { + async fn list_integrations( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let workspace_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let integrations = self + .service + .im + .integration_list(&session, workspace_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListIntegrationsResponse { + integrations: integrations.into_iter().map(integration_to_proto).collect(), + })) + } + + async fn create_integration( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let workspace_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateIntegrationParams { + provider: req.provider, + name: String::new(), + external_workspace_id: None, + internal_channel_id: None, + external_channel_id: Some(req.external_channel_id), + bot_token: None, + webhook_url: None, + sync_direction: req.sync_direction, + }; + + let integ = self + .service + .im + .integration_create(&session, workspace_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateIntegrationResponse { + integration: Some(integration_to_proto(integ)), + })) + } + + async fn update_integration( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let integration_id = parse_uuid(&req.integration_id, "integration_id")?; + let session = system_session(); + + let params = UpdateIntegrationParams { + name: None, + external_channel_id: None, + webhook_url: None, + sync_direction: req.sync_direction, + enabled: req.active, + }; + + let integ = self + .service + .im + .integration_update(&session, integration_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateIntegrationResponse { + integration: Some(integration_to_proto(integ)), + })) + } + + async fn delete_integration( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let integration_id = parse_uuid(&req.integration_id, "integration_id")?; + let session = system_session(); + + self.service + .im + .integration_delete(&session, integration_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteIntegrationResponse {})) + } +} + +// Section: CustomEmojiGrpcService + +pub struct CustomEmojiGrpcService { + service: AppService, +} + +impl CustomEmojiGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn emoji_to_proto(e: crate::models::channels::CustomEmoji) -> crate::pb::im::CustomEmoji { + crate::pb::im::CustomEmoji { + id: e.id.to_string(), + workspace_id: e.workspace_id.to_string(), + name: e.name, + image_url: e.url, + created_at: to_proto_ts(e.created_at), + } +} + +#[tonic::async_trait] +impl CustomEmojiService for CustomEmojiGrpcService { + async fn list_custom_emojis( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let workspace_id = parse_uuid(&req.workspace_id, "workspace_id")?; + let session = system_session(); + + let emojis = self + .service + .im + .custom_emoji_list(&session, workspace_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListCustomEmojisResponse { + emojis: emojis.into_iter().map(emoji_to_proto).collect(), + })) + } + + async fn create_custom_emoji( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let workspace_id = parse_uuid(&req.workspace_id, "workspace_id")?; + let session = system_session(); + + let params = CreateCustomEmojiParams { + name: req.name, + url: req.image_url, + animated: None, + }; + + let emoji = self + .service + .im + .custom_emoji_create(&session, workspace_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateCustomEmojiResponse { + emoji: Some(emoji_to_proto(emoji)), + })) + } + + async fn delete_custom_emoji( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let emoji_id = parse_uuid(&req.emoji_id, "emoji_id")?; + let session = system_session(); + + self.service + .im + .custom_emoji_delete(&session, emoji_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteCustomEmojiResponse {})) + } +} + +// Section: ForumTagGrpcService + +pub struct ForumTagGrpcService { + service: AppService, +} + +impl ForumTagGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn forum_tag_to_proto(t: crate::models::channels::ForumTag) -> crate::pb::im::ForumTag { + crate::pb::im::ForumTag { + id: t.id.to_string(), + channel_id: t.channel_id.to_string(), + name: t.name, + moderated: t.moderated, + position: t.position, + created_at: to_proto_ts(t.created_at), + updated_at: to_proto_ts(t.updated_at), + } +} + +#[tonic::async_trait] +impl ForumTagService for ForumTagGrpcService { + async fn list_forum_tags( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let tags = self + .service + .im + .forum_tag_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListForumTagsResponse { + tags: tags.into_iter().map(forum_tag_to_proto).collect(), + })) + } + + async fn create_forum_tag( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateForumTagParams { + name: req.name, + emoji_id: None, + emoji_name: None, + moderated: Some(req.moderated), + position: req.position, + }; + + let tag = self + .service + .im + .forum_tag_create(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateForumTagResponse { + tag: Some(forum_tag_to_proto(tag)), + })) + } + + async fn update_forum_tag( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tag_id = parse_uuid(&req.tag_id, "tag_id")?; + let session = system_session(); + + let params = UpdateForumTagParams { + name: req.name, + emoji_id: None, + emoji_name: None, + moderated: req.moderated, + position: req.position, + }; + + let tag = self + .service + .im + .forum_tag_update(&session, tag_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateForumTagResponse { + tag: Some(forum_tag_to_proto(tag)), + })) + } + + async fn delete_forum_tag( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tag_id = parse_uuid(&req.tag_id, "tag_id")?; + let session = system_session(); + + self.service + .im + .forum_tag_delete(&session, tag_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteForumTagResponse {})) + } +} + +// Section: VoiceGrpcService + +pub struct VoiceGrpcService { + service: AppService, +} + +impl VoiceGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn voice_participant_to_proto(p: crate::models::channels::VoiceParticipant) -> crate::pb::im::VoiceParticipant { + crate::pb::im::VoiceParticipant { + id: p.id.to_string(), + channel_id: p.channel_id.to_string(), + user_id: p.user_id.to_string(), + muted: p.muted, + deafened: p.deafened, + joined_at: to_proto_ts(p.joined_at), + } +} + +#[tonic::async_trait] +impl VoiceService for VoiceGrpcService { + async fn list_voice_participants( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let participants = self + .service + .im + .voice_participant_list(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(ListVoiceParticipantsResponse { + participants: participants + .into_iter() + .map(voice_participant_to_proto) + .collect(), + })) + } + + async fn update_voice_state( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = UpdateVoiceStateParams { + session_id: None, + muted: req.muted, + deafened: req.deafened, + self_muted: None, + self_deafened: None, + self_video: None, + streaming: None, + }; + + let p = self + .service + .im + .voice_state_update(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateVoiceStateResponse { + participant: Some(voice_participant_to_proto(p)), + })) + } +} + +// Section: StageGrpcService + +pub struct StageGrpcService { + service: AppService, +} + +impl StageGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn stage_to_proto(s: crate::models::channels::Stage) -> crate::pb::im::Stage { + crate::pb::im::Stage { + id: s.id.to_string(), + channel_id: s.channel_id.to_string(), + topic: s.topic, + privacy_level: s.privacy_level.to_string(), + discoverable: s.discoverable, + started_at: to_proto_ts(s.started_at), + ended_at: s.ended_at.map(to_proto_ts).flatten(), + created_at: to_proto_ts(s.created_at), + updated_at: to_proto_ts(s.updated_at), + } +} + +#[tonic::async_trait] +impl StageService for StageGrpcService { + async fn get_stage( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let stage = self + .service + .im + .stage_get(&session, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(GetStageResponse { + stage: stage.map(stage_to_proto), + })) + } + + async fn create_stage( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let params = CreateStageParams { + topic: req.topic, + privacy_level: Some(req.privacy_level), + discoverable: Some(req.discoverable), + }; + + let stage = self + .service + .im + .stage_create(&session, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(CreateStageResponse { + stage: Some(stage_to_proto(stage)), + })) + } + + async fn update_stage( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let stage_id = parse_uuid(&req.stage_id, "stage_id")?; + let session = system_session(); + + let params = UpdateStageParams { + topic: req.topic, + privacy_level: req.privacy_level, + discoverable: req.discoverable, + }; + + let stage = self + .service + .im + .stage_update(&session, stage_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateStageResponse { + stage: Some(stage_to_proto(stage)), + })) + } + + async fn delete_stage( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let stage_id = parse_uuid(&req.stage_id, "stage_id")?; + let session = system_session(); + + self.service + .im + .stage_delete(&session, stage_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeleteStageResponse {})) + } +} + +// Section: ChannelAuditGrpcService + +pub struct ChannelAuditGrpcService { + service: AppService, +} + +impl ChannelAuditGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } +} + +fn audit_event_to_proto(e: crate::models::channels::ChannelEvent) -> crate::pb::im::ChannelAuditEvent { + crate::pb::im::ChannelAuditEvent { + id: e.id.to_string(), + channel_id: e.channel_id.to_string(), + actor_id: e.actor_id.map(|id| id.to_string()).unwrap_or_default(), + event_type: e.event_type.to_string(), + target_type: e.target_type.map(|t| t.to_string()).unwrap_or_default(), + target_id: e.target_id.map(|id| id.to_string()).unwrap_or_default(), + old_value: e.old_value.map(|v| v.to_string()), + new_value: e.new_value.map(|v| v.to_string()), + created_at: to_proto_ts(e.created_at), + } +} + +#[tonic::async_trait] +impl ChannelAuditService for ChannelAuditGrpcService { + async fn list_channel_events( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = parse_uuid(&req.channel_id, "channel_id")?; + let session = system_session(); + + let events = self + .service + .im + .audit_list(&session, channel_id, req.limit as i64, req.offset as i64) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let total = events.len() as i32; + + Ok(Response::new(ListChannelEventsResponse { + events: events.into_iter().map(audit_event_to_proto).collect(), + total, + })) + } +} + +pub struct ChannelSettingsServices { + pub channel_role: ChannelRoleGrpcService, + pub channel_invitation: ChannelInvitationGrpcService, + pub channel_webhook: ChannelWebhookGrpcService, + pub channel_slash_command: ChannelSlashCommandGrpcService, + pub channel_repo_link: ChannelRepoLinkGrpcService, + pub im_integration: ImIntegrationGrpcService, + pub custom_emoji: CustomEmojiGrpcService, + pub forum_tag: ForumTagGrpcService, + pub voice: VoiceGrpcService, + pub stage: StageGrpcService, + pub channel_audit: ChannelAuditGrpcService, +} + +impl ChannelSettingsServices { + pub fn new(service: crate::service::AppService) -> Self { + Self { + channel_role: ChannelRoleGrpcService::new(service.clone()), + channel_invitation: ChannelInvitationGrpcService::new(service.clone()), + channel_webhook: ChannelWebhookGrpcService::new(service.clone()), + channel_slash_command: ChannelSlashCommandGrpcService::new(service.clone()), + channel_repo_link: ChannelRepoLinkGrpcService::new(service.clone()), + im_integration: ImIntegrationGrpcService::new(service.clone()), + custom_emoji: CustomEmojiGrpcService::new(service.clone()), + forum_tag: ForumTagGrpcService::new(service.clone()), + voice: VoiceGrpcService::new(service.clone()), + stage: StageGrpcService::new(service.clone()), + channel_audit: ChannelAuditGrpcService::new(service), + } + } +} diff --git a/grpc/member.rs b/grpc/member.rs new file mode 100644 index 0000000..cfc0ea3 --- /dev/null +++ b/grpc/member.rs @@ -0,0 +1,243 @@ +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::models::channels::ChannelMember; +use crate::pb::im::member_service_server::MemberService; +use crate::pb::im::{ + ChannelMember as PbChannelMember, InviteMemberRequest, InviteMemberResponse, + IsMemberRequest, IsMemberResponse, JoinChannelRequest, JoinChannelResponse, + KickMemberRequest, KickMemberResponse, LeaveChannelRequest, LeaveChannelResponse, + ListMembersRequest, ListMembersResponse, UpdateMemberRequest, UpdateMemberResponse, +}; +use crate::service::im::session::ImSession; +use crate::service::im::members::{InviteMemberParams, UpdateMemberParams}; +use crate::service::AppService; + +pub struct MemberGrpcService { + service: AppService, +} + +impl MemberGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } + + async fn resolve_workspace_name(&self, channel_id: Uuid) -> Result { + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let ws_name: String = sqlx::query_scalar("SELECT name FROM workspace WHERE id = $1") + .bind(channel.workspace_id) + .fetch_optional(self.service.ctx.db.reader()) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::not_found("workspace not found"))?; + + Ok(ws_name) + } + + fn parse_uuid(s: &str, field: &str) -> Result { + Uuid::parse_str(s).map_err(|_| Status::invalid_argument(format!("invalid {}", field))) + } + + fn to_timestamp(dt: chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + } + } + + fn to_pb_member(m: ChannelMember) -> PbChannelMember { + PbChannelMember { + id: m.id.to_string(), + channel_id: m.channel_id.to_string(), + user_id: m.user_id.to_string(), + role: m.role.to_string(), + status: m.status.to_string(), + muted: m.muted, + pinned: m.pinned, + last_read_message_id: m.last_read_message_id.map(|id| id.to_string()), + last_read_at: m.last_read_at.map(Self::to_timestamp), + joined_at: m.joined_at.map(Self::to_timestamp), + left_at: m.left_at.map(Self::to_timestamp), + created_at: Some(Self::to_timestamp(m.created_at)), + updated_at: Some(Self::to_timestamp(m.updated_at)), + } + } +} + +#[tonic::async_trait] +impl MemberService for MemberGrpcService { + async fn list_members( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let session = ImSession::new(Uuid::nil()); + let members = self + .service + .im + .member_list(&session, &wk_name, channel_id, req.limit as i64, req.offset as i64) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let pb_members: Vec = members.into_iter().map(Self::to_pb_member).collect(); + let total = pb_members.len() as i32; + + Ok(Response::new(ListMembersResponse { + members: pb_members, + total, + })) + } + + async fn invite_member( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let params = InviteMemberParams { + user_id, + role: req.role, + }; + + let session = ImSession::new(Uuid::nil()); + let member = self + .service + .im + .member_invite(&session, &wk_name, channel_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(InviteMemberResponse { + member: Some(Self::to_pb_member(member)), + })) + } + + async fn update_member( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let params = UpdateMemberParams { + role: req.role, + muted: req.muted, + pinned: req.pinned, + }; + + let session = ImSession::new(Uuid::nil()); + let member = self + .service + .im + .member_update(&session, &wk_name, channel_id, user_id, params) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UpdateMemberResponse { + member: Some(Self::to_pb_member(member)), + })) + } + + async fn kick_member( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let session = ImSession::new(Uuid::nil()); + self.service + .im + .member_kick(&session, &wk_name, channel_id, user_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(KickMemberResponse {})) + } + + async fn join_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let session = ImSession::new(user_id); + let member = self + .service + .im + .member_join(&session, &wk_name, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(JoinChannelResponse { + member: Some(Self::to_pb_member(member)), + })) + } + + async fn leave_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + let wk_name = self.resolve_workspace_name(channel_id).await?; + + let session = ImSession::new(user_id); + self.service + .im + .member_leave(&session, &wk_name, channel_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(LeaveChannelResponse {})) + } + + async fn is_member( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_id = Self::parse_uuid(&req.user_id, "user_id")?; + + let is_member = self + .service + .im + .is_channel_member(channel_id, user_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let role = if is_member { + self.service + .im + .channel_member_role(channel_id, user_id) + .await + .map_err(|e| Status::internal(e.to_string()))? + .to_string() + } else { + String::new() + }; + + Ok(Response::new(IsMemberResponse { is_member, role })) + } +} diff --git a/grpc/mod.rs b/grpc/mod.rs new file mode 100644 index 0000000..1f8ba65 --- /dev/null +++ b/grpc/mod.rs @@ -0,0 +1,60 @@ +pub mod auth; +pub mod channel; +pub mod channel_settings; +pub mod member; +pub mod permission; + +use std::net::SocketAddr; + +use crate::pb::im::channel_audit_service_server::ChannelAuditServiceServer; +use crate::pb::im::channel_invitation_service_server::ChannelInvitationServiceServer; +use crate::pb::im::channel_repo_link_service_server::ChannelRepoLinkServiceServer; +use crate::pb::im::channel_role_service_server::ChannelRoleServiceServer; +use crate::pb::im::channel_service_server::ChannelServiceServer; +use crate::pb::im::channel_slash_command_service_server::ChannelSlashCommandServiceServer; +use crate::pb::im::channel_webhook_service_server::ChannelWebhookServiceServer; +use crate::pb::im::custom_emoji_service_server::CustomEmojiServiceServer; +use crate::pb::im::forum_tag_service_server::ForumTagServiceServer; +use crate::pb::im::im_integration_service_server::ImIntegrationServiceServer; +use crate::pb::im::internal_auth_service_server::InternalAuthServiceServer; +use crate::pb::im::member_service_server::MemberServiceServer; +use crate::pb::im::permission_service_server::PermissionServiceServer; +use crate::pb::im::stage_service_server::StageServiceServer; +use crate::pb::im::voice_service_server::VoiceServiceServer; +use crate::service::AppService; + +pub async fn start_grpc_server( + addr: SocketAddr, + service: AppService, +) -> Result<(), Box> { + let auth_service = service.internal_auth.clone(); + let channel_svc = channel::ChannelGrpcService::new(service.clone()); + let member_svc = member::MemberGrpcService::new(service.clone()); + let permission_svc = permission::PermissionGrpcService::new(service.clone()); + let internal_auth_svc = auth::InternalAuthGrpcService::new(auth_service); + + let cs = channel_settings::ChannelSettingsServices::new(service); + + tracing::info!(%addr, "gRPC server listening"); + + tonic::transport::Server::builder() + .add_service(InternalAuthServiceServer::new(internal_auth_svc)) + .add_service(ChannelServiceServer::new(channel_svc)) + .add_service(MemberServiceServer::new(member_svc)) + .add_service(PermissionServiceServer::new(permission_svc)) + .add_service(ChannelRoleServiceServer::new(cs.channel_role)) + .add_service(ChannelInvitationServiceServer::new(cs.channel_invitation)) + .add_service(ChannelWebhookServiceServer::new(cs.channel_webhook)) + .add_service(ChannelSlashCommandServiceServer::new(cs.channel_slash_command)) + .add_service(ChannelRepoLinkServiceServer::new(cs.channel_repo_link)) + .add_service(ImIntegrationServiceServer::new(cs.im_integration)) + .add_service(CustomEmojiServiceServer::new(cs.custom_emoji)) + .add_service(ForumTagServiceServer::new(cs.forum_tag)) + .add_service(VoiceServiceServer::new(cs.voice)) + .add_service(StageServiceServer::new(cs.stage)) + .add_service(ChannelAuditServiceServer::new(cs.channel_audit)) + .serve(addr) + .await?; + + Ok(()) +} diff --git a/grpc/permission.rs b/grpc/permission.rs new file mode 100644 index 0000000..72ba381 --- /dev/null +++ b/grpc/permission.rs @@ -0,0 +1,360 @@ +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::models::channels::ChannelPermissionOverwrite; +use crate::models::common::{OverwriteTarget, Role}; +use crate::pb::im::permission_service_server::PermissionService; +use crate::pb::im::{ + CheckPermissionRequest, CheckPermissionResponse, DeletePermissionOverwriteRequest, + DeletePermissionOverwriteResponse, EnsureReadableRequest, EnsureReadableResponse, + GetPermissionOverwritesRequest, GetPermissionOverwritesResponse, GetPermissionsRequest, + GetPermissionsResponse, PermissionOverwrite, ResolveChannelRequest, ResolveChannelResponse, + SetPermissionOverwriteRequest, SetPermissionOverwriteResponse, +}; +use crate::service::util::role_level; +use crate::service::AppService; + +pub struct PermissionGrpcService { + service: AppService, +} + +impl PermissionGrpcService { + pub fn new(service: AppService) -> Self { + Self { service } + } + + fn parse_uuid(s: &str, field: &str) -> Result { + Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}"))) + } + + fn im_permission_to_str(v: i32) -> &'static str { + match v { + 1 => "READ_CHANNEL", + 2 => "SEND_MESSAGE", + 3 => "MANAGE_THREADS", + 4 => "MANAGE_REACTIONS", + 5 => "MANAGE_PINS", + 6 => "INVITE_MEMBERS", + 7 => "KICK_MEMBERS", + 8 => "MANAGE_CHANNEL", + 9 => "MANAGE_ROLES", + 10 => "MANAGE_WEBHOOKS", + 11 => "MANAGE_EMOJIS", + 12 => "VIEW_AUDIT_LOG", + 13 => "MANAGE_INTEGRATIONS", + 14 => "SEND_TTS", + 15 => "USE_SLASH_COMMANDS", + 16 => "ATTACH_FILES", + 17 => "MENTION_EVERYONE", + 18 => "MANAGE_MESSAGES", + 19 => "ADMIN", + _ => "UNSPECIFIED", + } + } + + fn str_to_im_permission(s: &str) -> i32 { + match s { + "READ_CHANNEL" => 1, + "SEND_MESSAGE" => 2, + "MANAGE_THREADS" => 3, + "MANAGE_REACTIONS" => 4, + "MANAGE_PINS" => 5, + "INVITE_MEMBERS" => 6, + "KICK_MEMBERS" => 7, + "MANAGE_CHANNEL" => 8, + "MANAGE_ROLES" => 9, + "MANAGE_WEBHOOKS" => 10, + "MANAGE_EMOJIS" => 11, + "VIEW_AUDIT_LOG" => 12, + "MANAGE_INTEGRATIONS" => 13, + "SEND_TTS" => 14, + "USE_SLASH_COMMANDS" => 15, + "ATTACH_FILES" => 16, + "MENTION_EVERYONE" => 17, + "MANAGE_MESSAGES" => 18, + "ADMIN" => 19, + _ => 0, + } + } + + fn permission_requires_role(p: i32) -> Role { + match p { + 1 => Role::Viewer, + 2 => Role::Member, + 3 => Role::Member, + 4 => Role::Member, + 5 => Role::Moderator, + 6 => Role::Moderator, + 7 => Role::Moderator, + 8 => Role::Admin, + 9 => Role::Admin, + 10 => Role::Admin, + 11 => Role::Admin, + 12 => Role::Moderator, + 13 => Role::Admin, + 14 => Role::Member, + 15 => Role::Member, + 16 => Role::Member, + 17 => Role::Moderator, + 18 => Role::Moderator, + 19 => Role::Admin, + _ => Role::Owner, + } + } + + fn role_to_permissions(role: Role) -> Vec { + let level = role_level(role); + let mut perms = Vec::new(); + + if level >= role_level(Role::Viewer) { + perms.push(1); + } + if level >= role_level(Role::Member) { + perms.extend_from_slice(&[2, 3, 4, 14, 15, 16]); + } + if level >= role_level(Role::Moderator) { + perms.extend_from_slice(&[5, 6, 7, 12, 17, 18]); + } + if level >= role_level(Role::Admin) { + perms.extend_from_slice(&[8, 9, 10, 11, 13, 19]); + } + + perms.sort(); + perms.dedup(); + perms + } + + fn overwrite_to_proto(o: ChannelPermissionOverwrite) -> PermissionOverwrite { + PermissionOverwrite { + id: o.id.to_string(), + channel_id: o.channel_id.to_string(), + target_type: o.target_type.as_str().to_string(), + target_id: o.target_id.to_string(), + allow: o + .allow + .iter() + .map(|p| Self::str_to_im_permission(p)) + .collect(), + deny: o + .deny + .iter() + .map(|p| Self::str_to_im_permission(p)) + .collect(), + created_at: o.created_at.to_rfc3339(), + updated_at: o.updated_at.to_rfc3339(), + } + } +} + +#[tonic::async_trait] +impl PermissionService for PermissionGrpcService { + async fn check_permission( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_uid = Self::parse_uuid(&req.user_id, "user_id")?; + + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::not_found(e.to_string()))?; + + let role = self + .service + .im + .channel_member_role(channel.id, user_uid) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let required_role = Self::permission_requires_role(req.permission); + let allowed = role_level(role) >= role_level(required_role); + + Ok(Response::new(CheckPermissionResponse { + allowed, + role: role.as_str().to_string(), + })) + } + + async fn get_permissions( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_uid = Self::parse_uuid(&req.user_id, "user_id")?; + + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::not_found(e.to_string()))?; + + let role = self + .service + .im + .channel_member_role(channel.id, user_uid) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let permissions = Self::role_to_permissions(role); + + Ok(Response::new(GetPermissionsResponse { + permissions, + role: role.as_str().to_string(), + })) + } + + async fn set_permission_overwrite( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let target_id = Self::parse_uuid(&req.target_id, "target_id")?; + let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown); + + let allow: Vec = req + .allow + .into_iter() + .map(|v| Self::im_permission_to_str(v).to_string()) + .collect(); + let deny: Vec = req + .deny + .into_iter() + .map(|v| Self::im_permission_to_str(v).to_string()) + .collect(); + + let now = chrono::Utc::now(); + let id = Uuid::new_v4(); + + let overwrite = sqlx::query_as::<_, ChannelPermissionOverwrite>( + "INSERT INTO channel_permission_overwrite \ + (id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ + ON CONFLICT (channel_id, target_type, target_id) \ + DO UPDATE SET allow = $5, deny = $6, updated_at = $9 \ + RETURNING id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at", + ) + .bind(id) + .bind(channel_id) + .bind(target_type) + .bind(target_id) + .bind(&allow) + .bind(&deny) + .bind(Uuid::nil()) + .bind(now) + .bind(now) + .fetch_one(self.service.ctx.db.writer()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(SetPermissionOverwriteResponse { + overwrite: Some(Self::overwrite_to_proto(overwrite)), + })) + } + + async fn get_permission_overwrites( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + + let overwrites = sqlx::query_as::<_, ChannelPermissionOverwrite>( + "SELECT id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at \ + FROM channel_permission_overwrite WHERE channel_id = $1", + ) + .bind(channel_id) + .fetch_all(self.service.ctx.db.reader()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_overwrites: Vec<_> = overwrites + .into_iter() + .map(Self::overwrite_to_proto) + .collect(); + + Ok(Response::new(GetPermissionOverwritesResponse { + overwrites: proto_overwrites, + })) + } + + async fn delete_permission_overwrite( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let target_id = Self::parse_uuid(&req.target_id, "target_id")?; + let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown); + + sqlx::query( + "DELETE FROM channel_permission_overwrite \ + WHERE channel_id = $1 AND target_type = $2 AND target_id = $3", + ) + .bind(channel_id) + .bind(target_type) + .bind(target_id) + .execute(self.service.ctx.db.writer()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(DeletePermissionOverwriteResponse {})) + } + + async fn resolve_channel( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::not_found(e.to_string()))?; + + Ok(Response::new(ResolveChannelResponse { + channel_id: channel.id.to_string(), + workspace_id: channel.workspace_id.to_string(), + name: channel.name, + visibility: channel.visibility.as_str().to_string(), + channel_type: channel.channel_type.as_str().to_string(), + read_only: channel.read_only, + archived: channel.archived, + created_by: Some(channel.created_by.to_string()), + })) + } + + async fn ensure_readable( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?; + let user_uid = Self::parse_uuid(&req.user_id, "user_id")?; + + let channel = self + .service + .im + .resolve_channel(channel_id) + .await + .map_err(|e| Status::not_found(e.to_string()))?; + + let allowed = self + .service + .im + .ensure_channel_readable(user_uid, &channel) + .await + .is_ok(); + + Ok(Response::new(EnsureReadableResponse { allowed })) + } +} diff --git a/immediate/bridge.rs b/immediate/bridge.rs deleted file mode 100644 index 3f9c6c0..0000000 --- a/immediate/bridge.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::sync::Arc; - -use futures_util::StreamExt; -use uuid::Uuid; - -use crate::queue::NatsQueue; - -use super::{ - ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent, - PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager, - WsSinkManager, -}; - -#[derive(Clone)] -pub struct NatsWsBridge { - queue: Arc, - sessions: Arc, - sinks: Arc, -} - -impl NatsWsBridge { - pub fn new( - queue: Arc, - sessions: Arc, - sinks: Arc, - ) -> Self { - Self { - queue, - sessions, - sinks, - } - } - - pub async fn run_ephemeral(self, subject: &str) { - let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else { - tracing::warn!(subject, "nats ws bridge subscribe failed"); - return; - }; - while let Some(msg) = sub.next().await { - self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg)) - .await; - } - } - - async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) { - if subject.starts_with("im.message.") { - self.channel_event(payload, |data| WsOutbound::Message { request_id, data }); - } else if subject.starts_with("im.thread.") { - self.channel_event(payload, |data| WsOutbound::Thread { request_id, data }); - } else if subject.starts_with("im.member.") { - self.channel_event(payload, |data| WsOutbound::Member { request_id, data }); - } else if subject.starts_with("im.reaction.") { - self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data }); - } else if subject.starts_with("im.poll.") { - self.channel_event(payload, |data| WsOutbound::Poll { request_id, data }); - } else if subject.starts_with("im.article.") { - self.channel_event(payload, |data| WsOutbound::Article { request_id, data }); - } else if subject.starts_with("im.typing.") { - self.channel_event(payload, |data| WsOutbound::Typing { request_id, data }); - } else if subject.starts_with("im.presence.") { - self.presence_event(payload, request_id); - } else if subject.starts_with("im.channel.") { - self.channel_meta_event(subject, payload, request_id); - } else if subject.starts_with("im.category.") { - self.category_event(payload, request_id); - } else if subject.starts_with("im.draft.") { - self.draft_event(payload, request_id); - } else if subject.starts_with("im.follow.") { - self.channel_event(payload, |data| WsOutbound::Follow { request_id, data }); - } - } - - fn channel_event(&self, payload: &[u8], build: F) - where - T: serde::de::DeserializeOwned + ChannelScoped, - F: Fn(T) -> WsOutbound, - { - let Ok(data) = serde_json::from_slice::(payload) else { - tracing::warn!("nats ws bridge decode channel event failed"); - return; - }; - let channel_id = data.channel_id(); - let subscribers = self.sessions.subscribers(channel_id); - let delivered = self.sinks.send_many(subscribers, build(data)); - tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers"); - } - - fn presence_event(&self, payload: &[u8], request_id: Uuid) { - let Ok(data) = serde_json::from_slice::(payload) else { - tracing::warn!("nats ws bridge decode presence event failed"); - return; - }; - let ids = self.sessions.user_connections(data.user_id); - let delivered = self - .sinks - .send_many(ids, WsOutbound::Presence { request_id, data }); - tracing::debug!(delivered, "nats presence forwarded to ws subscribers"); - } - - fn category_event(&self, payload: &[u8], request_id: Uuid) { - let Ok(data) = serde_json::from_slice::(payload) else { - tracing::warn!("nats ws bridge decode category event failed"); - return; - }; - let targets = self.sessions.workspace_connections(&data.workspace_name); - let delivered = self - .sinks - .send_many(targets, WsOutbound::Category { request_id, data }); - tracing::debug!(delivered, "nats category event forwarded to ws subscribers"); - } - - fn draft_event(&self, payload: &[u8], request_id: Uuid) { - let Ok(data) = serde_json::from_slice::(payload) else { - tracing::warn!("nats ws bridge decode draft event failed"); - return; - }; - let targets = self.sessions.user_connections(data.user_id); - let delivered = self - .sinks - .send_many(targets, WsOutbound::Draft { request_id, data }); - tracing::debug!(delivered, "nats draft event forwarded to ws subscribers"); - } - - fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) { - let Ok(data) = serde_json::from_slice::(payload) else { - tracing::warn!("nats ws bridge decode channel event failed"); - return; - }; - let mut targets = data - .workspace_name - .as_deref() - .map(|workspace| self.sessions.workspace_connections(workspace)) - .unwrap_or_else(|| self.sessions.subscribers(data.channel_id)); - if targets.is_empty() - && let Some(id) = subject - .rsplit('.') - .next() - .and_then(|v| v.parse::().ok()) - { - targets = self.sessions.subscribers(id); - } - let delivered = self - .sinks - .send_many(targets, WsOutbound::Channel { request_id, data }); - tracing::debug!(delivered, "nats channel event forwarded to ws subscribers"); - } -} - -pub trait ChannelScoped { - fn channel_id(&self) -> Uuid; -} - -impl ChannelScoped for MessageEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for ThreadEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for MemberEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for ReactionEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for PollEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for ArticleEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for TypingEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} -impl ChannelScoped for FollowEvent { - fn channel_id(&self) -> Uuid { - self.channel_id - } -} - -fn request_id(msg: &async_nats::Message) -> Uuid { - msg.headers - .as_ref() - .and_then(|h| h.get("X-Request-Id")) - .and_then(|v| v.as_str().parse().ok()) - .unwrap_or_else(Uuid::nil) -} diff --git a/immediate/dedup.rs b/immediate/dedup.rs deleted file mode 100644 index 8ced357..0000000 --- a/immediate/dedup.rs +++ /dev/null @@ -1,58 +0,0 @@ -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::AppResult; -use ::redis::Cmd; - -use super::redis_keys::*; - -pub struct DedupManager { - redis: AppRedis, - window_secs: u64, -} - -impl DedupManager { - pub fn new(redis: AppRedis) -> Self { - Self { - redis, - window_secs: WS_DEDUP_WINDOW_SECS, - } - } - - pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult { - let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); - let mut conn = self.redis.get_connection()?; - let result: Option = Cmd::new() - .arg("SET") - .arg(&key) - .arg("1") - .arg("NX") - .arg("EX") - .arg(self.window_secs) - .query(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - Ok(result.is_some()) - } - - pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult { - let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); - let mut conn = self.redis.get_connection()?; - let exists: bool = Cmd::new() - .arg("EXISTS") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - Ok(exists) - } - - pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> { - let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); - let mut conn = self.redis.get_connection()?; - Cmd::new() - .arg("DEL") - .arg(&key) - .query::<()>(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - Ok(()) - } -} diff --git a/immediate/envelope.rs b/immediate/envelope.rs deleted file mode 100644 index e5b668a..0000000 --- a/immediate/envelope.rs +++ /dev/null @@ -1,39 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransportEnvelope { - #[serde(default = "Uuid::now_v7")] - pub message_id: Uuid, - pub request_id: Uuid, - pub user_id: Uuid, - pub payload: T, - #[serde(default = "default_timestamp")] - pub created_at: chrono::DateTime, - #[serde(default)] - pub attempt: u8, -} - -fn default_timestamp() -> chrono::DateTime { - chrono::Utc::now() -} - -impl TransportEnvelope { - pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self { - Self { - message_id: Uuid::now_v7(), - request_id, - user_id, - payload, - created_at: chrono::Utc::now(), - attempt: 1, - } - } - - pub fn retry(self) -> Self { - Self { - attempt: self.attempt + 1, - ..self - } - } -} diff --git a/immediate/handler.rs b/immediate/handler.rs deleted file mode 100644 index 4f09dd2..0000000 --- a/immediate/handler.rs +++ /dev/null @@ -1,447 +0,0 @@ -use std::sync::Arc; - -use uuid::Uuid; - -use crate::immediate::dedup::DedupManager; -use crate::immediate::limiter::HandlerLimiter; -use crate::immediate::nats::ImNats; -use crate::immediate::outbound::*; -use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter}; -use crate::immediate::reconnect::ReconnectManager; -use crate::immediate::session::{WsSession, WsSessionManager}; -use crate::service::ImService; -use crate::service::im::messages::EditMessageParams; -use crate::service::im::messages::SendMessageParams; -use crate::service::im::presence::UpdatePresenceParams; -use crate::service::im::session::ImSession; - -use super::inbound::WsInbound; -use super::redis_keys::*; -use super::sink::WsSinkManager; - -#[allow(dead_code)] -#[derive(Clone)] -pub struct WsHandler { - nats: Arc, - manager: Arc, - sinks: Arc, - service: ImService, - dedup: Arc, - rate_limiter: Arc, - local_limiter: Arc, - handler_limiter: Arc, - reconnect: Arc, - session: Option, -} - -#[allow(dead_code)] -impl WsHandler { - pub fn new( - manager: Arc, - sinks: Arc, - service: ImService, - nats: Arc, - dedup: Arc, - rate_limiter: Arc, - reconnect: Arc, - ) -> Self { - Self { - nats, - manager, - sinks, - service, - dedup, - rate_limiter, - local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)), - handler_limiter: Arc::new(HandlerLimiter::new(1024)), - reconnect, - session: None, - } - } - - pub fn session(&self) -> Option<&WsSession> { - self.session.as_ref() - } - pub fn is_authenticated(&self) -> bool { - self.session.is_some() - } - - pub fn handle_disconnect(&self) { - if let Some(s) = &self.session - && let Err(e) = self.manager.unregister_connection(s) - { - tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed"); - } - } - - pub async fn handle(&mut self, msg: WsInbound) -> Vec { - match msg { - WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await, - m => { - let Some(s) = &self.session else { - return vec![WsOutbound::Error { - request_id: request_id_of(&m), - code: "not_authenticated".into(), - message: "authenticate first".into(), - }]; - }; - if !self.manager.is_deliverable(s.connection_id) { - return vec![WsOutbound::Error { - request_id: request_id_of(&m), - code: "session_not_active".into(), - message: "session is not active".into(), - }]; - } - let Ok(_permit) = self.handler_limiter.try_acquire() else { - return vec![WsOutbound::Error { - request_id: request_id_of(&m), - code: "overloaded".into(), - message: "too many inflight messages".into(), - }]; - }; - if !self.local_limiter.check() { - return vec![WsOutbound::Error { - request_id: request_id_of(&m), - code: "rate_limit_exceeded".into(), - message: "too many messages".into(), - }]; - } - match self.rate_limiter.check(s.connection_id) { - Ok(true) => {} - Ok(false) => { - return vec![WsOutbound::Error { - request_id: request_id_of(&m), - code: "rate_limit_exceeded".into(), - message: "too many messages".into(), - }]; - } - Err(e) => tracing::warn!(error = %e, "rate limit check failed"), - } - self.dispatch(s, m).await - } - } - } - - async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec { - match msg { - WsInbound::Heartbeat { request_id } => { - if let Err(e) = self.manager.heartbeat(session) { - tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed"); - } - vec![WsOutbound::HeartbeatAck { - request_id, - timestamp_ms: chrono::Utc::now().timestamp_millis(), - }] - } - WsInbound::JoinChannel { - request_id, - channel_id, - } => match self.service.resolve_channel(channel_id).await { - Ok(channel) => match self - .service - .ensure_channel_readable(session.user_id, &channel) - .await - { - Ok(()) => { - self.manager - .subscribe_channel(session.connection_id, channel_id); - vec![] - } - Err(e) => vec![WsOutbound::Error { - request_id, - code: "join_channel_failed".into(), - message: e.to_string(), - }], - }, - Err(e) => vec![WsOutbound::Error { - request_id, - code: "join_channel_failed".into(), - message: e.to_string(), - }], - }, - WsInbound::LeaveChannel { - request_id: _, - channel_id, - } => { - self.manager - .unsubscribe_channel(session.connection_id, channel_id); - vec![] - } - WsInbound::TypingStart { - request_id, - channel_id, - thread_id, - } => { - let _ = self - .manager - .set_typing(channel_id, thread_id, session.user_id); - self.nats - .emit( - &ImNats::typing_subject(channel_id), - request_id, - &TypingEvent { - channel_id, - thread_id, - user_id: session.user_id, - }, - ) - .await; - vec![] - } - WsInbound::TypingStop { - request_id: _, - channel_id, - thread_id, - } => { - let _ = self - .manager - .clear_typing(channel_id, thread_id, session.user_id); - vec![] - } - WsInbound::MessageSend { - request_id, - channel_id, - body, - thread_id, - reply_to, - message_type, - } => { - if body.len() > WS_MAX_MESSAGE_BYTES { - return vec![WsOutbound::Error { - request_id, - code: "message_too_large".into(), - message: "message body too large".into(), - }]; - } - match self.dedup.check_and_mark(request_id, channel_id) { - Ok(true) => {} - Ok(false) => { - return vec![WsOutbound::Error { - request_id, - code: "duplicate".into(), - message: "duplicate message".into(), - }]; - } - Err(e) => tracing::warn!(error = %e, "dedup check failed"), - } - let ctx = ImSession::new(session.user_id); - let params = SendMessageParams { - body, - message_type, - thread_id, - reply_to_message_id: reply_to, - pinned: None, - attachments: None, - embeds: None, - }; - match self - .service - .message_send( - &ctx, - &session.workspace_name, - channel_id, - params, - request_id, - ) - .await - { - Ok(msg) => vec![WsOutbound::SeqAck { - request_id, - channel_id, - seq: msg.seq, - }], - Err(e) => { - if let Err(clear_err) = self.dedup.clear(request_id, channel_id) { - tracing::warn!(error = %clear_err, "dedup clear failed after message send error"); - } - vec![WsOutbound::Error { - request_id, - code: "message_send_failed".into(), - message: e.to_string(), - }] - } - } - } - WsInbound::MessageEdit { - request_id, - channel_id, - message_id, - body, - } => { - if body.len() > WS_MAX_MESSAGE_BYTES { - return vec![WsOutbound::Error { - request_id, - code: "message_too_large".into(), - message: "message body too large".into(), - }]; - } - let ctx = ImSession::new(session.user_id); - let params = EditMessageParams { body }; - match self - .service - .message_edit( - &ctx, - &session.workspace_name, - channel_id, - message_id, - params, - request_id, - ) - .await - { - Ok(_) => vec![], - Err(e) => vec![WsOutbound::Error { - request_id, - code: "message_edit_failed".into(), - message: e.to_string(), - }], - } - } - WsInbound::MessageDelete { - request_id, - channel_id, - message_id, - } => { - let ctx = ImSession::new(session.user_id); - match self - .service - .message_delete( - &ctx, - &session.workspace_name, - channel_id, - message_id, - request_id, - ) - .await - { - Ok(()) => vec![], - Err(e) => vec![WsOutbound::Error { - request_id, - code: "message_delete_failed".into(), - message: e.to_string(), - }], - } - } - WsInbound::PresenceUpdate { - request_id, - status, - custom_status_text, - custom_status_emoji, - } => { - let ctx = ImSession::new(session.user_id); - let params = UpdatePresenceParams { - status, - custom_status_text: custom_status_text.clone(), - custom_status_emoji: custom_status_emoji.clone(), - }; - match self - .service - .presence_update(&ctx, &session.workspace_name, params) - .await - { - Ok(p) => { - self.nats - .emit( - &ImNats::presence_subject(session.user_id), - request_id, - &PresenceEvent { - user_id: session.user_id, - status: p.status.to_string(), - custom_status_text, - custom_status_emoji, - }, - ) - .await; - vec![] - } - Err(e) => vec![WsOutbound::Error { - request_id, - code: "presence_update_failed".into(), - message: e.to_string(), - }], - } - } - WsInbound::ReadReceipt { - request_id, - channel_id, - last_read_message_id, - last_seq, - } => { - if let Some(seq) = last_seq - && let Err(e) = - self.reconnect - .save_read_position(session.user_id, channel_id, seq) - { - tracing::warn!(error = %e, "save read position failed"); - } - vec![WsOutbound::ReadReceiptAck { - request_id, - channel_id, - last_read_message_id, - last_seq, - }] - } - WsInbound::Auth { .. } => unreachable!(), - } - } - - fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) { - let _ = self.sinks.send( - old_id, - WsOutbound::Error { - request_id: Uuid::nil(), - code: "session_replaced".into(), - message: format!("session replaced by {new_id}"), - }, - ); - self.sinks.detach(old_id); - if let Some(old) = self.manager.get_session(old_id) - && let Err(e) = self.manager.unregister_connection(&old) - { - tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed"); - } - } - - async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec { - match self.manager.redeem_token(&token) { - Ok(session) => { - match self.manager.register_connection_with_replacement(&session) { - Ok(Some(old_id)) => { - self.close_replaced_connection(old_id, session.connection_id) - } - Ok(None) => {} - Err(e) => tracing::warn!(error = %e, "register connection failed"), - } - let cid = session.connection_id; - let interval = self.manager.heartbeat_interval_secs(); - self.session = Some(session); - vec![WsOutbound::AuthOk { - request_id, - connection_id: cid, - heartbeat_interval_secs: interval, - }] - } - Err(e) => vec![WsOutbound::AuthError { - request_id, - message: e.to_string(), - }], - } - } -} - -#[allow(dead_code)] -fn request_id_of(msg: &WsInbound) -> Uuid { - match msg { - WsInbound::Auth { request_id, .. } => *request_id, - WsInbound::Heartbeat { request_id } => *request_id, - WsInbound::JoinChannel { request_id, .. } => *request_id, - WsInbound::LeaveChannel { request_id, .. } => *request_id, - WsInbound::TypingStart { request_id, .. } => *request_id, - WsInbound::TypingStop { request_id, .. } => *request_id, - WsInbound::MessageSend { request_id, .. } => *request_id, - WsInbound::MessageEdit { request_id, .. } => *request_id, - WsInbound::MessageDelete { request_id, .. } => *request_id, - WsInbound::PresenceUpdate { request_id, .. } => *request_id, - WsInbound::ReadReceipt { request_id, .. } => *request_id, - } -} diff --git a/immediate/inbound.rs b/immediate/inbound.rs deleted file mode 100644 index 970c794..0000000 --- a/immediate/inbound.rs +++ /dev/null @@ -1,68 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum WsInbound { - Auth { - request_id: Uuid, - token: String, - }, - Heartbeat { - request_id: Uuid, - }, - JoinChannel { - request_id: Uuid, - channel_id: Uuid, - }, - LeaveChannel { - request_id: Uuid, - channel_id: Uuid, - }, - TypingStart { - request_id: Uuid, - channel_id: Uuid, - thread_id: Option, - }, - TypingStop { - request_id: Uuid, - channel_id: Uuid, - thread_id: Option, - }, - MessageSend { - request_id: Uuid, - channel_id: Uuid, - body: String, - #[serde(skip_serializing_if = "Option::is_none")] - thread_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reply_to: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message_type: Option, - }, - MessageEdit { - request_id: Uuid, - channel_id: Uuid, - message_id: Uuid, - body: String, - }, - MessageDelete { - request_id: Uuid, - channel_id: Uuid, - message_id: Uuid, - }, - PresenceUpdate { - request_id: Uuid, - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - custom_status_text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - custom_status_emoji: Option, - }, - ReadReceipt { - request_id: Uuid, - channel_id: Uuid, - last_read_message_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - last_seq: Option, - }, -} diff --git a/immediate/limiter.rs b/immediate/limiter.rs deleted file mode 100644 index 0a7a6b6..0000000 --- a/immediate/limiter.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HandlerLimitError; - -#[derive(Clone)] -pub struct HandlerLimiter { - sem: Arc, - max_inflight: usize, - rejected: Arc, -} - -impl HandlerLimiter { - pub fn new(max_inflight: usize) -> Self { - Self { - sem: Arc::new(Semaphore::new(max_inflight)), - max_inflight, - rejected: Arc::new(AtomicU64::new(0)), - } - } - - pub fn try_acquire(&self) -> Result { - match self.sem.clone().try_acquire_owned() { - Ok(permit) => Ok(permit), - Err(_) => { - self.rejected.fetch_add(1, Ordering::Relaxed); - Err(HandlerLimitError) - } - } - } - - pub fn inflight(&self) -> usize { - self.max_inflight - self.sem.available_permits() - } - - pub fn available(&self) -> usize { - self.sem.available_permits() - } - - pub fn rejected_total(&self) -> u64 { - self.rejected.load(Ordering::Relaxed) - } -} diff --git a/immediate/mod.rs b/immediate/mod.rs deleted file mode 100644 index ea8826e..0000000 --- a/immediate/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -mod bridge; -mod dedup; -mod envelope; -mod handler; -mod inbound; -mod limiter; -mod nats; -mod outbound; -mod rate_limit; -mod reconnect; -mod redis_keys; -mod runtime; -mod seq; -mod session; -mod session_redis; -mod sink; -mod typing; - -pub use bridge::NatsWsBridge; -pub use dedup::DedupManager; -pub use envelope::TransportEnvelope; -pub use inbound::WsInbound; -pub use limiter::HandlerLimiter; -pub use nats::ImNats; -pub use outbound::{ - ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent, - DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction, - MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent, - ThreadAction, ThreadEvent, TypingEvent, WsOutbound, -}; -pub use rate_limit::{LocalRateLimiter, RateLimiter}; -pub use reconnect::ReconnectManager; -pub use runtime::WsRuntime; -pub use seq::SeqAllocator; -pub use session::{WsSession, WsSessionManager, WsSessionState}; -pub use sink::{WsReceiver, WsSender, WsSinkManager}; diff --git a/immediate/nats.rs b/immediate/nats.rs deleted file mode 100644 index c21f7d9..0000000 --- a/immediate/nats.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::sync::Arc; - -use serde::Serialize; -use uuid::Uuid; - -use crate::queue::NatsQueue; - -#[derive(Clone)] -pub struct ImNats { - inner: Arc, -} - -impl ImNats { - pub fn new(nats: Arc) -> Self { - Self { inner: nats } - } - - pub async fn emit(&self, subject: &str, request_id: Uuid, event: &T) { - if let Err(e) = self - .inner - .publish_with_headers( - subject, - &serde_json::to_vec(event).unwrap_or_default(), - vec![("X-Request-Id".into(), request_id.to_string())], - ) - .await - { - tracing::warn!(subject, error = %e, "nats emit failed"); - } - } - - #[inline] - pub fn channel_subject(channel_id: Uuid) -> String { - format!("im.channel.{channel_id}") - } - - #[inline] - pub fn message_subject(channel_id: Uuid) -> String { - format!("im.message.{channel_id}") - } - - #[inline] - pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String { - format!("im.thread.{channel_id}.{thread_id}") - } - - #[inline] - pub fn member_subject(channel_id: Uuid) -> String { - format!("im.member.{channel_id}") - } - - #[inline] - pub fn reaction_subject(channel_id: Uuid) -> String { - format!("im.reaction.{channel_id}") - } - - #[inline] - pub fn typing_subject(channel_id: Uuid) -> String { - format!("im.typing.{channel_id}") - } - - #[inline] - pub fn presence_subject(user_id: Uuid) -> String { - format!("im.presence.{user_id}") - } - - #[inline] - pub fn poll_subject(channel_id: Uuid) -> String { - format!("im.poll.{channel_id}") - } - - #[inline] - pub fn article_subject(channel_id: Uuid) -> String { - format!("im.article.{channel_id}") - } - - #[inline] - pub fn workspace_channels_subject(workspace_name: &str) -> String { - format!("im.ws_channels.{workspace_name}") - } -} diff --git a/immediate/outbound.rs b/immediate/outbound.rs deleted file mode 100644 index 29fd304..0000000 --- a/immediate/outbound.rs +++ /dev/null @@ -1,256 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum WsOutbound { - AuthOk { - request_id: Uuid, - connection_id: Uuid, - heartbeat_interval_secs: u64, - }, - AuthError { - request_id: Uuid, - message: String, - }, - HeartbeatAck { - request_id: Uuid, - timestamp_ms: i64, - }, - Error { - request_id: Uuid, - code: String, - message: String, - }, - Typing { - request_id: Uuid, - data: TypingEvent, - }, - Presence { - request_id: Uuid, - data: PresenceEvent, - }, - Message { - request_id: Uuid, - data: MessageEvent, - }, - Channel { - request_id: Uuid, - data: ChannelEvent, - }, - Thread { - request_id: Uuid, - data: ThreadEvent, - }, - Member { - request_id: Uuid, - data: MemberEvent, - }, - Reaction { - request_id: Uuid, - data: ReactionEvent, - }, - Poll { - request_id: Uuid, - data: PollEvent, - }, - Article { - request_id: Uuid, - data: ArticleEvent, - }, - Category { - request_id: Uuid, - data: CategoryEvent, - }, - Draft { - request_id: Uuid, - data: DraftEvent, - }, - Follow { - request_id: Uuid, - data: FollowEvent, - }, - ReadReceiptAck { - request_id: Uuid, - channel_id: Uuid, - last_read_message_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - last_seq: Option, - }, - SeqAck { - request_id: Uuid, - channel_id: Uuid, - seq: i64, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TypingEvent { - pub channel_id: Uuid, - pub thread_id: Option, - pub user_id: Uuid, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PresenceEvent { - pub user_id: Uuid, - pub status: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub custom_status_text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub custom_status_emoji: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageEvent { - pub channel_id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - pub thread_id: Option, - pub message_id: Uuid, - pub author_id: Uuid, - pub action: MessageAction, - #[serde(skip_serializing_if = "Option::is_none")] - pub body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub seq: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageAction { - Created, - Edited, - Deleted, - Pinned, - Unpinned, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelEvent { - pub channel_id: Uuid, - pub action: ChannelAction, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChannelAction { - Created, - Updated, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThreadEvent { - pub channel_id: Uuid, - pub thread_id: Uuid, - pub action: ThreadAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ThreadAction { - Created, - Updated, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemberEvent { - pub channel_id: Uuid, - pub user_id: Uuid, - pub action: MemberAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MemberAction { - Joined, - Left, - Kicked, - Updated, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReactionEvent { - pub channel_id: Uuid, - pub message_id: Uuid, - pub user_id: Uuid, - pub action: ReactionAction, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ReactionAction { - Added, - Removed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PollEvent { - pub channel_id: Uuid, - pub poll_id: Uuid, - pub action: PollAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum PollAction { - Created, - Voted, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArticleEvent { - pub channel_id: Uuid, - pub article_id: Uuid, - pub action: ArticleAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ArticleAction { - Created, - Updated, - Published, - Unpublished, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CategoryEvent { - pub workspace_name: String, - pub category_id: Uuid, - pub action: CategoryAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CategoryAction { - Created, - Updated, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DraftEvent { - pub channel_id: Uuid, - pub user_id: Uuid, - pub thread_id: Option, - pub action: DraftAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DraftAction { - Saved, - Deleted, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FollowEvent { - pub channel_id: Uuid, - pub follow_id: Uuid, - pub action: FollowAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FollowAction { - Created, - Deleted, - Retried, -} diff --git a/immediate/rate_limit.rs b/immediate/rate_limit.rs deleted file mode 100644 index 27bd7cc..0000000 --- a/immediate/rate_limit.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::time::Instant; - -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::AppResult; -use ::redis::Cmd; - -use super::redis_keys::*; - -pub struct RateLimiter { - redis: AppRedis, - max_per_sec: u32, -} - -impl RateLimiter { - pub fn new(redis: AppRedis) -> Self { - Self { - redis, - max_per_sec: WS_MAX_MESSAGES_PER_SEC, - } - } - - pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self { - Self { redis, max_per_sec } - } - - pub fn check(&self, connection_id: Uuid) -> AppResult { - let key = format!("{WS_RATE_PREFIX}{connection_id}"); - let mut conn = self.redis.get_connection()?; - let count: i64 = Cmd::new() - .arg("INCR") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - if count == 1 { - let _ = Cmd::new() - .arg("EXPIRE") - .arg(&key) - .arg(1_u64) - .query::<()>(&mut *conn.inner_mut()); - } - Ok(count <= self.max_per_sec as i64) - } - - pub fn check_sliding(&self, connection_id: Uuid) -> AppResult { - let key = format!("{WS_RATE_PREFIX}{connection_id}"); - let mut conn = self.redis.get_connection()?; - let count: i64 = Cmd::new() - .arg("INCR") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - if count == 1 { - let _ = Cmd::new() - .arg("EXPIRE") - .arg(&key) - .arg(2_u64) - .query::<()>(&mut *conn.inner_mut()); - } - Ok(count <= self.max_per_sec as i64) - } - - pub fn remaining(&self, connection_id: Uuid) -> AppResult { - let key = format!("{WS_RATE_PREFIX}{connection_id}"); - let mut conn = self.redis.get_connection()?; - let count: Option = Cmd::new() - .arg("GET") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(crate::error::AppError::Redis)?; - Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32)) - } -} - -pub struct LocalRateLimiter { - count: std::sync::atomic::AtomicU32, - start: std::sync::Mutex, - max_per_sec: u32, -} - -impl LocalRateLimiter { - pub fn new(max_per_sec: u32) -> Self { - Self { - count: std::sync::atomic::AtomicU32::new(0), - start: std::sync::Mutex::new(Instant::now()), - max_per_sec, - } - } - - pub fn check(&self) -> bool { - let mut start = self.start.lock().unwrap(); - if start.elapsed().as_secs() >= 1 { - self.count.store(0, std::sync::atomic::Ordering::Relaxed); - *start = Instant::now(); - } - drop(start); - self.count - .fetch_add(1, std::sync::atomic::Ordering::Relaxed) - < self.max_per_sec - } -} diff --git a/immediate/reconnect.rs b/immediate/reconnect.rs deleted file mode 100644 index 6a0023f..0000000 --- a/immediate/reconnect.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::collections::HashMap; - -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::{AppError, AppResult}; -use ::redis::Cmd; - -use super::redis_keys::*; - -pub struct ReconnectManager { - redis: AppRedis, -} - -impl ReconnectManager { - pub fn new(redis: AppRedis) -> Self { - Self { redis } - } - - pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> { - let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); - let mut conn = self.redis.get_connection()?; - Cmd::new() - .arg("SETEX") - .arg(&key) - .arg(WS_RECONNECT_STATE_TTL_SECS) - .arg(seq.to_string()) - .query::<()>(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - Ok(()) - } - - pub fn save_read_positions( - &self, - user_id: Uuid, - positions: &HashMap, - ) -> AppResult<()> { - let mut conn = self.redis.get_connection()?; - for (channel_id, seq) in positions { - let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); - Cmd::new() - .arg("SETEX") - .arg(&key) - .arg(WS_RECONNECT_STATE_TTL_SECS) - .arg(seq.to_string()) - .query::<()>(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - } - Ok(()) - } - - pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult> { - let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); - let mut conn = self.redis.get_connection()?; - let val: Option = Cmd::new() - .arg("GET") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - Ok(val.and_then(|v| v.parse().ok())) - } - - pub fn get_all_positions(&self, user_id: Uuid) -> AppResult> { - let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*"); - let mut conn = self.redis.get_connection()?; - let keys: Vec = Cmd::new() - .arg("KEYS") - .arg(&pattern) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - let mut result = HashMap::new(); - let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len(); - for key in &keys { - if let Some(channel_str) = key.get(prefix_len..) - && let Ok(channel_id) = channel_str.parse::() - { - let val: Option = Cmd::new() - .arg("GET") - .arg(key) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - if let Some(v) = val - && let Ok(seq) = v.parse::() - { - result.insert(channel_id, seq); - } - } - } - Ok(result) - } - - pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> { - let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); - let mut conn = self.redis.get_connection()?; - let _ = Cmd::new() - .arg("DEL") - .arg(&key) - .query::<()>(&mut *conn.inner_mut()); - Ok(()) - } -} diff --git a/immediate/redis_keys.rs b/immediate/redis_keys.rs deleted file mode 100644 index 8371f6e..0000000 --- a/immediate/redis_keys.rs +++ /dev/null @@ -1,19 +0,0 @@ -#![allow(dead_code)] -pub const WS_TOKEN_PREFIX: &str = "im:ws:token:"; -pub const WS_ONLINE_PREFIX: &str = "im:ws:online:"; -pub const WS_CONNS_PREFIX: &str = "im:ws:conns:"; -pub const WS_SEQ_PREFIX: &str = "im:seq:"; -pub const WS_DEDUP_PREFIX: &str = "im:dedup:"; -pub const WS_RATE_PREFIX: &str = "im:rate:"; -pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:"; - -pub const WS_TOKEN_TTL_SECS: u64 = 30; -pub const WS_ONLINE_TTL_SECS: u64 = 60; -pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30; -pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60; -pub const WS_MAX_IDLE_SECS: u64 = 300; -pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024; -pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100; -pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024; -pub const WS_DEDUP_WINDOW_SECS: u64 = 300; -pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400; diff --git a/immediate/runtime.rs b/immediate/runtime.rs deleted file mode 100644 index 6eebc74..0000000 --- a/immediate/runtime.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::sync::Arc; - -use uuid::Uuid; - -use crate::queue::NatsQueue; - -use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager}; - -#[derive(Clone)] -pub struct WsRuntime { - sessions: Arc, - sinks: Arc, - bridge: NatsWsBridge, -} - -impl WsRuntime { - pub fn new(queue: Arc, sessions: Arc) -> Self { - let sinks = Arc::new(WsSinkManager::new()); - let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone()); - Self { - sessions, - sinks, - bridge, - } - } - - pub fn sinks(&self) -> Arc { - self.sinks.clone() - } - - pub fn sessions(&self) -> Arc { - self.sessions.clone() - } - - pub fn attach(&self, connection_id: Uuid) -> WsReceiver { - let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel(); - self.sinks.attach(connection_id, tx); - rx - } - - pub fn detach(&self, connection_id: Uuid) { - self.sinks.detach(connection_id); - self.sessions.unsubscribe_all(connection_id); - } - - pub fn start_nats_bridge(&self) { - let bridge = self.bridge.clone(); - tokio::spawn(async move { - bridge.run_ephemeral("im.>").await; - }); - } -} diff --git a/immediate/seq.rs b/immediate/seq.rs deleted file mode 100644 index 8bedb1d..0000000 --- a/immediate/seq.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicI64, Ordering}; - -use dashmap::DashMap; -use tokio::sync::Mutex; -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::{AppError, AppResult}; -use ::redis::Cmd; - -use super::redis_keys::*; - -struct Segment { - end: i64, - next: AtomicI64, -} - -pub struct SeqAllocator { - redis: AppRedis, - segments: DashMap>, - locks: DashMap>>, - segment_size: u64, -} - -const MAX_RETRIES: u32 = 3; - -impl SeqAllocator { - pub fn new(redis: AppRedis) -> Self { - Self { - redis, - segments: DashMap::new(), - locks: DashMap::new(), - segment_size: WS_SEQ_SEGMENT_SIZE, - } - } - - pub async fn next(&self, channel_id: Uuid) -> AppResult { - for _ in 0..MAX_RETRIES { - if let Some(seq) = self.try_allocate(&channel_id) { - return Ok(seq); - } - let lock = self - .locks - .entry(channel_id) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone(); - let _guard = lock.lock().await; - if let Some(seq) = self.try_allocate(&channel_id) { - return Ok(seq); - } - self.refresh(channel_id).await?; - } - Err(AppError::InternalServerError( - "seq allocation exhausted retries".into(), - )) - } - - pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult { - let key = format!("{WS_SEQ_PREFIX}{channel_id}"); - let mut conn = self.redis.get_connection()?; - let current: i64 = Cmd::new() - .arg("SET") - .arg(&key) - .arg(db_max) - .arg("NX") - .arg("EX") - .arg(86400) - .query::>(&mut *conn.inner_mut()) - .map_err(AppError::Redis)? - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| { - let existing: i64 = Cmd::new() - .arg("GET") - .arg(&key) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis) - .unwrap_or(db_max); - if existing < db_max { db_max } else { existing } - }); - self.segments.remove(&channel_id); - Ok(current) - } - - fn try_allocate(&self, channel_id: &Uuid) -> Option { - let state = self.segments.get(channel_id)?; - let next = state.next.fetch_add(1, Ordering::Relaxed); - if next < state.end { Some(next) } else { None } - } - - async fn refresh(&self, channel_id: Uuid) -> AppResult<()> { - let key = format!("{WS_SEQ_PREFIX}{channel_id}"); - let mut conn = self.redis.get_connection()?; - let counter: i64 = Cmd::new() - .arg("INCRBY") - .arg(&key) - .arg(self.segment_size as i64) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - - let start = counter - self.segment_size as i64 + 1; - let end = counter + 1; - self.segments.insert( - channel_id, - Arc::new(Segment { - end, - next: AtomicI64::new(start), - }), - ); - let _ = Cmd::new() - .arg("EXPIRE") - .arg(&key) - .arg(86400_u64) - .query::<()>(&mut *conn.inner_mut()); - Ok(()) - } -} diff --git a/immediate/session.rs b/immediate/session.rs deleted file mode 100644 index 035143d..0000000 --- a/immediate/session.rs +++ /dev/null @@ -1,301 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::time::Duration; - -use dashmap::DashMap; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::{AppError, AppResult}; -use crate::queue::NatsQueue; -use ::redis::Cmd; - -use super::redis_keys::*; -use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online}; -use super::typing; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum WsSessionState { - Connecting, - Authenticated, - Replaced, - Closing, - Closed, -} - -impl WsSessionState { - pub fn is_deliverable(self) -> bool { - matches!(self, Self::Authenticated) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WsSession { - pub user_id: Uuid, - pub device_id: String, - pub connection_id: Uuid, - pub workspace_name: String, - pub connected_at: i64, - pub authenticated_at: Option, - pub state: WsSessionState, - pub superseded_by: Option, -} - -#[derive(Clone)] -pub struct WsSessionManager { - redis: AppRedis, - #[allow(dead_code)] - nats: Arc, - user_devices: Arc>>, - sessions: Arc>, - channel_routes: Arc>>, - session_channels: Arc>>, -} - -impl WsSessionManager { - pub fn new(redis: AppRedis, nats: Arc) -> Self { - Self { - redis, - nats, - user_devices: Arc::new(DashMap::new()), - sessions: Arc::new(DashMap::new()), - channel_routes: Arc::new(DashMap::new()), - session_channels: Arc::new(DashMap::new()), - } - } - - pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult { - self.issue_token_for_device(user_id, workspace_name, "default") - } - - pub fn issue_token_for_device( - &self, - user_id: Uuid, - workspace_name: &str, - device_id: &str, - ) -> AppResult { - let token = format!("ws_{}", Uuid::now_v7()); - let session = WsSession { - user_id, - device_id: device_id.to_string(), - connection_id: Uuid::nil(), - workspace_name: workspace_name.to_string(), - connected_at: 0, - authenticated_at: None, - state: WsSessionState::Connecting, - superseded_by: None, - }; - let json = serde_json::to_string(&session)?; - let key = format!("{WS_TOKEN_PREFIX}{token}"); - let mut conn = self.redis.get_connection()?; - Cmd::new() - .arg("SETEX") - .arg(&key) - .arg(WS_TOKEN_TTL_SECS) - .arg(&json) - .query::<()>(&mut *conn.inner_mut())?; - Ok(token) - } - - pub fn redeem_token(&self, token: &str) -> AppResult { - let key = format!("{WS_TOKEN_PREFIX}{token}"); - let mut conn = self.redis.get_connection()?; - let json: Option = Cmd::new() - .arg("GETDEL") - .arg(&key) - .query::>(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - let json = json.ok_or(AppError::Unauthorized)?; - let mut session: WsSession = serde_json::from_str(&json) - .map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?; - let now = chrono::Utc::now().timestamp_millis(); - session.connection_id = Uuid::now_v7(); - session.connected_at = now; - session.authenticated_at = Some(now); - session.state = WsSessionState::Authenticated; - session.superseded_by = None; - Ok(session) - } - - pub fn register_connection(&self, session: &WsSession) -> AppResult<()> { - let _ = self.register_connection_with_replacement(session)?; - Ok(()) - } - - pub fn register_connection_with_replacement( - &self, - session: &WsSession, - ) -> AppResult> { - let mut current = session.clone(); - current.state = WsSessionState::Authenticated; - current.superseded_by = None; - self.sessions.insert(current.connection_id, current.clone()); - - let replaced = { - let mut entry = self.user_devices.entry(current.user_id).or_default(); - entry.insert(current.device_id.clone(), current.connection_id) - }; - - if let Some(old_id) = replaced - && old_id != current.connection_id - { - if let Some(mut old) = self.sessions.get_mut(&old_id) { - old.state = WsSessionState::Replaced; - old.superseded_by = Some(current.connection_id); - } - self.unsubscribe_all(old_id); - } - - register_redis_online(&self.redis, ¤t)?; - Ok(replaced.filter(|old| *old != current.connection_id)) - } - - pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> { - let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s); - let current = removed.as_ref().unwrap_or(session); - self.unsubscribe_all(current.connection_id); - - if let Some(mut devices) = self.user_devices.get_mut(¤t.user_id) - && devices.get(¤t.device_id).copied() == Some(current.connection_id) - { - devices.remove(¤t.device_id); - } - self.user_devices - .remove_if(¤t.user_id, |_, devices| devices.is_empty()); - unregister_redis_online(&self.redis, current) - } - - pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> { - if !self.is_deliverable(session.connection_id) { - return Err(AppError::Unauthorized); - } - heartbeat_redis(&self.redis, session) - } - - pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) { - self.channel_routes - .entry(channel_id) - .or_default() - .insert(connection_id); - self.session_channels - .entry(connection_id) - .or_default() - .insert(channel_id); - } - - pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) { - if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) { - sessions.remove(&connection_id); - } - self.channel_routes - .remove_if(&channel_id, |_, sessions| sessions.is_empty()); - if let Some(mut channels) = self.session_channels.get_mut(&connection_id) { - channels.remove(&channel_id); - } - self.session_channels - .remove_if(&connection_id, |_, channels| channels.is_empty()); - } - - pub fn unsubscribe_all(&self, connection_id: Uuid) { - let channels = self - .session_channels - .remove(&connection_id) - .map(|(_, channels)| channels) - .unwrap_or_default(); - for channel_id in channels { - if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) { - sessions.remove(&connection_id); - } - self.channel_routes - .remove_if(&channel_id, |_, sessions| sessions.is_empty()); - } - } - - pub fn subscribers(&self, channel_id: Uuid) -> Vec { - self.channel_routes - .get(&channel_id) - .map(|sessions| sessions.iter().copied().collect()) - .unwrap_or_default() - } - - pub fn user_connections(&self, user_id: Uuid) -> Vec { - self.user_devices - .get(&user_id) - .map(|devices| devices.values().copied().collect()) - .unwrap_or_default() - } - - pub fn workspace_connections(&self, workspace_name: &str) -> Vec { - self.sessions - .iter() - .filter_map(|entry| { - let session = entry.value(); - (session.workspace_name == workspace_name && session.state.is_deliverable()) - .then_some(session.connection_id) - }) - .collect() - } - - pub fn get_session(&self, connection_id: Uuid) -> Option { - self.sessions - .get(&connection_id) - .map(|session| session.clone()) - } - - pub fn is_deliverable(&self, connection_id: Uuid) -> bool { - self.sessions - .get(&connection_id) - .map(|session| session.state.is_deliverable() && session.superseded_by.is_none()) - .unwrap_or(false) - } - - pub fn is_user_online(&self, user_id: Uuid) -> AppResult { - Ok(self - .user_devices - .get(&user_id) - .map(|devices| !devices.is_empty()) - .unwrap_or(false)) - } - - pub fn get_connection_count(&self, user_id: Uuid) -> AppResult { - Ok(self - .user_devices - .get(&user_id) - .map(|devices| devices.len() as u32) - .unwrap_or(0)) - } - - pub fn set_typing( - &self, - channel_id: Uuid, - thread_id: Option, - user_id: Uuid, - ) -> AppResult<()> { - typing::set_typing(&self.redis, channel_id, thread_id, user_id) - } - - pub fn clear_typing( - &self, - channel_id: Uuid, - thread_id: Option, - user_id: Uuid, - ) -> AppResult<()> { - typing::clear_typing(&self.redis, channel_id, thread_id, user_id) - } - - pub fn get_typing_users( - &self, - channel_id: Uuid, - thread_id: Option, - ) -> AppResult> { - typing::get_typing_users(&self.redis, channel_id, thread_id) - } - - pub fn heartbeat_interval(&self) -> Duration { - Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS) - } - pub fn heartbeat_interval_secs(&self) -> u64 { - WS_HEARTBEAT_INTERVAL_SECS - } -} diff --git a/immediate/session_redis.rs b/immediate/session_redis.rs deleted file mode 100644 index 695a433..0000000 --- a/immediate/session_redis.rs +++ /dev/null @@ -1,93 +0,0 @@ -use crate::cache::redis::AppRedis; -use crate::error::{AppError, AppResult}; -use crate::service::im::util::PRESENCE_PREFIX; -use ::redis::Cmd; - -use super::redis_keys::*; -use super::session::WsSession; - -pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> { - let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); - let conn_id = session.connection_id.to_string(); - let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); - let mut conn = redis.get_connection()?; - - Cmd::new() - .arg("SADD") - .arg(&set_key) - .arg(&conn_id) - .query::(&mut *conn.inner_mut())?; - Cmd::new() - .arg("EXPIRE") - .arg(&set_key) - .arg(WS_ONLINE_TTL_SECS) - .query::<()>(&mut *conn.inner_mut())?; - Cmd::new() - .arg("SETEX") - .arg(&meta_key) - .arg(WS_ONLINE_TTL_SECS) - .arg(session.workspace_name.as_str()) - .query::<()>(&mut *conn.inner_mut())?; - Ok(()) -} - -pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> { - let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); - let conn_id = session.connection_id.to_string(); - let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); - let mut conn = redis.get_connection()?; - - Cmd::new() - .arg("SREM") - .arg(&set_key) - .arg(&conn_id) - .query::(&mut *conn.inner_mut())?; - - let remaining: i32 = Cmd::new() - .arg("SCARD") - .arg(&set_key) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - if remaining == 0 { - Cmd::new() - .arg("DEL") - .arg(&set_key) - .query::<()>(&mut *conn.inner_mut())?; - let pk = format!("{PRESENCE_PREFIX}{}", session.user_id); - let _ = Cmd::new() - .arg("DEL") - .arg(&pk) - .query::<()>(&mut *conn.inner_mut()); - } - let _ = Cmd::new() - .arg("DEL") - .arg(&meta_key) - .query::<()>(&mut *conn.inner_mut()); - Ok(()) -} - -pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> { - let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); - let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); - let pk = format!("{PRESENCE_PREFIX}{}", session.user_id); - let mut conn = redis.get_connection()?; - - let _ = Cmd::new() - .arg("EXPIRE") - .arg(&set_key) - .arg(WS_ONLINE_TTL_SECS) - .query::<()>(&mut *conn.inner_mut()); - let _ = Cmd::new() - .arg("SETEX") - .arg(&meta_key) - .arg(WS_ONLINE_TTL_SECS) - .arg(session.workspace_name.as_str()) - .query::<()>(&mut *conn.inner_mut()); - let _ = Cmd::new() - .arg("SETEX") - .arg(&pk) - .arg(WS_ONLINE_TTL_SECS) - .arg("online") - .query::<()>(&mut *conn.inner_mut()); - Ok(()) -} diff --git a/immediate/sink.rs b/immediate/sink.rs deleted file mode 100644 index 23e9092..0000000 --- a/immediate/sink.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::Arc; - -use dashmap::DashMap; -use tokio::sync::mpsc; -use uuid::Uuid; - -use super::WsOutbound; - -pub type WsSender = mpsc::UnboundedSender; -pub type WsReceiver = mpsc::UnboundedReceiver; - -#[derive(Clone, Default)] -pub struct WsSinkManager { - sinks: Arc>, -} - -impl WsSinkManager { - pub fn new() -> Self { - Self::default() - } - - pub fn channel() -> (WsSender, WsReceiver) { - mpsc::unbounded_channel() - } - - pub fn attach(&self, connection_id: Uuid, sender: WsSender) { - self.sinks.insert(connection_id, sender); - } - - pub fn detach(&self, connection_id: Uuid) { - self.sinks.remove(&connection_id); - } - - pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool { - self.sinks - .get(&connection_id) - .map(|sink| sink.send(message).is_ok()) - .unwrap_or(false) - } - - pub fn send_many(&self, ids: I, message: WsOutbound) -> usize - where - I: IntoIterator, - { - ids.into_iter() - .filter(|id| self.send(*id, message.clone())) - .count() - } - - pub fn contains(&self, connection_id: Uuid) -> bool { - self.sinks.contains_key(&connection_id) - } -} diff --git a/immediate/typing.rs b/immediate/typing.rs deleted file mode 100644 index 16a72ec..0000000 --- a/immediate/typing.rs +++ /dev/null @@ -1,71 +0,0 @@ -use uuid::Uuid; - -use crate::cache::redis::AppRedis; -use crate::error::{AppError, AppResult}; -use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS}; -use ::redis::Cmd; - -pub fn set_typing( - redis: &AppRedis, - channel_id: Uuid, - thread_id: Option, - user_id: Uuid, -) -> AppResult<()> { - let key = typing_key(channel_id, thread_id, user_id); - let mut conn = redis.get_connection()?; - Cmd::new() - .arg("SETEX") - .arg(&key) - .arg(TYPING_TTL_SECS as u64) - .arg("1") - .query::<()>(&mut *conn.inner_mut())?; - Ok(()) -} - -pub fn clear_typing( - redis: &AppRedis, - channel_id: Uuid, - thread_id: Option, - user_id: Uuid, -) -> AppResult<()> { - let key = typing_key(channel_id, thread_id, user_id); - let mut conn = redis.get_connection()?; - Cmd::new() - .arg("DEL") - .arg(&key) - .query::<()>(&mut *conn.inner_mut())?; - Ok(()) -} - -pub fn get_typing_users( - redis: &AppRedis, - channel_id: Uuid, - thread_id: Option, -) -> AppResult> { - let pattern = match thread_id { - Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"), - None => format!("{TYPING_PREFIX}{channel_id}:*"), - }; - let mut conn = redis.get_connection()?; - let keys: Vec = Cmd::new() - .arg("KEYS") - .arg(&pattern) - .query(&mut *conn.inner_mut()) - .map_err(AppError::Redis)?; - let mut ids = Vec::with_capacity(keys.len()); - for key in &keys { - if let Some(part) = key.rsplit(':').next() - && let Ok(uid) = part.parse::() - { - ids.push(uid); - } - } - Ok(ids) -} - -fn typing_key(channel_id: Uuid, thread_id: Option, user_id: Uuid) -> String { - match thread_id { - Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"), - None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"), - } -} diff --git a/lib.rs b/lib.rs index e622600..27775f0 100644 --- a/lib.rs +++ b/lib.rs @@ -3,7 +3,7 @@ pub mod cache; pub mod config; pub mod error; pub mod etcd; -pub mod immediate; +pub mod grpc; pub mod models; pub mod pb; pub mod queue; diff --git a/openapi.json b/openapi.json index f9f7178..4c174ea 100644 --- a/openapi.json +++ b/openapi.json @@ -715,6 +715,72 @@ } } }, + "/api/v1/auth/password/change": { + "post": { + "tags": [ + "Auth" + ], + "operationId": "authChangePassword", + "requestBody": { + "description": "Password change parameters (passwords encrypted with session RSA public key)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password changed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "400": { + "description": "Invalid password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/auth/register": { "post": { "tags": [ @@ -969,6 +1035,2749 @@ } } }, + "/api/v1/im/workspaces/{workspace_name}/categories": { + "get": { + "tags": [ + "IM" + ], + "summary": "List categories", + "operationId": "imCategoryList", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Categories listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_ChannelCategory" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "IM" + ], + "operationId": "imCategoryCreate", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Category creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Category created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelCategory" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}": { + "put": { + "tags": [ + "IM" + ], + "summary": "Update a category", + "operationId": "imCategoryUpdate", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "category_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Category update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCategoryParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Category updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelCategory" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or category not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "IM" + ], + "summary": "Delete a category", + "operationId": "imCategoryDelete", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "category_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Category deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or category not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels": { + "get": { + "tags": [ + "IM" + ], + "summary": "List channels", + "operationId": "imChannelList", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_type", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "channel_kind", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "category_id", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "archived", + "in": "query", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Channels listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_ChannelDetail" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "IM" + ], + "summary": "Create a channel", + "operationId": "imChannelCreate", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Channel creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChannelParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Channel created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelDetail" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}": { + "get": { + "tags": [ + "IM" + ], + "summary": "Get a channel", + "operationId": "imChannelGet", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelDetail" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "put": { + "tags": [ + "IM" + ], + "summary": "Update a channel", + "operationId": "imChannelUpdate", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Channel update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateChannelParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Channel updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelDetail" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "IM" + ], + "summary": "Delete a channel", + "operationId": "imChannelDelete", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/join": { + "post": { + "tags": [ + "IM" + ], + "summary": "Join a channel", + "operationId": "imMemberJoin", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Joined channel successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelMember" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/leave": { + "post": { + "tags": [ + "IM" + ], + "summary": "Leave a channel", + "operationId": "imMemberLeave", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Left channel successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members": { + "get": { + "tags": [ + "IM" + ], + "summary": "List channel members", + "operationId": "imMemberList", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Members listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_ChannelMember" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "IM" + ], + "summary": "Invite a member", + "operationId": "imMemberInvite", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Invitation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteMemberParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Member invited successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelMember" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace or channel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}": { + "put": { + "tags": [ + "IM" + ], + "summary": "Update member role", + "operationId": "imMemberUpdate", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Member update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMemberParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Member updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ChannelMember" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace, channel or member not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "IM" + ], + "summary": "Kick a member", + "operationId": "imMemberKick", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member kicked successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace, channel or member not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notifications for the current user", + "operationId": "notificationList", + "parameters": [ + { + "name": "unread_only", + "in": "query", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Notifications listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationDetail" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Clear all notifications (dismiss all)", + "operationId": "notificationClearAll", + "responses": { + "200": { + "description": "All notifications cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_i64" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/blocks": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notification blocks for the current user", + "operationId": "notificationListBlocks", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Blocks listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationBlock" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Notifications" + ], + "summary": "Create a notification block", + "operationId": "notificationCreateBlock", + "requestBody": { + "description": "Block creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Block created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationBlock" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/blocks/{block_id}": { + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Delete a notification block", + "operationId": "notificationDeleteBlock", + "parameters": [ + { + "name": "block_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Block deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Block not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/deliveries": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notification deliveries for the current user", + "operationId": "notificationListDeliveries", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Deliveries listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationDelivery" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/read-all": { + "put": { + "tags": [ + "Notifications" + ], + "summary": "Mark all notifications as read", + "operationId": "notificationMarkAllAsRead", + "responses": { + "200": { + "description": "All notifications marked as read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_i64" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/subscriptions": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notification subscriptions for the current user", + "operationId": "notificationListSubscriptions", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Subscriptions listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationSubscription" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Notifications" + ], + "summary": "Create a notification subscription", + "operationId": "notificationCreateSubscription", + "requestBody": { + "description": "Subscription creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubscriptionParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Subscription created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationSubscription" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/subscriptions/{subscription_id}": { + "put": { + "tags": [ + "Notifications" + ], + "summary": "Update a notification subscription", + "operationId": "notificationUpdateSubscription", + "parameters": [ + { + "name": "subscription_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Subscription update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSubscriptionParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Subscription updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationSubscription" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Subscription not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Delete a notification subscription", + "operationId": "notificationDeleteSubscription", + "parameters": [ + { + "name": "subscription_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Subscription deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Subscription not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/templates": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List notification templates (requires system admin)", + "operationId": "notificationListTemplates", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Templates listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationTemplate" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "System admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Notifications" + ], + "summary": "Create a notification template (requires system admin)", + "operationId": "notificationCreateTemplate", + "requestBody": { + "description": "Template creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Template created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationTemplate" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "System admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/templates/{template_id}": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get a notification template by ID (requires system admin)", + "operationId": "notificationGetTemplate", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Template retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationTemplate" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "System admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "put": { + "tags": [ + "Notifications" + ], + "summary": "Update a notification template (requires system admin)", + "operationId": "notificationUpdateTemplate", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Template update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplateParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Template updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationTemplate" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "System admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Delete a notification template (requires system admin)", + "operationId": "notificationDeleteTemplate", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Template deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "403": { + "description": "System admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/unread-count": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get unread notification count for the current user", + "operationId": "notificationUnreadCount", + "responses": { + "200": { + "description": "Unread count returned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_i64" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}": { + "delete": { + "tags": [ + "Notifications" + ], + "summary": "Delete a notification", + "operationId": "notificationDelete", + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Notification not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}/deliveries": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "List deliveries for a specific notification", + "operationId": "notificationListDeliveriesForNotification", + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Deliveries listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NotificationDelivery" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Notification not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}/dismiss": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Dismiss a notification", + "operationId": "notificationDismiss", + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification dismissed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationDetail" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Notification not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}/read": { + "put": { + "tags": [ + "Notifications" + ], + "summary": "Mark a notification as read", + "operationId": "notificationMarkAsRead", + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification marked as read", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NotificationDetail" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Notification not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/repos/invitations/accept": { "post": { "tags": [ @@ -1204,11 +4013,11 @@ "User" ], "summary": "Delete user account", - "description": "Permanently deletes the authenticated user's account and all associated data.\nRequires authentication.\n\nPreconditions:\n- User must transfer or delete all owned workspaces\n- User must transfer or delete all owned repositories\n\nEffects:\n- All user data is removed (SSH keys, GPG keys, sessions, devices, OAuth links, etc.)\n- User is soft-deleted (marked as deleted, not physically removed)\n- Current session is cleared\n- Account cannot be recovered\n\nReturns success message on completion.", + "description": "Marks the authenticated user's account and all associated data for deletion.\nThe user's data is soft-deleted (marked as deleted, not physically removed).\nA restore link is sent to the user's verified email, valid for 30 days.\nRequires authentication and a verified email address.\n\nPreconditions:\n- User must have at least one verified email address\n- User must transfer or delete all owned workspaces\n- User must transfer or delete all owned repositories\n\nEffects:\n- All user data is soft-deleted (SSH keys, GPG keys, sessions, devices, etc.)\n- Current session is cleared\n- A restore token is generated and sent via email\n- Account can be restored within 30 days using the restore link\n\nReturns success message on completion.", "operationId": "userDeleteAccount", "responses": { "200": { - "description": "Account deleted successfully. All user data has been removed.", + "description": "Account marked for deletion. A restore link has been sent to your email.", "content": { "application/json": { "schema": { @@ -1218,7 +4027,7 @@ } }, "400": { - "description": "Cannot delete: user still owns workspaces or repositories", + "description": "Cannot delete: user still owns workspaces or repositories, or no verified email", "content": { "application/json": { "schema": { @@ -1271,26 +4080,21 @@ "User" ], "summary": "Upload user avatar", - "description": "Uploads a new avatar image for the authenticated user.\nRequires authentication.\n\nParameters:\n- data: Raw avatar image bytes (PNG, JPEG, or WebP, max 5MB)\n- content_type: MIME type of the image (e.g., \"image/png\")\n- file_name: Original file name (used to infer file extension)\n\nEffects:\n- Avatar image is stored in S3-compatible object storage\n- Previous avatar is deleted from storage\n- User's avatar URL is updated\n\nReturns the new avatar URL and storage key.", + "description": "Uploads a new avatar image for the authenticated user.\nRequires authentication. Accepts multipart/form-data with a single \"avatar\" field.\n\nSupported formats: PNG, JPEG, WebP, GIF (max 5MB).\n\nEffects:\n- Avatar image is stored in S3-compatible object storage\n- Previous avatar is deleted from storage\n- User's avatar URL is updated\n\nReturns the new avatar URL and storage key.", "operationId": "userUploadAvatar", "requestBody": { - "description": "Avatar upload parameters", + "description": "Avatar image file in a multipart form field named 'avatar'.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadUserAvatarParams" - } - } - }, - "required": true + "multipart/form-data": {} + } }, "responses": { "200": { - "description": "Avatar uploaded successfully. Returns the new avatar URL and storage key.", + "description": "Avatar uploaded successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_UserAvatarResponse" + "$ref": "#/components/schemas/ApiResponse_AvatarData" } } } @@ -1456,6 +4260,457 @@ ] } }, + "/api/v1/user/blocks": { + "get": { + "tags": [ + "User" + ], + "summary": "List blocked users", + "description": "Returns a paginated list of users blocked by the authenticated user.\nRequires authentication.", + "operationId": "userListBlocks", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of blocks to return (default: 50, max: 100)", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of blocks to skip for pagination (default: 0)", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Blocked users listed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_UserBlock" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/user/blocks/{target_user_id}": { + "post": { + "tags": [ + "User" + ], + "summary": "Block a user", + "description": "Blocks the specified user. Once blocked, neither user can see the other's\ncontent or interact in shared channels. Requires authentication.", + "operationId": "userBlockUser", + "parameters": [ + { + "name": "target_user_id", + "in": "path", + "description": "User ID to block", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlockBody" + } + } + } + }, + "responses": { + "201": { + "description": "User blocked successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserBlock" + } + } + } + }, + "400": { + "description": "Invalid request (e.g., blocking yourself)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "User is already blocked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Unblock a user", + "description": "Removes a previously applied block on the specified user. Requires authentication.", + "operationId": "userUnblockUser", + "parameters": [ + { + "name": "target_user_id", + "in": "path", + "description": "User ID to unblock", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User unblocked successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Block not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/user/follows": { + "get": { + "tags": [ + "User" + ], + "summary": "List followed users", + "description": "Returns a paginated list of users followed by the authenticated user.\nRequires authentication.", + "operationId": "userListFollows", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of follows to return (default: 50, max: 100)", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of follows to skip for pagination (default: 0)", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Follows listed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_UserFollow" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/user/follows/{target_user_id}": { + "post": { + "tags": [ + "User" + ], + "summary": "Follow a user", + "description": "Starts following the specified user. Requires authentication.", + "operationId": "userFollowUser", + "parameters": [ + { + "name": "target_user_id", + "in": "path", + "description": "User ID to follow", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "201": { + "description": "User followed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserFollow" + } + } + } + }, + "400": { + "description": "Invalid request (e.g., following yourself)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Already following this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "User" + ], + "summary": "Unfollow a user", + "description": "Stops following the specified user. Requires authentication.", + "operationId": "userUnfollowUser", + "parameters": [ + { + "name": "target_user_id", + "in": "path", + "description": "User ID to unfollow", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User unfollowed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Follow not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/user/keys/gpg": { "get": { "tags": [ @@ -1464,6 +4719,32 @@ "summary": "List user GPG keys", "description": "Returns all GPG public keys registered by the authenticated user.\nKeys are sorted by creation date (newest first).\nOnly non-revoked keys are included.\nRequires authentication.", "operationId": "userListGpgKeys", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], "responses": { "200": { "description": "GPG keys listed successfully. Returns array of GPG key objects with fingerprints and metadata.", @@ -1656,6 +4937,32 @@ "summary": "List user SSH keys", "description": "Returns all SSH public keys registered by the authenticated user.\nKeys are sorted by creation date (newest first).\nOnly non-revoked keys are included.\nRequires authentication.", "operationId": "userListSshKeys", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], "responses": { "200": { "description": "SSH keys listed successfully. Returns array of SSH key objects with fingerprints and metadata.", @@ -1953,6 +5260,191 @@ ] } }, + "/api/v1/user/presence": { + "get": { + "tags": [ + "User" + ], + "summary": "Get user presence", + "description": "Returns the current presence status for the authenticated user,\nincluding online/offline status, custom status text, and device information.\nRequires authentication.", + "operationId": "userGetPresence", + "responses": { + "200": { + "description": "Presence retrieved successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserPresence" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Presence not found for this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "put": { + "tags": [ + "User" + ], + "summary": "Update user presence", + "description": "Updates the presence status for the authenticated user.\nSupports custom status text, emoji, device type, and IP address.\nCreates a new presence record if one does not exist.\nRequires authentication.", + "operationId": "userUpdatePresence", + "parameters": [ + { + "name": "status", + "in": "path", + "description": "New presence status", + "required": true, + "schema": { + "$ref": "#/components/schemas/PresenceStatus" + } + }, + { + "name": "custom_status_text", + "in": "path", + "description": "Optional custom status text (e.g., \"In a meeting\")", + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "custom_status_emoji", + "in": "path", + "description": "Optional custom status emoji (e.g., \":palm_tree:\")", + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "device_type", + "in": "path", + "description": "Device type the user is currently using", + "required": true, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceType" + } + ] + } + }, + { + "name": "ip_address", + "in": "path", + "description": "IP address of the current session", + "required": true, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePresenceBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Presence updated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserPresence" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/user/profile": { "get": { "tags": [ @@ -2074,6 +5566,32 @@ "summary": "List user devices", "description": "Returns all registered devices for the authenticated user.\nDevices are sorted by last seen time (most recent first).\nIncludes device metadata such as name, type, fingerprint, and trust status.\nRequires authentication.", "operationId": "userListDevices", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], "responses": { "200": { "description": "Devices listed successfully. Returns array of device objects with metadata.", @@ -2265,6 +5783,32 @@ "summary": "List OAuth accounts", "description": "Returns all linked OAuth/third-party login accounts for the authenticated user.\nAccounts are sorted by link date (most recent first).\nIncludes provider information, usernames, and token expiry status.\nRequires authentication.", "operationId": "userListOAuthAccounts", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], "responses": { "200": { "description": "OAuth accounts listed successfully. Returns array of linked OAuth accounts with provider details.", @@ -2600,6 +6144,107 @@ "session_cookie": [] } ] + }, + "post": { + "tags": [ + "User" + ], + "summary": "Create a personal access token", + "description": "Creates a new personal access token (PAT) for the authenticated user.\nThe full token value is returned in the response — this is the ONLY time it will be shown.\nStore it securely; it cannot be retrieved again.\nRequires authentication.", + "operationId": "userCreateToken", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Display name for the token (e.g., \"My CLI Token\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scopes", + "in": "path", + "description": "List of permission scopes assigned to the token", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + } + } + }, + { + "name": "expires_at", + "in": "path", + "description": "Optional expiration date (UTC). If not set, the token never expires.", + "required": true, + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Personal access token created successfully. The raw token value is included in the response and will never be shown again.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CreatePersonalAccessTokenResponse" + } + } + } + }, + "400": { + "description": "Invalid request body (e.g., missing name or scopes)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required or session expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] } }, "/api/v1/user/security/tokens/{token_id}": { @@ -2711,7 +6356,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_Workspace" + "$ref": "#/components/schemas/ApiResponse_Vec_WorkspaceDetail" } } } @@ -2762,7 +6407,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Workspace" + "$ref": "#/components/schemas/ApiResponse_WorkspaceDetail" } } } @@ -2888,7 +6533,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Workspace" + "$ref": "#/components/schemas/ApiResponse_WorkspaceDetail" } } } @@ -2960,7 +6605,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Workspace" + "$ref": "#/components/schemas/ApiResponse_WorkspaceDetail" } } } @@ -3219,6 +6864,78 @@ } }, "/api/v1/workspaces/{workspace_name}/approvals/{approval_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetApproval", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "approval_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Approval retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspacePendingApproval" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Approval not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "put": { "tags": [ "Workspaces" @@ -3448,7 +7165,7 @@ "Workspaces" ], "summary": "Upload workspace avatar", - "description": "Upload an avatar image for a workspace. Requires admin role. Maximum size 5 MB. Supported: png, jpg, gif, webp.", + "description": "Upload an avatar image for a workspace. Requires admin role. Maximum size 5 MB. Supported: png, jpg, gif, webp. Accepts multipart/form-data with a single 'avatar' field.", "operationId": "workspaceUploadAvatar", "parameters": [ { @@ -3459,41 +7176,13 @@ "schema": { "type": "string" } - }, - { - "name": "content_type", - "in": "query", - "description": "MIME type of the uploaded image.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "file_name", - "in": "query", - "description": "Original file name for extension detection.", - "required": false, - "schema": { - "type": "string" - } } ], "requestBody": { - "description": "Raw image bytes.", + "description": "Avatar image file in a multipart form field named 'avatar'.", "content": { - "application/octet-stream": { - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - } - } - }, - "required": true + "multipart/form-data": {} + } }, "responses": { "200": { @@ -3684,6 +7373,83 @@ } } }, + "/api/v1/workspaces/{workspace_name}/billing/history": { + "get": { + "tags": [ + "Workspaces" + ], + "summary": "List billing history", + "description": "Return billing history for a workspace. Requires owner role.", + "operationId": "workspaceBillingHistory", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "description": "Workspace name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "List of billing history entries (currently empty).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiListResponse_Value" + } + } + } + }, + "401": { + "description": "Unauthenticated or insufficient role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, "/api/v1/workspaces/{workspace_name}/branding": { "get": { "tags": [ @@ -3969,6 +7735,170 @@ } }, "/api/v1/workspaces/{workspace_name}/domains/{domain_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetDomain", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Domain retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceDomain" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Domain not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "put": { + "tags": [ + "Workspaces" + ], + "summary": "Update a domain", + "description": "Update a domain name. Requires admin role.", + "operationId": "workspaceUpdateDomain", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "description": "Workspace name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "domain_id", + "in": "path", + "description": "Domain record ID.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Updated domain name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDomainParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Domain updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceDomain" + } + } + } + }, + "400": { + "description": "Domain is empty.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthenticated or insufficient role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Domain not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Database transaction failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + }, "delete": { "tags": [ "Workspaces" @@ -4357,6 +8287,78 @@ } }, "/api/v1/workspaces/{workspace_name}/integrations/{integration_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetIntegration", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "integration_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Integration retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceIntegration" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Integration not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "put": { "tags": [ "Workspaces" @@ -4661,6 +8663,78 @@ } }, "/api/v1/workspaces/{workspace_name}/invitations/{invitation_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetInvitation", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "invitation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Invitation retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceInvitation" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Invitation not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Workspaces" @@ -4860,7 +8934,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_Issue" + "$ref": "#/components/schemas/ApiResponse_Vec_IssueDetail" } } } @@ -4937,7 +9011,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Issue" + "$ref": "#/components/schemas/ApiResponse_IssueDetail" } } } @@ -5035,7 +9109,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Issue" + "$ref": "#/components/schemas/ApiResponse_IssueDetail" } } } @@ -5750,7 +9824,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_IssueComment" + "$ref": "#/components/schemas/ApiResponse_Vec_IssueCommentDetail" } } } @@ -5845,7 +9919,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_IssueComment" + "$ref": "#/components/schemas/ApiResponse_IssueCommentDetail" } } } @@ -8428,6 +12502,78 @@ } }, "/api/v1/workspaces/{workspace_name}/members/{member_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetMember", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "member_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceMember" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Member not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Workspaces" @@ -8655,7 +12801,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_Repo" + "$ref": "#/components/schemas/ApiResponse_Vec_RepoDetail" } } } @@ -8742,7 +12888,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Repo" + "$ref": "#/components/schemas/ApiResponse_RepoDetail" } } } @@ -8849,7 +12995,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Repo" + "$ref": "#/components/schemas/ApiResponse_RepoDetail" } } } @@ -8945,7 +13091,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Repo" + "$ref": "#/components/schemas/ApiResponse_RepoDetail" } } } @@ -9205,14 +13351,11 @@ "tags": [ "Repos" ], - "summary": "List branches in a repository", - "description": "Returns a paginated list of all branches in the repository, sorted by name alphabetically.\nIncludes branch metadata such as:\n- Branch name and commit SHA\n- Protected status\n- Default branch flag\n- Last push information\n\nRequires read access to the repository.", "operationId": "repoListBranches", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -9221,7 +13364,6 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" @@ -9230,7 +13372,6 @@ { "name": "limit", "in": "query", - "description": "Maximum number of branches to return (default: 50, max: 100)", "required": false, "schema": { "type": [ @@ -9243,7 +13384,6 @@ { "name": "offset", "in": "query", - "description": "Number of branches to skip for pagination (default: 0)", "required": false, "schema": { "type": [ @@ -9256,27 +13396,17 @@ ], "responses": { "200": { - "description": "Branches listed successfully. Returns an array of branch objects with metadata.", + "description": "Branches listed successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_RepoBranch" + "$ref": "#/components/schemas/ApiResponse_ListBranchesResponse" } } } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions to access this repository", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -9286,17 +13416,7 @@ } }, "404": { - "description": "Repository or workspace not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", + "description": "Repository not found", "content": { "application/json": { "schema": { @@ -9316,14 +13436,11 @@ "tags": [ "Repos" ], - "summary": "Create a new branch", - "description": "Creates a new branch in the repository based on an existing commit or branch.\nRequires Write role or higher in the repository.\n\nParameters:\n- name: Branch name (1-100 characters, alphanumeric, hyphens, underscores, dots, slashes allowed)\n- from: Source branch name or commit SHA to branch from (defaults to default branch)\n\nReturns the created branch with metadata including the initial commit SHA.", "operationId": "repoCreateBranch", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -9332,7 +13449,6 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" @@ -9340,11 +13456,10 @@ } ], "requestBody": { - "description": "Branch creation parameters", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBranchParams" + "$ref": "#/components/schemas/CreateBranchBody" } } }, @@ -9352,17 +13467,17 @@ }, "responses": { "201": { - "description": "Branch created successfully. Returns the newly created branch with metadata.", + "description": "Branch created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_RepoBranch" + "$ref": "#/components/schemas/ApiResponse_Branch" } } } }, "400": { - "description": "Invalid parameters: name too long, invalid characters, or source branch/commit doesn't exist", + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -9372,17 +13487,7 @@ } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Write role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -9392,27 +13497,7 @@ } }, "404": { - "description": "Repository, workspace, or source branch/commit not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "409": { - "description": "Branch with this name already exists", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error or Git operation failed", + "description": "Repository not found", "content": { "application/json": { "schema": { @@ -9429,19 +13514,85 @@ ] } }, - "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}": { + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetBranch", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "branch_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branch retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Branch" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Branch not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Repos" ], - "summary": "Delete a branch", - "description": "Permanently deletes a branch from the repository. The default branch cannot be deleted.\nRequires Write role or higher in the repository.\n\nEffects:\n- Branch is permanently removed from the repository\n- All commits exclusive to this branch remain accessible via their SHA\n- Open pull requests targeting this branch will be closed\n\nReturns success message on completion.", "operationId": "repoDeleteBranch", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -9450,26 +13601,23 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" } }, { - "name": "branch_id", + "name": "branch_name", "in": "path", - "description": "Branch ID (UUID)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "Branch deleted successfully.", + "description": "Branch deleted", "content": { "application/json": { "schema": { @@ -9479,7 +13627,7 @@ } }, "400": { - "description": "Cannot delete the default branch", + "description": "Cannot delete default branch", "content": { "application/json": { "schema": { @@ -9489,17 +13637,7 @@ } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Write role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -9509,17 +13647,7 @@ } }, "404": { - "description": "Repository, workspace, or branch not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error or Git operation failed", + "description": "Branch not found", "content": { "application/json": { "schema": { @@ -9536,19 +13664,16 @@ ] } }, - "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}/default": { + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}/default": { "put": { "tags": [ "Repos" ], - "summary": "Set default branch", - "description": "Sets a branch as the repository's default branch. The default branch is used for:\n- New pull requests base branch\n- Repository cloning\n- New branch creation base\n\nRequires Admin role or higher in the repository.\n\nReturns success message on completion.", "operationId": "repoSetDefaultBranch", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -9557,26 +13682,23 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" } }, { - "name": "branch_id", + "name": "branch_name", "in": "path", - "description": "Branch ID (UUID)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "Default branch set successfully. All new operations will use this branch as the default.", + "description": "Default branch set", "content": { "application/json": { "schema": { @@ -9586,17 +13708,7 @@ } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Admin role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -9606,17 +13718,7 @@ } }, "404": { - "description": "Repository, workspace, or branch not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", + "description": "Branch not found", "content": { "application/json": { "schema": { @@ -9633,19 +13735,16 @@ ] } }, - "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}/protection": { + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}/protection": { "put": { "tags": [ "Repos" ], - "summary": "Set branch protection", - "description": "Enables or disables protection for a specific branch.\nRequires Admin role or higher in the repository.\n\nEffects:\n- When enabled: prevents force pushes and branch deletion\n- When disabled: allows force pushes and branch deletion\n\nReturns success message on completion.", "operationId": "repoSetBranchProtection", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -9654,25 +13753,21 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" } }, { - "name": "branch_id", + "name": "branch_name", "in": "path", - "description": "Branch ID (UUID)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "requestBody": { - "description": "Branch protection parameters", "content": { "application/json": { "schema": { @@ -9684,7 +13779,7 @@ }, "responses": { "200": { - "description": "Branch protection rules set successfully.", + "description": "Branch protection set", "content": { "application/json": { "schema": { @@ -9693,28 +13788,8 @@ } } }, - "400": { - "description": "Invalid parameters: negative approvals count or conflicting protection settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Admin role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -9724,17 +13799,7 @@ } }, "404": { - "description": "Repository, workspace, or branch not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", + "description": "Branch not found", "content": { "application/json": { "schema": { @@ -10067,6 +14132,109 @@ ] } }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/commit-comments/{comment_id}": { + "put": { + "tags": [ + "Repos" + ], + "operationId": "repoUpdateCommitComment", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "comment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Commit comment update parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCommitCommentParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Commit comment updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoCommitComment" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Commit comment not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/commits/{commit_sha}/comments": { "get": { "tags": [ @@ -10313,6 +14481,97 @@ ] } }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/commits/{push_commit_id}/statuses/{status_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetCommitStatus", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "push_commit_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Commit status retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoCommitStatus" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Commit status not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/deploy-keys": { "get": { "tags": [ @@ -10543,6 +14802,86 @@ } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/deploy-keys/{key_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetDeployKey", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Deploy key retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoDeployKey" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Deploy key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Repos" @@ -10684,7 +15023,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Repo" + "$ref": "#/components/schemas/ApiResponse_RepoDetail" } } } @@ -10755,6 +15094,77 @@ "session_cookie": [] } ] + }, + "delete": { + "tags": [ + "Repos" + ], + "operationId": "repoDeleteFork", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Fork deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Fork not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/forks": { @@ -10870,6 +15280,2193 @@ ] } }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/blame": { + "get": { + "tags": [ + "Git" + ], + "summary": "Blame a file", + "operationId": "gitBlame", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "revision", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Blame retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_BlameResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/blobs": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get blob content", + "operationId": "gitGetBlob", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "revision", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Blob retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Blob" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Blob not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches": { + "get": { + "tags": [ + "Git" + ], + "summary": "List branches", + "operationId": "gitListBranches", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pattern", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Branches listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ListBranchesResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Git" + ], + "summary": "Create a branch", + "operationId": "gitCreateBranch", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Branch creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBranchBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Branch created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Branch" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches/{branch_name}": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get a branch", + "operationId": "gitGetBranch", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "branch_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branch retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Branch" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Branch not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "Git" + ], + "summary": "Delete a branch", + "operationId": "gitDeleteBranch", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "branch_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branch deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/cherry-pick": { + "post": { + "tags": [ + "Git" + ], + "summary": "Cherry-pick a commit", + "operationId": "gitCherryPick", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Cherry-pick parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CherryPickParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Cherry-pick completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CreateCommitResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Cherry-pick conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits": { + "get": { + "tags": [ + "Git" + ], + "summary": "List commits", + "operationId": "gitListCommits", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "revision", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Commits listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ListCommitsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Git" + ], + "summary": "Create a commit", + "operationId": "gitCreateCommit", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Commit creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCommitParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Commit created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CreateCommitResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/{revision}": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get a single commit", + "operationId": "gitGetCommit", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "revision", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Commit retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Commit" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Commit not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/compare": { + "get": { + "tags": [ + "Git" + ], + "summary": "Compare two commits", + "operationId": "gitCompare", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "base", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "head", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Comparison completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CompareCommitsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/conflicts": { + "get": { + "tags": [ + "Git" + ], + "summary": "List merge conflicts", + "operationId": "gitListConflicts", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Conflicts listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ListMergeConflictsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/diff": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get diff between two revisions", + "operationId": "gitDiff", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "base", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "head", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Diff retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_GetDiffResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/diff-stats": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get diff statistics between two revisions", + "operationId": "gitDiffStats", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "base", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "head", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Diff stats retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DiffStats" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/exists": { + "get": { + "tags": [ + "Git" + ], + "summary": "Check if repository exists", + "operationId": "gitRepoExists", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository existence check completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_bool" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/garbage-collect": { + "post": { + "tags": [ + "Git" + ], + "summary": "Run garbage collection", + "operationId": "gitGarbageCollect", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Garbage collection completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepositoryMaintenanceResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/health": { + "get": { + "tags": [ + "Git" + ], + "summary": "Check repository health", + "operationId": "gitRepoHealth", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository health check completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepositoryHealthResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/info": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get repository info", + "operationId": "gitRepoInfo", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository info retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Repository" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/merge": { + "post": { + "tags": [ + "Git" + ], + "summary": "Merge branches", + "operationId": "gitMerge", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Merge parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Merge completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MergeResult" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Merge conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/merge-check": { + "get": { + "tags": [ + "Git" + ], + "summary": "Check if a merge is possible", + "operationId": "gitMergeCheck", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Merge check completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MergeResult" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/rebase": { + "post": { + "tags": [ + "Git" + ], + "summary": "Rebase a branch", + "operationId": "gitRebase", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Rebase parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RebaseParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Rebase completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RebaseResult" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "Rebase conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/revert": { + "post": { + "tags": [ + "Git" + ], + "summary": "Revert a commit", + "operationId": "gitRevert", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Revert parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Revert completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CreateCommitResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/stats": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get repository statistics", + "operationId": "gitRepoStats", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository statistics retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepositoryStatistics" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags": { + "get": { + "tags": [ + "Git" + ], + "summary": "List tags", + "operationId": "gitListTags", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pattern", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Tags listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ListTagsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "post": { + "tags": [ + "Git" + ], + "summary": "Create a tag", + "operationId": "gitCreateTag", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Tag creation parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Tag created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Tag" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags/{tag_name}": { + "delete": { + "tags": [ + "Git" + ], + "summary": "Delete a tag", + "operationId": "gitDeleteTag", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tree": { + "get": { + "tags": [ + "Git" + ], + "summary": "List tree contents", + "operationId": "gitListTree", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "revision", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Tree listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ListTreeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/invitations": { "get": { "tags": [ @@ -11100,6 +17697,86 @@ } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/invitations/{invitation_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetInvitation", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "invitation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Invitation retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoInvitation" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Invitation not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Repos" @@ -12753,6 +19430,86 @@ } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/members/{member_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetMember", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "member_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoMember" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Member not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "delete": { "tags": [ "Repos" @@ -13707,7 +20464,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_PullRequest" + "$ref": "#/components/schemas/ApiResponse_Vec_PullRequestDetail" } } } @@ -13803,7 +20560,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_PullRequest" + "$ref": "#/components/schemas/ApiResponse_PullRequestDetail" } } } @@ -14292,7 +21049,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_PullRequest" + "$ref": "#/components/schemas/ApiResponse_PullRequestDetail" } } } @@ -17591,7 +24348,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_PrReview" + "$ref": "#/components/schemas/ApiResponse_Vec_PrReviewDetail" } } } @@ -17686,7 +24443,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_PrReview" + "$ref": "#/components/schemas/ApiResponse_PrReviewDetail" } } } @@ -17933,7 +24690,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_PrReview" + "$ref": "#/components/schemas/ApiResponse_PrReviewDetail" } } } @@ -18050,7 +24807,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_PrReview" + "$ref": "#/components/schemas/ApiResponse_PrReviewDetail" } } } @@ -18730,6 +25487,86 @@ } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetRelease", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "release_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Release retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoRelease" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Release not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "put": { "tags": [ "Repos" @@ -19631,14 +26468,11 @@ "tags": [ "Repos" ], - "summary": "List tags in a repository", - "description": "Returns a paginated list of all tags in the repository, sorted by creation date (newest first).\nIncludes tag metadata such as:\n- Tag name and commit SHA\n- Tagger information and timestamp\n- Tag message (for annotated tags)\n\nRequires read access to the repository.", "operationId": "repoListTags", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -19647,7 +26481,6 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" @@ -19656,7 +26489,6 @@ { "name": "limit", "in": "query", - "description": "Maximum number of tags to return (default: 50, max: 100)", "required": false, "schema": { "type": [ @@ -19669,7 +26501,6 @@ { "name": "offset", "in": "query", - "description": "Number of tags to skip for pagination (default: 0)", "required": false, "schema": { "type": [ @@ -19682,27 +26513,17 @@ ], "responses": { "200": { - "description": "Tags listed successfully. Returns an array of tag objects with metadata.", + "description": "Tags listed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_RepoTag" + "$ref": "#/components/schemas/ApiResponse_ListTagsResponse" } } } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions to access this repository", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -19712,17 +26533,7 @@ } }, "404": { - "description": "Repository or workspace not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", + "description": "Repository not found", "content": { "application/json": { "schema": { @@ -19742,14 +26553,11 @@ "tags": [ "Repos" ], - "summary": "Create a new tag", - "description": "Creates a new tag in the repository pointing to a specific commit or branch.\nRequires Write role or higher in the repository.\n\nParameters:\n- name: Tag name (1-100 characters, typically follows semantic versioning like v1.0.0)\n- target: Commit SHA or branch name to tag (defaults to HEAD of default branch)\n- message: Optional tag message for annotated tags\n\nReturns the created tag with metadata including the commit SHA.", "operationId": "repoCreateTag", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -19758,7 +26566,6 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" @@ -19766,11 +26573,10 @@ } ], "requestBody": { - "description": "Tag creation parameters", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTagParams" + "$ref": "#/components/schemas/CreateTagBody" } } }, @@ -19778,17 +26584,17 @@ }, "responses": { "201": { - "description": "Tag created successfully. Returns the newly created tag with metadata.", + "description": "Tag created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_RepoTag" + "$ref": "#/components/schemas/ApiResponse_Tag" } } } }, "400": { - "description": "Invalid parameters: name too long, invalid characters, or target commit/branch doesn't exist", + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -19798,17 +26604,7 @@ } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Write role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -19818,27 +26614,7 @@ } }, "404": { - "description": "Repository, workspace, or target commit/branch not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "409": { - "description": "Tag with this name already exists", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error or Git operation failed", + "description": "Repository not found", "content": { "application/json": { "schema": { @@ -19855,19 +26631,16 @@ ] } }, - "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_id}": { - "delete": { + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_name}": { + "get": { "tags": [ "Repos" ], - "summary": "Delete a tag", - "description": "Permanently deletes a tag from the repository. The tagged commit remains accessible via its SHA.\nRequires Write role or higher in the repository.\n\nEffects:\n- Tag is permanently removed from the repository\n- The tagged commit remains in the repository history\n- Releases associated with this tag are not automatically deleted\n\nReturns success message on completion.", - "operationId": "repoDeleteTag", + "operationId": "repoGetTag", "parameters": [ { "name": "workspace_name", "in": "path", - "description": "Workspace name (unique identifier)", "required": true, "schema": { "type": "string" @@ -19876,46 +26649,33 @@ { "name": "repo_name", "in": "path", - "description": "Repository name (unique within the workspace)", "required": true, "schema": { "type": "string" } }, { - "name": "tag_id", + "name": "tag_name", "in": "path", - "description": "Tag ID (UUID)", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "Tag deleted successfully.", + "description": "Tag retrieved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_String" + "$ref": "#/components/schemas/ApiResponse_Tag" } } } }, "401": { - "description": "Authentication required or session expired", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, - "403": { - "description": "Insufficient permissions (requires Write role or higher)", + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -19925,7 +26685,76 @@ } }, "404": { - "description": "Repository, workspace, or tag not found", + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "put": { + "tags": [ + "Repos" + ], + "operationId": "repoUpdateTag", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTagBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tag updated (delete+recreate if renamed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Tag" + } + } + } + }, + "400": { + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -19934,8 +26763,87 @@ } } }, - "500": { - "description": "Internal server error or Git operation failed", + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Tag not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, + "delete": { + "tags": [ + "Repos" + ], + "operationId": "repoDeleteTag", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Tag not found", "content": { "application/json": { "schema": { @@ -19997,7 +26905,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Repo" + "$ref": "#/components/schemas/ApiResponse_RepoDetail" } } } @@ -20683,6 +27591,86 @@ } }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoGetWebhook", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Webhook retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RepoWebhook" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Webhook not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "put": { "tags": [ "Repos" @@ -20895,6 +27883,203 @@ ] } }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}/deliveries": { + "get": { + "tags": [ + "Repos" + ], + "operationId": "repoListWebhookDeliveries", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Webhook deliveries listed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Webhook not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, + "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry": { + "post": { + "tags": [ + "Repos" + ], + "operationId": "repoRetryWebhookDelivery", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Webhook delivery retried successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Webhook or delivery not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + } + }, "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/wiki": { "get": { "tags": [ @@ -21913,6 +29098,69 @@ ] } }, + "/api/v1/workspaces/{workspace_name}/restore": { + "post": { + "tags": [ + "Workspaces" + ], + "summary": "Restore a soft-deleted workspace", + "description": "Restore a workspace that was previously soft-deleted. Requires owner role.", + "operationId": "workspaceRestore", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "description": "Workspace name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Workspace restored.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Unauthenticated or insufficient role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Workspace not found or not deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Database transaction failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, "/api/v1/workspaces/{workspace_name}/settings": { "get": { "tags": [ @@ -22180,7 +29428,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_Workspace" + "$ref": "#/components/schemas/ApiResponse_WorkspaceDetail" } } } @@ -22431,6 +29679,78 @@ } }, "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}": { + "get": { + "tags": [ + "Workspaces" + ], + "operationId": "workspaceGetWebhook", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Webhook retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WorkspaceWebhook" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Webhook not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + } + ] + }, "put": { "tags": [ "Workspaces" @@ -22594,6 +29914,176 @@ } } } + }, + "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}/deliveries": { + "get": { + "tags": [ + "Workspaces" + ], + "summary": "List webhook deliveries", + "description": "Return delivery logs for a webhook. Requires admin role.", + "operationId": "workspaceListWebhookDeliveries", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "description": "Workspace name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "description": "Webhook ID.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "List of deliveries (currently empty).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiListResponse_Value" + } + } + } + }, + "401": { + "description": "Unauthenticated or insufficient role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry": { + "post": { + "tags": [ + "Workspaces" + ], + "summary": "Retry a webhook delivery", + "description": "Retry a failed webhook delivery. Requires admin role.", + "operationId": "workspaceRetryWebhookDelivery", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "description": "Workspace name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "webhook_id", + "in": "path", + "description": "Webhook ID.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delivery_id", + "in": "path", + "description": "Delivery ID.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "202": { + "description": "Retry scheduled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiEmptyResponse" + } + } + } + }, + "401": { + "description": "Unauthenticated or insufficient role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "404": { + "description": "Delivery not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -22783,6 +30273,236 @@ } } }, + "ApiListResponse_Value": { + "type": "object", + "required": [ + "data", + "total", + "page", + "per_page" + ], + "properties": { + "data": { + "type": "array", + "items": {} + }, + "page": { + "type": "integer", + "format": "int64" + }, + "per_page": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResponse_AvatarData": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "avatar_url", + "storage_key" + ], + "properties": { + "avatar_url": { + "type": "string" + }, + "storage_key": { + "type": "string" + } + } + } + } + }, + "ApiResponse_BlameResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "hunks", + "truncated" + ], + "properties": { + "hunks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlameHunk" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "truncated": { + "type": "boolean" + } + } + } + } + }, + "ApiResponse_Blob": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "path", + "mode", + "size", + "data", + "encoding", + "binary", + "truncated", + "is_lfs" + ], + "properties": { + "binary": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "encoding": { + "type": "string" + }, + "is_lfs": { + "type": "boolean" + }, + "mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "path": { + "type": "string" + }, + "recent_commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RecentCommit" + } + ] + }, + "size": { + "type": "integer", + "format": "int64" + }, + "truncated": { + "type": "boolean" + } + } + } + } + }, + "ApiResponse_Branch": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "name", + "full_ref", + "is_default", + "is_head", + "is_merged", + "is_detached" + ], + "properties": { + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "full_ref": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "is_detached": { + "type": "boolean" + }, + "is_head": { + "type": "boolean" + }, + "is_merged": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "target_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BranchUpstream" + } + ] + } + } + } + } + }, "ApiResponse_BranchMergeCheck": { "type": "object", "required": [ @@ -22956,6 +30676,703 @@ } } }, + "ApiResponse_Channel": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "workspace_id", + "created_by", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "available_tags": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Value" + } + ] + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "default_auto_archive_duration": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "default_forum_layout": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumLayout" + } + ] + }, + "default_reaction_emoji": { + "type": [ + "string", + "null" + ] + }, + "default_sort_order": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumSortOrder" + } + ] + }, + "default_thread_rate_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "rate_limit_per_user": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "require_tag": { + "type": [ + "boolean", + "null" + ] + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "ApiResponse_ChannelCategory": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "workspace_id", + "name", + "position", + "collapsed", + "created_by", + "created_at", + "updated_at" + ], + "properties": { + "collapsed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "ApiResponse_ChannelDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "workspace_id", + "creator", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "ApiResponse_ChannelMember": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "channel_id", + "user_id", + "role", + "status", + "muted", + "pinned", + "created_at", + "updated_at" + ], + "properties": { + "channel_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "joined_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "left_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "muted": { + "type": "boolean" + }, + "pinned": { + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "ApiResponse_Commit": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "abbreviated_oid", + "parent_oids", + "subject", + "body", + "message", + "trailers", + "raw" + ], + "properties": { + "abbreviated_oid": { + "type": "string" + }, + "author": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "authored_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Timestamp" + } + ] + }, + "body": { + "type": "string" + }, + "committed_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Timestamp" + } + ] + }, + "committer": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "message": { + "type": "string" + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "parent_oids": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Oid" + } + }, + "raw": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "signature": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/VerifiedSignature" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CommitStats" + } + ] + }, + "subject": { + "type": "string" + }, + "trailers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommitTrailer" + } + }, + "tree_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + } + } + } + } + }, + "ApiResponse_CompareCommitsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "commits" + ], + "properties": { + "commits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Commit" + } + }, + "merge_base": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CommitStats" + } + ] + } + } + } + } + }, "ApiResponse_ContextMe": { "type": "object", "required": [ @@ -23006,6 +31423,35 @@ } } }, + "ApiResponse_CreateCommitResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "branch" + ], + "properties": { + "branch": { + "type": "string" + }, + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + } + } + } + } + }, "ApiResponse_CreateInvitationResponse": { "type": "object", "required": [ @@ -23025,6 +31471,86 @@ } } }, + "ApiResponse_CreatePersonalAccessTokenResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "name", + "scopes", + "token", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + } + }, + "token": { + "type": "string" + } + } + } + } + }, + "ApiResponse_DiffStats": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "additions", + "deletions", + "changed_files" + ], + "properties": { + "additions": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "changed_files": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + } + }, "ApiResponse_EmailResponse": { "type": "object", "required": [ @@ -23103,6 +31629,52 @@ } } }, + "ApiResponse_GetDiffResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "files", + "overflow" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiffFile" + } + }, + "overflow": { + "type": "boolean" + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DiffStats" + } + ] + } + } + } + } + }, "ApiResponse_Issue": { "type": "object", "required": [ @@ -23316,6 +31888,168 @@ } } }, + "ApiResponse_IssueCommentDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "issue_id", + "author", + "body", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": "string", + "format": "uuid" + }, + "reply_to_comment_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "ApiResponse_IssueDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "workspace_id", + "author", + "number", + "title", + "state", + "priority", + "visibility", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "due_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "milestone_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, "ApiResponse_IssueEvent": { "type": "object", "required": [ @@ -23814,6 +32548,904 @@ } } }, + "ApiResponse_ListBranchesResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "branches" + ], + "properties": { + "branches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Branch" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + } + } + }, + "ApiResponse_ListCommitsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "commits" + ], + "properties": { + "commits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Commit" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + } + } + }, + "ApiResponse_ListMergeConflictsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "conflicts" + ], + "properties": { + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + } + } + }, + "ApiResponse_ListTagsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "tags" + ], + "properties": { + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + } + } + } + } + }, + "ApiResponse_ListTreeResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "entries", + "truncated" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TreeEntry" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "truncated": { + "type": "boolean" + } + } + } + } + }, + "ApiResponse_MergeResult": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "status", + "conflicts", + "message" + ], + "properties": { + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "merge_base": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "message": { + "type": "string" + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DiffStats" + } + ] + }, + "status": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "ApiResponse_Notification": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + }, + "ApiResponse_NotificationBlock": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "created_at", + "updated_at" + ], + "properties": { + "channel": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeliveryChannel" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "notification_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + }, + "ApiResponse_NotificationDelivery": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "notification_id", + "user_id", + "channel", + "status", + "attempts", + "created_at", + "updated_at" + ], + "properties": { + "attempts": { + "type": "integer", + "format": "int32" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "delivered_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "destination": { + "type": [ + "string", + "null" + ] + }, + "failed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Provider" + } + ] + }, + "provider_message_id": { + "type": [ + "string", + "null" + ] + }, + "scheduled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "ApiResponse_NotificationDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserBaseInfo" + } + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepoBaseInfo" + } + ] + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + ] + } + } + } + } + }, + "ApiResponse_NotificationSubscription": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "event_types", + "channels", + "level", + "muted", + "created_at", + "updated_at" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "event_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventType" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "level": { + "$ref": "#/components/schemas/SubscriptionLevel" + }, + "muted": { + "type": "boolean" + }, + "muted_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + }, + "ApiResponse_NotificationTemplate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "key", + "notification_type", + "channel", + "locale", + "title_template", + "body_template", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "action_text_template": { + "type": [ + "string", + "null" + ] + }, + "body_template": { + "type": "string" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "subject_template": { + "type": [ + "string", + "null" + ] + }, + "title_template": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_Option_BranchProtectionRule": { "type": "object", "required": [ @@ -24656,6 +34288,73 @@ } } }, + "ApiResponse_PrReviewDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "pull_request_id", + "author", + "state", + "dismissed", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "dismissed": { + "type": "boolean" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "pull_request_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_PrStatus": { "type": "object", "required": [ @@ -24906,6 +34605,178 @@ } } }, + "ApiResponse_PullRequestDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "repo_id", + "author", + "number", + "title", + "state", + "source_repo_id", + "source_branch", + "target_repo_id", + "target_branch", + "head_commit_sha", + "draft", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "base_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "head_commit_sha": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "merged_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "merged_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "repo_id": { + "type": "string", + "format": "uuid" + }, + "source_branch": { + "type": "string" + }, + "source_repo_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "target_branch": { + "type": "string" + }, + "target_repo_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "ApiResponse_RebaseResult": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "status", + "conflicts" + ], + "properties": { + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "head": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "status": { + "type": "integer", + "format": "int32" + } + } + } + } + }, "ApiResponse_Regenerate2FABackupCodesResponse": { "type": "object", "required": [ @@ -25012,9 +34883,30 @@ "storage_path", "git_service", "created_at", - "updated_at" + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" ], "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, "archived_at": { "type": [ "string", @@ -25029,6 +34921,9 @@ "default_branch": { "type": "string" }, + "delete_branch_on_merge": { + "type": "boolean" + }, "deleted_at": { "type": [ "string", @@ -25052,6 +34947,21 @@ "git_service": { "$ref": "#/components/schemas/GitService" }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string", "format": "uuid" @@ -25083,6 +34993,12 @@ "storage_path": { "type": "string" }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, "updated_at": { "type": "string", "format": "date-time" @@ -25418,6 +35334,157 @@ } } }, + "ApiResponse_RepoDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "workspace", + "owner", + "name", + "default_branch", + "visibility", + "status", + "is_fork", + "storage_node_ids", + "primary_storage_node_id", + "storage_path", + "git_service", + "created_at", + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" + ], + "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "forked_from_repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "git_service": { + "$ref": "#/components/schemas/GitService" + }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_fork": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "primary_storage_node_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "storage_node_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "storage_path": { + "type": "string" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + } + } + } + }, "ApiResponse_RepoFork": { "type": "object", "required": [ @@ -25973,6 +36040,186 @@ } } }, + "ApiResponse_Repository": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "bare", + "empty", + "object_format", + "default_branch", + "git_object_directory", + "git_alternate_object_directories" + ], + "properties": { + "bare": { + "type": "boolean" + }, + "default_branch": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "git_alternate_object_directories": { + "type": "array", + "items": { + "type": "string" + } + }, + "git_object_directory": { + "type": "string" + }, + "header": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepositoryHeader" + } + ] + }, + "object_format": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "ApiResponse_RepositoryHealthResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "ok", + "warnings", + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "ok": { + "type": "boolean" + }, + "statistics": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepositoryStatistics" + } + ] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "ApiResponse_RepositoryMaintenanceResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "ok", + "stdout", + "stderr" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + } + } + } + } + }, + "ApiResponse_RepositoryStatistics": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "size_bytes", + "loose_object_count", + "packed_object_count", + "packfile_count", + "reference_count", + "commit_graph_size_bytes", + "multi_pack_index_size_bytes" + ], + "properties": { + "commit_graph_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "loose_object_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "multi_pack_index_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packed_object_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packfile_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "reference_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + }, "ApiResponse_RsaResponse": { "type": "object", "required": [ @@ -26003,6 +36250,91 @@ } } }, + "ApiResponse_Tag": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "name", + "full_ref", + "target_type", + "annotated", + "message", + "raw" + ], + "properties": { + "annotated": { + "type": "boolean" + }, + "full_ref": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "raw": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "signature": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/VerifiedSignature" + } + ] + }, + "tag_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "tagger": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "target_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "target_type": { + "type": "integer", + "format": "int32" + } + } + } + } + }, "ApiResponse_User": { "type": "object", "required": [ @@ -26069,6 +36401,19 @@ ], "format": "date-time" }, + "restore_token_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "restore_token_hash": { + "type": [ + "string", + "null" + ] + }, "role": { "$ref": "#/components/schemas/Role" }, @@ -26149,7 +36494,7 @@ } } }, - "ApiResponse_UserAvatarResponse": { + "ApiResponse_UserBlock": { "type": "object", "required": [ "data" @@ -26158,15 +36503,28 @@ "data": { "type": "object", "required": [ - "avatar_url", - "storage_key" + "blocker_id", + "blocked_id", + "created_at" ], "properties": { - "avatar_url": { - "type": "string" + "blocked_id": { + "type": "string", + "format": "uuid" }, - "storage_key": { - "type": "string" + "blocker_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": [ + "string", + "null" + ] } } } @@ -26244,6 +36602,36 @@ } } }, + "ApiResponse_UserFollow": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "follower_id", + "following_id", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "follower_id": { + "type": "string", + "format": "uuid" + }, + "following_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, "ApiResponse_UserGpgKey": { "type": "object", "required": [ @@ -26497,6 +36885,85 @@ } } }, + "ApiResponse_UserPresence": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "status", + "last_active_at", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "custom_status_emoji": { + "type": [ + "string", + "null" + ] + }, + "custom_status_text": { + "type": [ + "string", + "null" + ] + }, + "device_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceType" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip_address": { + "type": [ + "string", + "null" + ] + }, + "last_active_at": { + "type": "string", + "format": "date-time" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/PresenceStatus" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + } + }, "ApiResponse_UserProfile": { "type": "object", "required": [ @@ -26880,6 +37347,529 @@ } } }, + "ApiResponse_Vec_Channel": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "workspace_id", + "created_by", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "available_tags": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Value" + } + ] + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "default_auto_archive_duration": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "default_forum_layout": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumLayout" + } + ] + }, + "default_reaction_emoji": { + "type": [ + "string", + "null" + ] + }, + "default_sort_order": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumSortOrder" + } + ] + }, + "default_thread_rate_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "rate_limit_per_user": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "require_tag": { + "type": [ + "boolean", + "null" + ] + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_ChannelCategory": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "workspace_id", + "name", + "position", + "collapsed", + "created_by", + "created_at", + "updated_at" + ], + "properties": { + "collapsed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_ChannelDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "workspace_id", + "creator", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_ChannelMember": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "channel_id", + "user_id", + "role", + "status", + "muted", + "pinned", + "created_at", + "updated_at" + ], + "properties": { + "channel_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "joined_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "left_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "muted": { + "type": "boolean" + }, + "pinned": { + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, "ApiResponse_Vec_Issue": { "type": "object", "required": [ @@ -27102,6 +38092,174 @@ } } }, + "ApiResponse_Vec_IssueCommentDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "issue_id", + "author", + "body", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": "string", + "format": "uuid" + }, + "reply_to_comment_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "ApiResponse_Vec_IssueDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "workspace_id", + "author", + "number", + "title", + "state", + "priority", + "visibility", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "due_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "milestone_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, "ApiResponse_Vec_IssueEvent": { "type": "object", "required": [ @@ -27627,6 +38785,697 @@ } } }, + "ApiResponse_Vec_Notification": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_NotificationBlock": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "created_at", + "updated_at" + ], + "properties": { + "channel": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeliveryChannel" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "notification_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_NotificationDelivery": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "notification_id", + "user_id", + "channel", + "status", + "attempts", + "created_at", + "updated_at" + ], + "properties": { + "attempts": { + "type": "integer", + "format": "int32" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "delivered_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "destination": { + "type": [ + "string", + "null" + ] + }, + "failed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Provider" + } + ] + }, + "provider_message_id": { + "type": [ + "string", + "null" + ] + }, + "scheduled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_NotificationDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserBaseInfo" + } + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepoBaseInfo" + } + ] + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + ] + } + } + } + } + } + }, + "ApiResponse_Vec_NotificationSubscription": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "event_types", + "channels", + "level", + "muted", + "created_at", + "updated_at" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "event_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventType" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "level": { + "$ref": "#/components/schemas/SubscriptionLevel" + }, + "muted": { + "type": "boolean" + }, + "muted_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + } + } + }, + "ApiResponse_Vec_NotificationTemplate": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "key", + "notification_type", + "channel", + "locale", + "title_template", + "body_template", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "action_text_template": { + "type": [ + "string", + "null" + ] + }, + "body_template": { + "type": "string" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "subject_template": { + "type": [ + "string", + "null" + ] + }, + "title_template": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, "ApiResponse_Vec_PrAssignee": { "type": "object", "required": [ @@ -28313,6 +40162,76 @@ } } }, + "ApiResponse_Vec_PrReviewDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pull_request_id", + "author", + "state", + "dismissed", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "dismissed": { + "type": "boolean" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "pull_request_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, "ApiResponse_Vec_PrSubscription": { "type": "object", "required": [ @@ -28503,6 +40422,144 @@ } } }, + "ApiResponse_Vec_PullRequestDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "repo_id", + "author", + "number", + "title", + "state", + "source_repo_id", + "source_branch", + "target_repo_id", + "target_branch", + "head_commit_sha", + "draft", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "base_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "head_commit_sha": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "merged_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "merged_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "repo_id": { + "type": "string", + "format": "uuid" + }, + "source_branch": { + "type": "string" + }, + "source_repo_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "target_branch": { + "type": "string" + }, + "target_repo_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, "ApiResponse_Vec_Repo": { "type": "object", "required": [ @@ -28527,9 +40584,30 @@ "storage_path", "git_service", "created_at", - "updated_at" + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" ], "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, "archived_at": { "type": [ "string", @@ -28544,6 +40622,9 @@ "default_branch": { "type": "string" }, + "delete_branch_on_merge": { + "type": "boolean" + }, "deleted_at": { "type": [ "string", @@ -28567,6 +40648,21 @@ "git_service": { "$ref": "#/components/schemas/GitService" }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string", "format": "uuid" @@ -28598,6 +40694,12 @@ "storage_path": { "type": "string" }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, "updated_at": { "type": "string", "format": "date-time" @@ -28946,6 +41048,160 @@ } } }, + "ApiResponse_Vec_RepoDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "workspace", + "owner", + "name", + "default_branch", + "visibility", + "status", + "is_fork", + "storage_node_ids", + "primary_storage_node_id", + "storage_path", + "git_service", + "created_at", + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" + ], + "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "forked_from_repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "git_service": { + "$ref": "#/components/schemas/GitService" + }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_fork": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "primary_storage_node_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "storage_node_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "storage_path": { + "type": "string" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + } + } + } + } + }, "ApiResponse_Vec_RepoFork": { "type": "object", "required": [ @@ -29443,6 +41699,45 @@ } } }, + "ApiResponse_Vec_UserBlock": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "blocker_id", + "blocked_id", + "created_at" + ], + "properties": { + "blocked_id": { + "type": "string", + "format": "uuid" + }, + "blocker_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "ApiResponse_Vec_UserDevice": { "type": "object", "required": [ @@ -29518,6 +41813,39 @@ } } }, + "ApiResponse_Vec_UserFollow": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "follower_id", + "following_id", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "follower_id": { + "type": "string", + "format": "uuid" + }, + "following_id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, "ApiResponse_Vec_UserGpgKey": { "type": "object", "required": [ @@ -30221,6 +42549,93 @@ } } }, + "ApiResponse_Vec_WorkspaceDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "owner", + "name", + "visibility", + "plan", + "status", + "default_role", + "is_personal", + "created_at", + "updated_at" + ], + "properties": { + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_role": { + "type": "string" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_personal": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "plan": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + } + } + } + } + } + }, "ApiResponse_Vec_WorkspaceDomain": { "type": "object", "required": [ @@ -31052,6 +43467,90 @@ } } }, + "ApiResponse_WorkspaceDetail": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "owner", + "name", + "visibility", + "plan", + "status", + "default_role", + "is_personal", + "created_at", + "updated_at" + ], + "properties": { + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_role": { + "type": "string" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_personal": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "plan": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + } + } + } + } + }, "ApiResponse_WorkspaceDomain": { "type": "object", "required": [ @@ -31599,6 +44098,294 @@ } } }, + "ApiResponse_bool": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "boolean" + } + } + }, + "ApiResponse_i64": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "integer", + "format": "int64" + } + } + }, + "AvatarData": { + "type": "object", + "required": [ + "avatar_url", + "storage_key" + ], + "properties": { + "avatar_url": { + "type": "string" + }, + "storage_key": { + "type": "string" + } + } + }, + "BlameHunk": { + "type": "object", + "required": [ + "original_path", + "final_path", + "original_start_line", + "final_start_line", + "line_count", + "boundary", + "lines" + ], + "properties": { + "boundary": { + "type": "boolean" + }, + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "final_path": { + "type": "string" + }, + "final_start_line": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "line_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlameLine" + } + }, + "original_path": { + "type": "string" + }, + "original_start_line": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "BlameLine": { + "type": "object", + "required": [ + "final_line", + "original_line", + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "final_line": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "original_line": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "BlameResponse": { + "type": "object", + "required": [ + "hunks", + "truncated" + ], + "properties": { + "hunks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlameHunk" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "truncated": { + "type": "boolean" + } + } + }, + "Blob": { + "type": "object", + "required": [ + "path", + "mode", + "size", + "data", + "encoding", + "binary", + "truncated", + "is_lfs" + ], + "properties": { + "binary": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "encoding": { + "type": "string" + }, + "is_lfs": { + "type": "boolean" + }, + "mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "path": { + "type": "string" + }, + "recent_commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RecentCommit" + } + ] + }, + "size": { + "type": "integer", + "format": "int64" + }, + "truncated": { + "type": "boolean" + } + } + }, + "BlockBody": { + "type": "object", + "properties": { + "reason": { + "type": [ + "string", + "null" + ], + "description": "Optional reason for blocking" + } + } + }, + "Branch": { + "type": "object", + "required": [ + "name", + "full_ref", + "is_default", + "is_head", + "is_merged", + "is_detached" + ], + "properties": { + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "full_ref": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "is_detached": { + "type": "boolean" + }, + "is_head": { + "type": "boolean" + }, + "is_merged": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "target_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BranchUpstream" + } + ] + } + } + }, "BranchMergeCheck": { "type": "object", "required": [ @@ -31723,6 +44510,29 @@ } } }, + "BranchUpstream": { + "type": "object", + "required": [ + "remote_name", + "remote_url", + "remote_branch_name", + "local_branch_name" + ], + "properties": { + "local_branch_name": { + "type": "string" + }, + "remote_branch_name": { + "type": "string" + }, + "remote_name": { + "type": "string" + }, + "remote_url": { + "type": "string" + } + } + }, "CaptchaQuery": { "type": "object", "required": [ @@ -31775,6 +44585,611 @@ } } }, + "ChangePasswordParams": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + } + }, + "Channel": { + "type": "object", + "required": [ + "id", + "workspace_id", + "created_by", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "available_tags": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Value" + } + ] + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "default_auto_archive_duration": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "default_forum_layout": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumLayout" + } + ] + }, + "default_reaction_emoji": { + "type": [ + "string", + "null" + ] + }, + "default_sort_order": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ForumSortOrder" + } + ] + }, + "default_thread_rate_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "rate_limit_per_user": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "require_tag": { + "type": [ + "boolean", + "null" + ] + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ChannelBaseInfo": { + "type": "object", + "required": [ + "id", + "name", + "channel_type", + "workspace_id", + "archived" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ChannelCategory": { + "type": "object", + "required": [ + "id", + "workspace_id", + "name", + "position", + "collapsed", + "created_by", + "created_at", + "updated_at" + ], + "properties": { + "collapsed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ChannelDetail": { + "type": "object", + "required": [ + "id", + "workspace_id", + "creator", + "name", + "channel_type", + "channel_kind", + "visibility", + "nsfw", + "archived", + "read_only", + "created_at", + "updated_at" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "bitrate": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "$ref": "#/components/schemas/ChannelKind" + }, + "channel_type": { + "$ref": "#/components/schemas/ChannelType" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_message_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": "boolean" + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": "boolean" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "rtc_region": { + "type": [ + "string", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_limit": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ChannelKind": { + "type": "string", + "enum": [ + "Text", + "Voice", + "Stage", + "Forum", + "Announcement", + "Unknown" + ] + }, + "ChannelListFilters": { + "type": "object", + "properties": { + "archived": { + "type": [ + "boolean", + "null" + ] + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "channel_type": { + "type": [ + "string", + "null" + ] + } + } + }, + "ChannelMember": { + "type": "object", + "required": [ + "id", + "channel_id", + "user_id", + "role", + "status", + "muted", + "pinned", + "created_at", + "updated_at" + ], + "properties": { + "channel_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "joined_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_read_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "left_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "muted": { + "type": "boolean" + }, + "pinned": { + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ChannelType": { + "type": "string", + "enum": [ + "Public", + "Private", + "Direct", + "Group", + "Repo", + "System", + "Unknown" + ] + }, + "CherryPickParams": { + "type": "object", + "required": [ + "commit", + "branch" + ], + "properties": { + "branch": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "mainline": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "message": { + "type": [ + "string", + "null" + ] + } + } + }, "ColorScheme": { "type": "string", "enum": [ @@ -31785,6 +45200,253 @@ "Unknown" ] }, + "Commit": { + "type": "object", + "required": [ + "abbreviated_oid", + "parent_oids", + "subject", + "body", + "message", + "trailers", + "raw" + ], + "properties": { + "abbreviated_oid": { + "type": "string" + }, + "author": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "authored_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Timestamp" + } + ] + }, + "body": { + "type": "string" + }, + "committed_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Timestamp" + } + ] + }, + "committer": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "message": { + "type": "string" + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "parent_oids": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Oid" + } + }, + "raw": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "signature": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/VerifiedSignature" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CommitStats" + } + ] + }, + "subject": { + "type": "string" + }, + "trailers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommitTrailer" + } + }, + "tree_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + } + } + }, + "CommitAction": { + "type": "object", + "required": [ + "action", + "file_path" + ], + "properties": { + "action": { + "type": "string" + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "executable": { + "type": [ + "boolean", + "null" + ] + }, + "file_path": { + "type": "string" + }, + "previous_path": { + "type": [ + "string", + "null" + ] + } + } + }, + "CommitStats": { + "type": "object", + "required": [ + "additions", + "deletions", + "changed_files" + ], + "properties": { + "additions": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "changed_files": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "CommitTrailer": { + "type": "object", + "required": [ + "key", + "value", + "separator_present" + ], + "properties": { + "key": { + "type": "string" + }, + "separator_present": { + "type": "boolean" + }, + "value": { + "type": "string" + } + } + }, + "CompareCommitsResponse": { + "type": "object", + "required": [ + "commits" + ], + "properties": { + "commits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Commit" + } + }, + "merge_base": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CommitStats" + } + ] + } + } + }, "ContextMe": { "type": "object", "required": [ @@ -31827,6 +45489,86 @@ } } }, + "CreateBlockParams": { + "type": "object", + "required": [ + "target_type" + ], + "properties": { + "channel": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeliveryChannel" + } + ] + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "notification_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateBranchBody": { + "type": "object", + "required": [ + "branch_name", + "start_point" + ], + "properties": { + "branch_name": { + "type": "string" + }, + "start_point": { + "type": "string" + } + } + }, "CreateBranchParams": { "type": "object", "required": [ @@ -31842,6 +45584,92 @@ } } }, + "CreateCategoryParams": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "CreateChannelParams": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "channel_type": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "nsfw": { + "type": [ + "boolean", + "null" + ] + }, + "parent_channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "rate_limit_per_user": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "visibility": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateCheckRunParams": { "type": "object", "required": [ @@ -31930,6 +45758,61 @@ } } }, + "CreateCommitParams": { + "type": "object", + "required": [ + "branch", + "message", + "actions" + ], + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommitAction" + } + }, + "branch": { + "type": "string" + }, + "force": { + "type": [ + "boolean", + "null" + ] + }, + "message": { + "type": "string" + }, + "start_revision": { + "type": [ + "string", + "null" + ] + } + } + }, + "CreateCommitResponse": { + "type": "object", + "required": [ + "branch" + ], + "properties": { + "branch": { + "type": "string" + }, + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + } + } + }, "CreateCommitStatusParams": { "type": "object", "required": [ @@ -32167,6 +46050,45 @@ } } }, + "CreatePersonalAccessTokenResponse": { + "type": "object", + "required": [ + "id", + "name", + "scopes", + "token", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + } + }, + "token": { + "type": "string" + } + } + }, "CreatePrLabelParams": { "type": "object", "required": [ @@ -32428,12 +46350,36 @@ "name" ], "properties": { + "allow_merge_commit": { + "type": [ + "boolean", + "null" + ] + }, + "allow_rebase_merge": { + "type": [ + "boolean", + "null" + ] + }, + "allow_squash_merge": { + "type": [ + "boolean", + "null" + ] + }, "default_branch": { "type": [ "string", "null" ] }, + "delete_branch_on_merge": { + "type": [ + "boolean", + "null" + ] + }, "description": { "type": [ "string", @@ -32446,6 +46392,30 @@ "null" ] }, + "has_issues": { + "type": [ + "boolean", + "null" + ] + }, + "has_pull_requests": { + "type": [ + "boolean", + "null" + ] + }, + "has_wiki": { + "type": [ + "boolean", + "null" + ] + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, @@ -32465,6 +46435,15 @@ "null" ] }, + "topics": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "visibility": { "type": [ "string", @@ -32505,6 +46484,83 @@ } } }, + "CreateSubscriptionParams": { + "type": "object", + "required": [ + "target_type", + "event_types", + "channels", + "level" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "event_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventType" + } + }, + "level": { + "$ref": "#/components/schemas/SubscriptionLevel" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateTagBody": { + "type": "object", + "required": [ + "tag_name", + "target" + ], + "properties": { + "annotated": { + "type": [ + "boolean", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "tag_name": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, "CreateTagParams": { "type": "object", "required": [ @@ -32529,34 +46585,75 @@ "CreateTemplateParams": { "type": "object", "required": [ - "name", + "key", + "notification_type", + "channel", + "locale", + "title_template", "body_template", - "labels" + "enabled" ], "properties": { + "action_text_template": { + "type": [ + "string", + "null" + ] + }, "body_template": { "type": "string" }, - "description": { - "type": [ - "string", - "null" - ] + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } + "enabled": { + "type": "boolean" }, - "name": { + "key": { "type": "string" }, - "title_template": { + "locale": { + "type": "string" + }, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "subject_template": { "type": [ "string", "null" ] + }, + "title_template": { + "type": "string" + } + } + }, + "CreateTokenBody": { + "type": "object", + "required": [ + "name", + "scopes" + ], + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Optional expiration date (UTC). If not set, the token never expires." + }, + "name": { + "type": "string", + "description": "Display name for the token (e.g., \"My CLI Token\")" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "List of permission scopes assigned to the token" } } }, @@ -32628,6 +46725,19 @@ } } }, + "DeliveryChannel": { + "type": "string", + "enum": [ + "Email", + "Web", + "Push", + "Slack", + "Discord", + "Webhook", + "Sms", + "Unknown" + ] + }, "Density": { "type": "string", "enum": [ @@ -32650,6 +46760,201 @@ "Unknown" ] }, + "DiffFile": { + "type": "object", + "required": [ + "old_path", + "new_path", + "old_mode", + "new_mode", + "change_type", + "binary", + "too_large", + "additions", + "deletions", + "hunks", + "patch", + "similarity" + ], + "properties": { + "additions": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "binary": { + "type": "boolean" + }, + "change_type": { + "type": "integer", + "format": "int32" + }, + "deletions": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "hunks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiffHunk" + } + }, + "new_mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "new_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "new_path": { + "type": "string" + }, + "old_mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "old_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "old_path": { + "type": "string" + }, + "patch": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "similarity": { + "type": "number", + "format": "double" + }, + "too_large": { + "type": "boolean" + } + } + }, + "DiffHunk": { + "type": "object", + "required": [ + "header", + "old_start", + "old_lines", + "new_start", + "new_lines", + "lines" + ], + "properties": { + "header": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiffLine" + } + }, + "new_lines": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "new_start": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "old_lines": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "old_start": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "DiffLine": { + "type": "object", + "required": [ + "type", + "old_line", + "new_line", + "content", + "truncated" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "new_line": { + "type": "integer", + "format": "int32" + }, + "old_line": { + "type": "integer", + "format": "int32" + }, + "truncated": { + "type": "boolean" + }, + "type": { + "type": "integer", + "format": "int32" + } + } + }, + "DiffStats": { + "type": "object", + "required": [ + "additions", + "deletions", + "changed_files" + ], + "properties": { + "additions": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "changed_files": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "DigestFrequency": { "type": "string", "enum": [ @@ -32801,6 +47106,23 @@ } } }, + "ForumLayout": { + "type": "string", + "enum": [ + "Default", + "ListView", + "GalleryView", + "Unknown" + ] + }, + "ForumSortOrder": { + "type": "string", + "enum": [ + "LatestActivity", + "CreationDate", + "Unknown" + ] + }, "Get2FAStatusResponse": { "type": "object", "required": [ @@ -32822,6 +47144,44 @@ } } }, + "GetDiffResponse": { + "type": "object", + "required": [ + "files", + "overflow" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiffFile" + } + }, + "overflow": { + "type": "boolean" + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DiffStats" + } + ] + } + } + }, "GitService": { "type": "string", "enum": [ @@ -32832,6 +47192,40 @@ "Unknown" ] }, + "Identity": { + "type": "object", + "description": "Git identity attached to commits and tags.", + "required": [ + "name", + "email" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "InviteMemberParams": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "role": { + "type": [ + "string", + "null" + ] + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "Issue": { "type": "object", "required": [ @@ -32964,6 +47358,36 @@ } } }, + "IssueBaseInfo": { + "type": "object", + "required": [ + "id", + "number", + "title", + "state", + "workspace_id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "title": { + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, "IssueComment": { "type": "object", "required": [ @@ -33021,6 +47445,152 @@ } } }, + "IssueCommentDetail": { + "type": "object", + "required": [ + "id", + "issue_id", + "author", + "body", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": "string", + "format": "uuid" + }, + "reply_to_comment_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "IssueDetail": { + "type": "object", + "required": [ + "id", + "workspace_id", + "author", + "number", + "title", + "state", + "priority", + "visibility", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "due_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "milestone_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, "IssueEvent": { "type": "object", "required": [ @@ -33538,6 +48108,130 @@ } } }, + "ListBranchesResponse": { + "type": "object", + "required": [ + "branches" + ], + "properties": { + "branches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Branch" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + }, + "ListCommitsResponse": { + "type": "object", + "required": [ + "commits" + ], + "properties": { + "commits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Commit" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + }, + "ListMergeConflictsResponse": { + "type": "object", + "required": [ + "conflicts" + ], + "properties": { + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + } + } + }, + "ListTagsResponse": { + "type": "object", + "required": [ + "tags" + ], + "properties": { + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + } + } + }, + "ListTreeResponse": { + "type": "object", + "required": [ + "entries", + "truncated" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TreeEntry" + } + }, + "page_info": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageInfo" + } + ] + }, + "truncated": { + "type": "boolean" + } + } + }, "LockIssueParams": { "type": "object", "required": [ @@ -33587,6 +48281,117 @@ } } }, + "MergeConflict": { + "type": "object", + "required": [ + "path", + "mode", + "sections", + "binary" + ], + "properties": { + "base_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "binary": { + "type": "boolean" + }, + "mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "ours_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "path": { + "type": "string" + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflictSection" + } + }, + "theirs_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + } + } + }, + "MergeConflictSection": { + "type": "object", + "required": [ + "label", + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "label": { + "type": "string" + } + } + }, + "MergeParams": { + "type": "object", + "required": [ + "target_branch", + "source" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "no_commit": { + "type": [ + "boolean", + "null" + ] + }, + "source": { + "type": "string" + }, + "squash": { + "type": [ + "boolean", + "null" + ] + }, + "target_branch": { + "type": "string" + } + } + }, "MergePrParams": { "type": "object", "properties": { @@ -33616,6 +48421,59 @@ } } }, + "MergeResult": { + "type": "object", + "required": [ + "status", + "conflicts", + "message" + ], + "properties": { + "commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "merge_base": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "message": { + "type": "string" + }, + "stats": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DiffStats" + } + ] + }, + "status": { + "type": "integer", + "format": "int32" + } + } + }, "MergeStrategyKind": { "type": "string", "enum": [ @@ -33650,6 +48508,693 @@ } } }, + "Notification": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "NotificationBlock": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "created_at", + "updated_at" + ], + "properties": { + "channel": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeliveryChannel" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "notification_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "NotificationDelivery": { + "type": "object", + "required": [ + "id", + "notification_id", + "user_id", + "channel", + "status", + "attempts", + "created_at", + "updated_at" + ], + "properties": { + "attempts": { + "type": "integer", + "format": "int32" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "delivered_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "destination": { + "type": [ + "string", + "null" + ] + }, + "failed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Provider" + } + ] + }, + "provider_message_id": { + "type": [ + "string", + "null" + ] + }, + "scheduled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "NotificationDetail": { + "type": "object", + "required": [ + "id", + "user_id", + "notification_type", + "title", + "priority", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "action_url": { + "type": [ + "string", + "null" + ] + }, + "actor": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserBaseInfo" + } + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "channel_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "issue_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "metadata": {}, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "pull_request_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "read_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepoBaseInfo" + } + ] + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TargetType" + } + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + ] + } + } + }, + "NotificationSubscription": { + "type": "object", + "required": [ + "id", + "user_id", + "target_type", + "event_types", + "channels", + "level", + "muted", + "created_at", + "updated_at" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "event_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventType" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "level": { + "$ref": "#/components/schemas/SubscriptionLevel" + }, + "muted": { + "type": "boolean" + }, + "muted_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_type": { + "$ref": "#/components/schemas/TargetType" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "workspace_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "NotificationTemplate": { + "type": "object", + "required": [ + "id", + "key", + "notification_type", + "channel", + "locale", + "title_template", + "body_template", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "action_text_template": { + "type": [ + "string", + "null" + ] + }, + "body_template": { + "type": "string" + }, + "channel": { + "$ref": "#/components/schemas/DeliveryChannel" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "notification_type": { + "$ref": "#/components/schemas/NotificationType" + }, + "subject_template": { + "type": [ + "string", + "null" + ] + }, + "title_template": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "NotificationType": { + "type": "string", + "enum": [ + "Mention", + "Assignment", + "Review", + "Comment", + "Build", + "Security", + "Billing", + "System", + "Digest", + "Unknown" + ] + }, + "Oid": { + "type": "object", + "description": "Canonical object id. `value` preserves the original binary representation used\nby the existing API; `hex` is the normalized lowercase hex form for clients.", + "required": [ + "value", + "hex", + "format" + ], + "properties": { + "format": { + "type": "integer", + "format": "int32" + }, + "hex": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "PageInfo": { + "type": "object", + "required": [ + "next_page_token", + "has_next_page", + "total_count" + ], + "properties": { + "has_next_page": { + "type": "boolean" + }, + "next_page_token": { + "type": "string" + }, + "total_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "Permission": { "type": "string", "enum": [ @@ -34321,6 +49866,65 @@ } } }, + "PrReviewDetail": { + "type": "object", + "required": [ + "id", + "pull_request_id", + "author", + "state", + "dismissed", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "dismissed": { + "type": "boolean" + }, + "dismissed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "dismissed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "pull_request_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "PrStatus": { "type": "object", "required": [ @@ -34419,6 +50023,18 @@ } } }, + "PresenceStatus": { + "type": "string", + "enum": [ + "Online", + "Idle", + "Dnd", + "Invisible", + "Offline", + "Away", + "Unknown" + ] + }, "Priority": { "type": "string", "enum": [ @@ -34580,6 +50196,233 @@ } } }, + "PullRequestBaseInfo": { + "type": "object", + "required": [ + "id", + "number", + "title", + "state", + "repo_id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "repo_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "title": { + "type": "string" + } + } + }, + "PullRequestDetail": { + "type": "object", + "required": [ + "id", + "repo_id", + "author", + "number", + "title", + "state", + "source_repo_id", + "source_branch", + "target_repo_id", + "target_branch", + "head_commit_sha", + "draft", + "locked", + "created_at", + "updated_at" + ], + "properties": { + "author": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "base_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "closed_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "head_commit_sha": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "locked": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": [ + "string", + "null" + ] + }, + "merged_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "merged_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "number": { + "type": "integer", + "format": "int64" + }, + "repo_id": { + "type": "string", + "format": "uuid" + }, + "source_branch": { + "type": "string" + }, + "source_repo_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "target_branch": { + "type": "string" + }, + "target_repo_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "RebaseParams": { + "type": "object", + "required": [ + "branch", + "upstream" + ], + "properties": { + "branch": { + "type": "string" + }, + "upstream": { + "type": "string" + } + } + }, + "RebaseResult": { + "type": "object", + "required": [ + "status", + "conflicts" + ], + "properties": { + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MergeConflict" + } + }, + "head": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Commit" + } + ] + }, + "status": { + "type": "integer", + "format": "int32" + } + } + }, + "RecentCommit": { + "type": "object", + "required": [ + "subject", + "committed_timestamp" + ], + "properties": { + "committed_timestamp": { + "type": "integer", + "format": "int64" + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "subject": { + "type": "string" + } + } + }, "Regenerate2FABackupCodesRequest": { "type": "object", "required": [ @@ -34725,9 +50568,30 @@ "storage_path", "git_service", "created_at", - "updated_at" + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" ], "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, "archived_at": { "type": [ "string", @@ -34742,6 +50606,9 @@ "default_branch": { "type": "string" }, + "delete_branch_on_merge": { + "type": "boolean" + }, "deleted_at": { "type": [ "string", @@ -34765,6 +50632,21 @@ "git_service": { "$ref": "#/components/schemas/GitService" }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string", "format": "uuid" @@ -34796,6 +50678,12 @@ "storage_path": { "type": "string" }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, "updated_at": { "type": "string", "format": "date-time" @@ -34809,6 +50697,35 @@ } } }, + "RepoBaseInfo": { + "type": "object", + "required": [ + "id", + "name", + "workspace_id", + "visibility", + "is_fork" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "is_fork": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, "RepoBranch": { "type": "object", "required": [ @@ -35097,6 +51014,149 @@ } } }, + "RepoDetail": { + "type": "object", + "required": [ + "id", + "workspace", + "owner", + "name", + "default_branch", + "visibility", + "status", + "is_fork", + "storage_node_ids", + "primary_storage_node_id", + "storage_path", + "git_service", + "created_at", + "updated_at", + "topics", + "has_issues", + "has_wiki", + "has_pull_requests", + "allow_forking", + "allow_merge_commit", + "allow_squash_merge", + "allow_rebase_merge", + "delete_branch_on_merge" + ], + "properties": { + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "allow_squash_merge": { + "type": "boolean" + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "forked_from_repo_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "git_service": { + "$ref": "#/components/schemas/GitService" + }, + "has_issues": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_fork": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "primary_storage_node_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "storage_node_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "storage_path": { + "type": "string" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceBaseInfo" + } + } + }, "RepoFork": { "type": "object", "required": [ @@ -35580,6 +51640,177 @@ } } }, + "Repository": { + "type": "object", + "required": [ + "bare", + "empty", + "object_format", + "default_branch", + "git_object_directory", + "git_alternate_object_directories" + ], + "properties": { + "bare": { + "type": "boolean" + }, + "default_branch": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "git_alternate_object_directories": { + "type": "array", + "items": { + "type": "string" + } + }, + "git_object_directory": { + "type": "string" + }, + "header": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepositoryHeader" + } + ] + }, + "object_format": { + "type": "integer", + "format": "int32" + } + } + }, + "RepositoryHeader": { + "type": "object", + "description": "Repository identity used by storage-facing RPCs.", + "required": [ + "storage_name", + "relative_path", + "storage_path" + ], + "properties": { + "relative_path": { + "type": "string", + "description": "Path relative to the storage root, usually ending in `.git` for bare repos." + }, + "storage_name": { + "type": "string", + "description": "Logical storage shard or disk name." + }, + "storage_path": { + "type": "string", + "description": "Optional absolute path for embedded/local deployments." + } + } + }, + "RepositoryHealthResponse": { + "type": "object", + "required": [ + "ok", + "warnings", + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "ok": { + "type": "boolean" + }, + "statistics": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RepositoryStatistics" + } + ] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "RepositoryMaintenanceResponse": { + "type": "object", + "required": [ + "ok", + "stdout", + "stderr" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + } + } + }, + "RepositoryStatistics": { + "type": "object", + "required": [ + "size_bytes", + "loose_object_count", + "packed_object_count", + "packfile_count", + "reference_count", + "commit_graph_size_bytes", + "multi_pack_index_size_bytes" + ], + "properties": { + "commit_graph_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "loose_object_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "multi_pack_index_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packed_object_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packfile_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "reference_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "RequestApprovalParams": { "type": "object", "required": [ @@ -35636,6 +51867,27 @@ } } }, + "RevertParams": { + "type": "object", + "required": [ + "commit", + "branch" + ], + "properties": { + "branch": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "message": { + "type": [ + "string", + "null" + ] + } + } + }, "ReviewApprovalRequest": { "type": "object", "required": [ @@ -35743,8 +51995,41 @@ ], "properties": { "protected": { - "type": "boolean", - "description": "Whether to enable branch protection" + "type": "boolean" + } + } + }, + "Signature": { + "type": "object", + "description": "Git signature with timestamp and timezone offset.", + "required": [ + "timezone_offset" + ], + "properties": { + "identity": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Identity" + } + ] + }, + "timezone_offset": { + "type": "integer", + "format": "int32", + "description": "Offset in minutes east of UTC, as stored by git." + }, + "when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Timestamp" + } + ] } } }, @@ -35841,6 +52126,83 @@ "Unknown" ] }, + "Tag": { + "type": "object", + "required": [ + "name", + "full_ref", + "target_type", + "annotated", + "message", + "raw" + ], + "properties": { + "annotated": { + "type": "boolean" + }, + "full_ref": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "raw": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + "signature": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/VerifiedSignature" + } + ] + }, + "tag_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "tagger": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "target_oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "target_type": { + "type": "integer", + "format": "int32" + } + } + }, "TargetType": { "type": "string", "enum": [ @@ -35869,6 +52231,23 @@ "Unknown" ] }, + "Timestamp": { + "type": "object", + "required": [ + "seconds", + "nanos" + ], + "properties": { + "nanos": { + "type": "integer", + "format": "int32" + }, + "seconds": { + "type": "integer", + "format": "int64" + } + } + }, "TransferIssueParams": { "type": "object", "required": [ @@ -35907,6 +52286,61 @@ } } }, + "TreeEntry": { + "type": "object", + "required": [ + "name", + "path", + "type", + "mode", + "size", + "is_lfs" + ], + "properties": { + "is_lfs": { + "type": "boolean" + }, + "mode": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "oid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Oid" + } + ] + }, + "path": { + "type": "string" + }, + "recent_commit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RecentCommit" + } + ] + }, + "size": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "integer", + "format": "int32" + } + } + }, "UpdateBillingParams": { "type": "object", "properties": { @@ -35978,6 +52412,98 @@ } } }, + "UpdateCategoryParams": { + "type": "object", + "properties": { + "collapsed": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "UpdateChannelParams": { + "type": "object", + "properties": { + "archived": { + "type": [ + "boolean", + "null" + ] + }, + "category_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "nsfw": { + "type": [ + "boolean", + "null" + ] + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "rate_limit_per_user": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "read_only": { + "type": [ + "boolean", + "null" + ] + }, + "topic": { + "type": [ + "string", + "null" + ] + }, + "visibility": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateCheckRunParams": { "type": "object", "properties": { @@ -36012,6 +52538,28 @@ } } }, + "UpdateCommitCommentParams": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + } + } + }, + "UpdateDomainParams": { + "type": "object", + "required": [ + "domain" + ], + "properties": { + "domain": { + "type": "string" + } + } + }, "UpdateIntegrationParams": { "type": "object", "properties": { @@ -36111,6 +52659,29 @@ } } }, + "UpdateMemberParams": { + "type": "object", + "properties": { + "muted": { + "type": [ + "boolean", + "null" + ] + }, + "pinned": { + "type": [ + "boolean", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateMemberRoleParams": { "type": "object", "required": [ @@ -36245,6 +52816,50 @@ } } }, + "UpdatePresenceBody": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "custom_status_emoji": { + "type": [ + "string", + "null" + ], + "description": "Optional custom status emoji (e.g., \":palm_tree:\")" + }, + "custom_status_text": { + "type": [ + "string", + "null" + ], + "description": "Optional custom status text (e.g., \"In a meeting\")" + }, + "device_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceType", + "description": "Device type the user is currently using" + } + ] + }, + "ip_address": { + "type": [ + "string", + "null" + ], + "description": "IP address of the current session" + }, + "status": { + "$ref": "#/components/schemas/PresenceStatus", + "description": "New presence status" + } + } + }, "UpdateProtectionRuleParams": { "type": "object", "properties": { @@ -36389,24 +53004,87 @@ "UpdateRepoParams": { "type": "object", "properties": { + "allow_forking": { + "type": [ + "boolean", + "null" + ] + }, + "allow_merge_commit": { + "type": [ + "boolean", + "null" + ] + }, + "allow_rebase_merge": { + "type": [ + "boolean", + "null" + ] + }, + "allow_squash_merge": { + "type": [ + "boolean", + "null" + ] + }, "default_branch": { "type": [ "string", "null" ] }, + "delete_branch_on_merge": { + "type": [ + "boolean", + "null" + ] + }, "description": { "type": [ "string", "null" ] }, + "has_issues": { + "type": [ + "boolean", + "null" + ] + }, + "has_pull_requests": { + "type": [ + "boolean", + "null" + ] + }, + "has_wiki": { + "type": [ + "boolean", + "null" + ] + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, "name": { "type": [ "string", "null" ] }, + "topics": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "visibility": { "type": [ "string", @@ -36415,12 +53093,92 @@ } } }, + "UpdateSubscriptionParams": { + "type": "object", + "properties": { + "channels": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "event_types": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/EventType" + } + }, + "level": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SubscriptionLevel" + } + ] + }, + "muted": { + "type": [ + "boolean", + "null" + ] + }, + "muted_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "UpdateTagBody": { + "type": "object", + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "new_name": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdateTagParams": { + "type": "object", + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateTemplateParams": { "type": "object", "properties": { - "active": { + "action_text_template": { "type": [ - "boolean", + "string", "null" ] }, @@ -36430,22 +53188,13 @@ "null" ] }, - "description": { + "enabled": { "type": [ - "string", + "boolean", "null" ] }, - "labels": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "name": { + "subject_template": { "type": [ "string", "null" @@ -36772,34 +53521,6 @@ } } }, - "UploadUserAvatarParams": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "content_type": { - "type": [ - "string", - "null" - ] - }, - "data": { - "type": "array", - "items": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - "file_name": { - "type": [ - "string", - "null" - ] - } - } - }, "User": { "type": "object", "required": [ @@ -36860,6 +53581,19 @@ ], "format": "date-time" }, + "restore_token_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "restore_token_hash": { + "type": [ + "string", + "null" + ] + }, "role": { "$ref": "#/components/schemas/Role" }, @@ -36930,21 +53664,66 @@ } } }, - "UserAvatarResponse": { + "UserBaseInfo": { "type": "object", "required": [ - "avatar_url", - "storage_key" + "id", + "username", + "is_bot" ], "properties": { "avatar_url": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "storage_key": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_bot": { + "type": "boolean" + }, + "username": { "type": "string" } } }, + "UserBlock": { + "type": "object", + "required": [ + "blocker_id", + "blocked_id", + "created_at" + ], + "properties": { + "blocked_id": { + "type": "string", + "format": "uuid" + }, + "blocker_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": [ + "string", + "null" + ] + } + } + }, "UserDevice": { "type": "object", "required": [ @@ -37009,6 +53788,28 @@ } } }, + "UserFollow": { + "type": "object", + "required": [ + "follower_id", + "following_id", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "follower_id": { + "type": "string", + "format": "uuid" + }, + "following_id": { + "type": "string", + "format": "uuid" + } + } + }, "UserGpgKey": { "type": "object", "required": [ @@ -37230,6 +54031,77 @@ } } }, + "UserPresence": { + "type": "object", + "required": [ + "id", + "user_id", + "status", + "last_active_at", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "custom_status_emoji": { + "type": [ + "string", + "null" + ] + }, + "custom_status_text": { + "type": [ + "string", + "null" + ] + }, + "device_type": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceType" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip_address": { + "type": [ + "string", + "null" + ] + }, + "last_active_at": { + "type": "string", + "format": "date-time" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/PresenceStatus" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "UserProfile": { "type": "object", "required": [ @@ -37465,6 +54337,38 @@ } }, "Value": {}, + "VerifiedSignature": { + "type": "object", + "required": [ + "verified", + "reason", + "signature", + "payload", + "key_fingerprint", + "signer" + ], + "properties": { + "key_fingerprint": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "reason": { + "type": "integer", + "format": "int32" + }, + "signature": { + "type": "string" + }, + "signer": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, "Verify2FAParams": { "type": "object", "required": [ @@ -37581,6 +54485,31 @@ } } }, + "WikiPageBaseInfo": { + "type": "object", + "required": [ + "id", + "title", + "slug", + "repo_id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "repo_id": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "WikiPageRevision": { "type": "object", "required": [ @@ -37776,6 +54705,32 @@ } } }, + "WorkspaceBaseInfo": { + "type": "object", + "required": [ + "id", + "name", + "visibility" + ], + "properties": { + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + } + } + }, "WorkspaceBilling": { "type": "object", "required": [ @@ -37919,6 +54874,82 @@ } } }, + "WorkspaceDetail": { + "type": "object", + "required": [ + "id", + "owner", + "name", + "visibility", + "plan", + "status", + "default_role", + "is_personal", + "created_at", + "updated_at" + ], + "properties": { + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "default_role": { + "type": "string" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_personal": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/UserBaseInfo" + }, + "plan": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Status" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + } + } + }, "WorkspaceDomain": { "type": "object", "required": [ @@ -38495,6 +55526,18 @@ { "name": "Wiki", "description": "Wiki page management including CRUD operations, revision history, version comparison, and page reversion." + }, + { + "name": "Notifications", + "description": "User notification management including listing, reading, dismissing, deleting, subscriptions, blocks, deliveries, and templates." + }, + { + "name": "Git", + "description": "Git-level operations including commits, branches, merges, rebase, blame, tree, blob, tags, and repository health/statistics endpoints." + }, + { + "name": "IM", + "description": "Channel management, member administration, and category organization." } ] } \ No newline at end of file diff --git a/pb/appks.rs b/pb/appks.rs new file mode 100644 index 0000000..132e182 --- /dev/null +++ b/pb/appks.rs @@ -0,0 +1,5 @@ +// Generated from proto/this/*.proto (package appks.v1) +// Compiled via tonic-build in build.rs using OUT_DIR + include! +// Build server = true, build client = false (appks is the server side). + +include!(concat!(env!("OUT_DIR"), "/appks.v1.rs")); diff --git a/pb/im.rs b/pb/im.rs new file mode 100644 index 0000000..2e91a17 --- /dev/null +++ b/pb/im.rs @@ -0,0 +1,5 @@ +// Generated from proto/this/im/*.proto (package appks.im.v1) +// Compiled via tonic-build in build.rs using OUT_DIR + include! +// Build server = true, build client = false (appks is the server side for IM RPCs). + +include!(concat!(env!("OUT_DIR"), "/appks.im.v1.rs")); diff --git a/pb/mod.rs b/pb/mod.rs index 0b946cf..3ddabd9 100644 --- a/pb/mod.rs +++ b/pb/mod.rs @@ -1,8 +1,37 @@ +pub mod appks; pub mod email; +pub mod im; pub mod repo; +use serde::{Deserialize, Serialize}; use tonic::transport::{Channel, Endpoint}; +#[derive(Clone, PartialEq, Eq, Hash, prost::Message, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Timestamp { + #[prost(int64, tag = "1")] + pub seconds: i64, + #[prost(int32, tag = "2")] + pub nanos: i32, +} + +impl From for Timestamp { + fn from(t: prost_types::Timestamp) -> Self { + Self { + seconds: t.seconds, + nanos: t.nanos, + } + } +} + +impl From for prost_types::Timestamp { + fn from(t: Timestamp) -> Self { + Self { + seconds: t.seconds, + nanos: t.nanos, + } + } +} + #[derive(Clone)] pub struct RepoClient { pub repository: repo::repository_service_client::RepositoryServiceClient, @@ -15,6 +44,8 @@ pub struct RepoClient { pub blame: repo::blame_service_client::BlameServiceClient, pub archive: repo::archive_service_client::ArchiveServiceClient, pub pack: repo::pack_service_client::PackServiceClient, + pub ref_: repo::ref_service_client::RefServiceClient, + pub remote: repo::remote_service_client::RemoteServiceClient, } impl RepoClient { @@ -41,7 +72,9 @@ impl RepoClient { merge: repo::merge_service_client::MergeServiceClient::new(channel.clone()), blame: repo::blame_service_client::BlameServiceClient::new(channel.clone()), archive: repo::archive_service_client::ArchiveServiceClient::new(channel.clone()), - pack: repo::pack_service_client::PackServiceClient::new(channel), + pack: repo::pack_service_client::PackServiceClient::new(channel.clone()), + ref_: repo::ref_service_client::RefServiceClient::new(channel.clone()), + remote: repo::remote_service_client::RemoteServiceClient::new(channel), } } } @@ -82,3 +115,18 @@ impl std::ops::DerefMut for EmailClient { &mut self.inner } } + +// Section: Appks gRPC server traits +// +// Core services (package appks.v1) live in pb::appks:: +// - RepoService +// +// IM services (package appks.im.v1) live in pb::im:: +// - ChannelService, MemberService, PermissionService +// - InternalAuthService +// - ChannelRoleService, ChannelInvitationService, ChannelWebhookService +// - ChannelSlashCommandService, ChannelRepoLinkService, ImIntegrationService +// - CustomEmojiService, ForumTagService, VoiceService, StageService +// - ChannelAuditService +// +// Implementations are in grpc/ and wired into the tonic server in grpc/mod.rs. diff --git a/proto/git/commit.proto b/proto/git/commit.proto index ec293fe..419d5c8 100644 --- a/proto/git/commit.proto +++ b/proto/git/commit.proto @@ -154,6 +154,102 @@ message CompareCommitsResponse { Oid merge_base = 4; } + +message FindCommitRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + bool include_stats = 3; +} + +message ListCommitsByOidRequest { + RepositoryHeader repository = 1; + repeated bytes oids = 2; // binary OID values + bool include_stats = 3; +} + +message ListCommitsByOidResponse { + repeated Commit commits = 1; +} + +message CommitIsAncestorRequest { + RepositoryHeader repository = 1; + string ancestor_oid = 2; + string descendant_oid = 3; +} + +message CommitIsAncestorResponse { + bool is_ancestor = 1; +} + +message CheckObjectsExistRequest { + RepositoryHeader repository = 1; + repeated string revisions = 2; // hex OIDs or rev expressions +} + +message RevisionExistence { + string revision = 1; + bool exists = 2; +} + +message CheckObjectsExistResponse { + repeated RevisionExistence revisions = 1; +} + +message CommitsByMessageRequest { + RepositoryHeader repository = 1; + string query = 2; // regex or literal to search in commit messages + string revision = 3; // limit to this branch/ref (empty = all branches) + uint32 limit = 4; + uint32 offset = 5; + bool case_insensitive = 6; +} + +message CommitsByMessageResponse { + repeated Commit commits = 1; +} + + +message GetCommitStatsRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; +} + +message LastCommitForPathRequest { + RepositoryHeader repository = 1; + string path = 2; + string revision = 3; // limit history to this ref + bool literal_pathspec = 4; +} + +message LastCommitForPathResponse { + Commit commit = 1; + string path = 2; +} + + +message CountCommitsRequest { + RepositoryHeader repository = 1; + string revision = 2; + string path = 3; + string since = 4; // ISO 8601 date + string until = 5; +} + +message CountCommitsResponse { + uint64 count = 1; +} + +message CountDivergingCommitsRequest { + RepositoryHeader repository = 1; + string left = 2; + string right = 3; +} + +message CountDivergingCommitsResponse { + uint64 left_count = 1; + uint64 right_count = 2; +} + service CommitService { rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse); rpc GetCommit(GetCommitRequest) returns (Commit); @@ -162,4 +258,15 @@ service CommitService { rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse); rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse); rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse); + + rpc FindCommit(FindCommitRequest) returns (Commit); + rpc ListCommitsByOid(ListCommitsByOidRequest) returns (ListCommitsByOidResponse); + rpc CommitIsAncestor(CommitIsAncestorRequest) returns (CommitIsAncestorResponse); + rpc CheckObjectsExist(CheckObjectsExistRequest) returns (CheckObjectsExistResponse); + rpc CommitsByMessage(CommitsByMessageRequest) returns (CommitsByMessageResponse); + rpc GetCommitStats(GetCommitStatsRequest) returns (CommitStats); + rpc LastCommitForPath(LastCommitForPathRequest) returns (LastCommitForPathResponse); + + rpc CountCommits(CountCommitsRequest) returns (CountCommitsResponse); + rpc CountDivergingCommits(CountDivergingCommitsRequest) returns (CountDivergingCommitsResponse); } diff --git a/proto/git/diff.proto b/proto/git/diff.proto index 8d6b346..afe4d88 100644 --- a/proto/git/diff.proto +++ b/proto/git/diff.proto @@ -132,9 +132,67 @@ message GetDiffStatsRequest { DiffOptions options = 4; } + +message RawDiffRequest { + RepositoryHeader repository = 1; + string base = 2; // revision or OID + string head = 3; + DiffOptions options = 4; +} + +message RawDiffResponse { + bytes data = 1; +} + +message RawPatchRequest { + RepositoryHeader repository = 1; + string base = 2; + string head = 3; +} + +message RawPatchResponse { + bytes data = 1; +} + + +message FindChangedPathsRequest { + RepositoryHeader repository = 1; + string base = 2; + string head = 3; + repeated string paths = 4; // filter to these paths +} + +message ChangedPath { + enum Status { + CHANGED_PATH_STATUS_UNSPECIFIED = 0; + CHANGED_PATH_STATUS_ADDED = 1; + CHANGED_PATH_STATUS_MODIFIED = 2; + CHANGED_PATH_STATUS_DELETED = 3; + CHANGED_PATH_STATUS_RENAMED = 4; + CHANGED_PATH_STATUS_COPIED = 5; + CHANGED_PATH_STATUS_TYPE_CHANGED = 6; + } + + Status status = 1; + string old_path = 2; + string new_path = 3; + uint32 additions = 4; + uint32 deletions = 5; + bool binary = 6; +} + +message FindChangedPathsResponse { + repeated ChangedPath paths = 1; +} + service DiffService { rpc GetDiff(GetDiffRequest) returns (GetDiffResponse); rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse); rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse); rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats); + + rpc RawDiff(RawDiffRequest) returns (stream RawDiffResponse); + rpc RawPatch(RawPatchRequest) returns (stream RawPatchResponse); + + rpc FindChangedPaths(FindChangedPathsRequest) returns (FindChangedPathsResponse); } diff --git a/proto/git/hooks.proto b/proto/git/hooks.proto new file mode 100644 index 0000000..4632e89 --- /dev/null +++ b/proto/git/hooks.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; + +package gitks; + +import "repository.proto"; + +// HookService provides gRPC callback hooks for git operations. +// External services can implement this interface to receive hook callbacks. +service HookService { + // Pre-receive hook callback: validate push before it happens. + rpc PreReceiveHook(PreReceiveHookRequest) returns (PreReceiveHookResponse); + + // Update hook callback: validate each ref update individually. + rpc UpdateHook(UpdateHookRequest) returns (UpdateHookResponse); + + // Post-receive hook callback: notify after push has completed. + rpc PostReceiveHook(PostReceiveHookRequest) returns (PostReceiveHookResponse); +} + +message PreReceiveHookRequest { + RepositoryHeader repository = 1; + repeated RefUpdate ref_updates = 2; + string push_options = 3; +} + +message PreReceiveHookResponse { + bool accept = 1; + string rejection_message = 2; +} + +message UpdateHookRequest { + RepositoryHeader repository = 1; + string ref_name = 2; + string old_oid = 3; + string new_oid = 4; +} + +message UpdateHookResponse { + bool accept = 1; + string rejection_message = 2; +} + +message PostReceiveHookRequest { + RepositoryHeader repository = 1; + repeated RefUpdate ref_updates = 2; +} + +message PostReceiveHookResponse { + repeated HookAction actions = 1; +} + +message RefUpdate { + string old_oid = 1; + string new_oid = 2; + string ref_name = 3; +} + +message HookAction { + string action_type = 1; // "trigger_ci", "update_index", etc. + string payload = 2; +} \ No newline at end of file diff --git a/proto/git/pack.proto b/proto/git/pack.proto index b3ee87b..6dd17ca 100644 --- a/proto/git/pack.proto +++ b/proto/git/pack.proto @@ -10,6 +10,7 @@ message GitProtocolFeatures { repeated string capabilities = 2; repeated string server_options = 3; repeated string agent = 4; + bool stateless = 5; } message ReferenceAdvertisement { @@ -24,11 +25,13 @@ message AdvertiseRefsRequest { RepositoryHeader repository = 1; GitProtocolFeatures protocol = 2; string service = 3; + bool raw = 4; } message AdvertiseRefsResponse { repeated ReferenceAdvertisement references = 1; repeated string capabilities = 2; + bytes raw_data = 3; } message UploadPackRequest { diff --git a/proto/git/ref.proto b/proto/git/ref.proto new file mode 100644 index 0000000..8d0ead1 --- /dev/null +++ b/proto/git/ref.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/empty.proto"; +import "oid.proto"; +import "repository.proto"; + + +message FindDefaultBranchNameRequest { + RepositoryHeader repository = 1; +} + +message FindDefaultBranchNameResponse { + string name = 1; +} + +message RefExistsRequest { + RepositoryHeader repository = 1; + string ref_name = 2; +} + +message RefExistsResponse { + bool exists = 1; +} + + +message RefUpdateEntry { + string ref_name = 1; + string new_oid = 2; + string old_oid = 3; // expected old OID (empty = no check) +} + +message UpdateReferencesRequest { + RepositoryHeader repository = 1; + repeated RefUpdateEntry updates = 2; +} + +message UpdateReferencesResponse { + repeated string failed_refs = 1; + string error = 2; +} + +message DeleteRefsRequest { + RepositoryHeader repository = 1; + repeated string ref_names = 2; +} + +message DeleteRefsResponse { + repeated string failed_refs = 1; + string error = 2; +} + + +message FindRefsByOIDRequest { + RepositoryHeader repository = 1; + string oid = 2; + RefFilter filter = 3; +} + +message RefFilter { + repeated string prefixes = 1; // e.g. ["refs/heads/", "refs/tags/"] + uint32 limit = 2; +} + +message FoundRef { + string ref_name = 1; + string target_oid = 2; + bool symbolic = 3; + string symbolic_target = 4; +} + +message FindRefsByOIDResponse { + repeated FoundRef refs = 1; +} + + +message ListRefsRequest { + RepositoryHeader repository = 1; + repeated string prefixes = 2; + string pattern = 3; // glob pattern, e.g. "refs/heads/*" + repeated string containing_oids = 4; + SortDirection sort_direction = 5; + Pagination pagination = 6; +} + +message ListRefsResponse { + repeated FoundRef refs = 1; + PageInfo page_info = 2; +} + +service RefService { + rpc FindDefaultBranchName(FindDefaultBranchNameRequest) returns (FindDefaultBranchNameResponse); + rpc RefExists(RefExistsRequest) returns (RefExistsResponse); + rpc UpdateReferences(UpdateReferencesRequest) returns (UpdateReferencesResponse); + rpc DeleteRefs(DeleteRefsRequest) returns (DeleteRefsResponse); + rpc FindRefsByOID(FindRefsByOIDRequest) returns (FindRefsByOIDResponse); + rpc ListRefs(ListRefsRequest) returns (ListRefsResponse); +} diff --git a/proto/git/remote.proto b/proto/git/remote.proto new file mode 100644 index 0000000..b64ba31 --- /dev/null +++ b/proto/git/remote.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package gitks; + +import "oid.proto"; +import "repository.proto"; + + +message FindRemoteRepositoryRequest { + string remote_url = 1; +} + +message RemoteHead { + string ref_name = 1; + string target_oid = 2; + bool symbolic = 3; + string symbolic_target = 4; +} + +message FindRemoteRepositoryResponse { + repeated RemoteHead refs = 1; + bool exists = 2; +} + +message FindRemoteRootRefRequest { + string remote_url = 1; +} + +message FindRemoteRootRefResponse { + string ref_name = 1; + string target_oid = 2; +} + + +message UpdateRemoteMirrorRequest { + RepositoryHeader repository = 1; + string remote_url = 2; + string remote_name = 3; // defaults to "origin" + bool force = 4; + bool prune = 5; + repeated string refspecs = 6; // if empty, fetch all refs +} + +message UpdateRemoteMirrorResponse { + bool ok = 1; + string error = 2; +} + +service RemoteService { + rpc FindRemoteRepository(FindRemoteRepositoryRequest) returns (FindRemoteRepositoryResponse); + rpc FindRemoteRootRef(FindRemoteRootRefRequest) returns (FindRemoteRootRefResponse); + rpc UpdateRemoteMirror(UpdateRemoteMirrorRequest) returns (UpdateRemoteMirrorResponse); +} diff --git a/proto/git/repository.proto b/proto/git/repository.proto index d9b1ebb..ee2f268 100644 --- a/proto/git/repository.proto +++ b/proto/git/repository.proto @@ -139,6 +139,276 @@ message RepositoryMaintenanceResponse { string stderr = 3; } + +message ListHooksRequest { + RepositoryHeader repository = 1; +} + +message HookInfo { + string hook_type = 1; + string level = 2; // "server" or "custom" + string path = 3; +} + +message ListHooksResponse { + repeated HookInfo hooks = 1; +} + +message SetCustomHookRequest { + RepositoryHeader repository = 1; + string hook_name = 2; // "pre-receive", "update", "post-receive" + string content = 3; // Hook script content +} + +message RemoveCustomHookRequest { + RepositoryHeader repository = 1; + string hook_name = 2; +} + + +enum SnapshotStorage { + SNAPSHOT_STORAGE_LOCAL = 0; + SNAPSHOT_STORAGE_S3 = 1; + SNAPSHOT_STORAGE_GCS = 2; +} + +message SnapshotInfo { + string snapshot_id = 1; + string relative_path = 2; + uint64 size_bytes = 3; + string created_at = 4; // ISO 8601 + string head_oid = 5; +} + +message CreateSnapshotRequest { + RepositoryHeader repository = 1; + SnapshotStorage storage = 2; + string storage_path = 3; +} + +message CreateSnapshotResponse { + string snapshot_id = 1; + uint64 size_bytes = 2; + string head_oid = 3; +} + +message RestoreSnapshotRequest { + RepositoryHeader target_repository = 1; + string snapshot_id = 2; + SnapshotStorage storage = 3; + string storage_path = 4; +} + +message ListSnapshotsRequest { + RepositoryHeader repository = 1; + uint32 limit = 2; +} + +message ListSnapshotsResponse { + repeated SnapshotInfo snapshots = 1; +} + +message DeleteSnapshotRequest { + string snapshot_id = 1; + SnapshotStorage storage = 2; +} + + +enum MoveRepositoryState { + MOVE_STATE_UNKNOWN = 0; + MOVE_STATE_PREPARING = 1; + MOVE_STATE_TRANSFERRING = 2; + MOVE_STATE_VERIFYING = 3; + MOVE_STATE_COMPLETED = 4; + MOVE_STATE_FAILED = 5; + MOVE_STATE_CANCELLED = 6; +} + +message MoveRepositoryRequest { + RepositoryHeader source_repository = 1; + RepositoryHeader target_repository = 2; +} + +message MoveRepositoryResponse { + MoveRepositoryState state = 1; + string error_message = 2; +} + +message FetchRepositoryDataRequest { + RepositoryHeader repository = 1; +} + +message FetchRepositoryDataResponse { + bytes data = 1; + bool done = 2; +} + + +message FindMergeBaseRequest { + RepositoryHeader repository = 1; + repeated bytes revisions = 2; // hex OIDs to find merge-base for +} + +message FindMergeBaseResponse { + string base_oid = 1; +} + + +message WriteRefRequest { + RepositoryHeader repository = 1; + string ref_name = 2; + string new_oid = 3; + string old_oid = 4; // expected old OID (empty = no check) + bool force = 5; +} + +message WriteRefResponse { + bool ok = 1; + string error = 2; +} + + +message SearchFilesByContentRequest { + RepositoryHeader repository = 1; + string query = 2; // regex pattern + string revision = 3; // tree-ish to search in (default HEAD) + uint32 max_results = 4; // default 100 + bool case_sensitive = 5; +} + +message SearchFilesByContentResponse { + repeated SearchResult results = 1; +} + +message SearchFilesByNameRequest { + RepositoryHeader repository = 1; + string query = 2; // regex pattern for file names + string revision = 3; + uint32 max_results = 4; + bool recursive = 5; +} + +message SearchFilesByNameResponse { + repeated SearchResult results = 1; +} + +message SearchResult { + string path = 1; + uint32 line = 2; // 0 for name-only search + string matched_text = 3; // the surrounding line content +} + + +message ObjectsSizeRequest { + RepositoryHeader repository = 1; + repeated string oids = 2; +} + +message ObjectsSizeResponse { + repeated ObjectSize sizes = 1; +} + +message ObjectSize { + string oid = 1; + uint64 size = 2; + bool found = 3; +} + +message RepositorySizeRequest { + RepositoryHeader repository = 1; +} + +message RepositorySizeResponse { + uint64 size_bytes = 1; +} + + +message FindLicenseRequest { + RepositoryHeader repository = 1; +} + +message FindLicenseResponse { + string license_spdx = 1; // SPDX identifier, e.g. "MIT" + string license_name = 2; // human-readable name + double confidence = 3; // 0.0 — 1.0 + string license_path = 4; // path to LICENSE file +} + + +enum OptimizeStrategy { + OPTIMIZE_STRATEGY_UNSPECIFIED = 0; + OPTIMIZE_STRATEGY_HEURISTIC = 1; // auto-decide based on repo state + OPTIMIZE_STRATEGY_AGGRESSIVE = 2; + OPTIMIZE_STRATEGY_INCREMENTAL = 3; +} + +message OptimizeRepositoryRequest { + RepositoryHeader repository = 1; + OptimizeStrategy strategy = 2; +} + +message OptimizeRepositoryResponse { + bool ok = 1; + string stdout = 2; + string stderr = 3; +} + + +message GetRawChangesRequest { + RepositoryHeader repository = 1; + string base = 2; // revision or OID + string head = 3; +} + +message RawChange { + enum Operation { + RAW_CHANGE_OPERATION_UNSPECIFIED = 0; + RAW_CHANGE_OPERATION_ADDED = 1; + RAW_CHANGE_OPERATION_MODIFIED = 2; + RAW_CHANGE_OPERATION_DELETED = 3; + RAW_CHANGE_OPERATION_RENAMED = 4; + RAW_CHANGE_OPERATION_COPIED = 5; + } + + Operation operation = 1; + string old_path = 2; + string new_path = 3; + uint32 old_mode = 4; + uint32 new_mode = 5; + string old_oid = 6; + string new_oid = 7; + double similarity = 8; +} + +message GetRawChangesResponse { + repeated RawChange changes = 1; +} + + +message FetchRemoteRequest { + RepositoryHeader repository = 1; + string remote_url = 2; + string remote_name = 3; // defaults to "origin" + repeated string refspecs = 4; + bool force = 5; + bool prune = 6; +} + +message FetchRemoteResponse { + bool ok = 1; + string error = 2; +} + +message CreateRepositoryFromURLRequest { + RepositoryHeader repository = 1; + string remote_url = 2; + bool mirror = 3; +} + +message CreateRepositoryFromURLResponse { + Repository repository = 1; +} + service RepositoryService { rpc GetRepository(GetRepositoryRequest) returns (Repository); rpc InitRepository(InitRepositoryRequest) returns (Repository); @@ -154,4 +424,33 @@ service RepositoryService { rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse); rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse); rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse); + + // Hooks management + rpc ListHooks(ListHooksRequest) returns (ListHooksResponse); + rpc SetCustomHook(SetCustomHookRequest) returns (google.protobuf.Empty); + rpc RemoveCustomHook(RemoveCustomHookRequest) returns (google.protobuf.Empty); + + // Snapshot operations + rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse); + rpc RestoreSnapshot(RestoreSnapshotRequest) returns (google.protobuf.Empty); + rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse); + rpc DeleteSnapshot(DeleteSnapshotRequest) returns (google.protobuf.Empty); + + // Repository move + rpc MoveRepository(MoveRepositoryRequest) returns (MoveRepositoryResponse); + rpc FetchRepositoryData(FetchRepositoryDataRequest) returns (stream FetchRepositoryDataResponse); + + rpc FindMergeBase(FindMergeBaseRequest) returns (FindMergeBaseResponse); + rpc WriteRef(WriteRefRequest) returns (WriteRefResponse); + rpc SearchFilesByContent(SearchFilesByContentRequest) returns (SearchFilesByContentResponse); + rpc SearchFilesByName(SearchFilesByNameRequest) returns (SearchFilesByNameResponse); + + rpc ObjectsSize(ObjectsSizeRequest) returns (ObjectsSizeResponse); + rpc RepositorySize(RepositorySizeRequest) returns (RepositorySizeResponse); + rpc FetchRemote(FetchRemoteRequest) returns (FetchRemoteResponse); + rpc CreateRepositoryFromURL(CreateRepositoryFromURLRequest) returns (CreateRepositoryFromURLResponse); + + rpc FindLicense(FindLicenseRequest) returns (FindLicenseResponse); + rpc OptimizeRepository(OptimizeRepositoryRequest) returns (OptimizeRepositoryResponse); + rpc GetRawChanges(GetRawChangesRequest) returns (GetRawChangesResponse); } diff --git a/proto/this/im/auth.proto b/proto/this/im/auth.proto new file mode 100644 index 0000000..5c3c4ac --- /dev/null +++ b/proto/this/im/auth.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package appks.im.v1; + +// Internal service-to-service authentication. +// appks issues API keys (stored in Redis), remote services +// carry the key in gRPC metadata "x-api-key", and call +// Authenticate to verify identity. + +message AuthenticateRequest { + string api_key = 1; +} + +message AuthenticateResponse { + bool authenticated = 1; + string service_name = 2; + string service_id = 3; + repeated string scopes = 4; + int64 expires_at = 5; +} + +service InternalAuthService { + // Verify an API key and return the associated service identity. + // Called by remote services to authenticate themselves. + rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse); +} diff --git a/proto/this/im/channel.proto b/proto/this/im/channel.proto new file mode 100644 index 0000000..02e6d83 --- /dev/null +++ b/proto/this/im/channel.proto @@ -0,0 +1,212 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + +// Channel management service for the IM microservice. +// Provides CRUD for channels and categories, plus channel statistics. + +// ── Enums ────────────────────────────────────────────────────────────── + +enum ChannelType { + CHANNEL_TYPE_UNSPECIFIED = 0; + CHANNEL_TYPE_PUBLIC = 1; + CHANNEL_TYPE_PRIVATE = 2; + CHANNEL_TYPE_DIRECT = 3; + CHANNEL_TYPE_GROUP = 4; + CHANNEL_TYPE_REPO = 5; + CHANNEL_TYPE_SYSTEM = 6; +} + +enum ChannelKind { + CHANNEL_KIND_UNSPECIFIED = 0; + CHANNEL_KIND_TEXT = 1; + CHANNEL_KIND_VOICE = 2; + CHANNEL_KIND_STAGE = 3; + CHANNEL_KIND_FORUM = 4; + CHANNEL_KIND_ANNOUNCEMENT = 5; +} + +enum Visibility { + VISIBILITY_UNSPECIFIED = 0; + VISIBILITY_PUBLIC = 1; + VISIBILITY_PRIVATE = 2; + VISIBILITY_INTERNAL = 3; + VISIBILITY_WORKSPACE = 4; + VISIBILITY_PROTECTED = 5; + VISIBILITY_HIDDEN = 6; + VISIBILITY_SECRET = 7; +} + +// ── Messages ─────────────────────────────────────────────────────────── + +message Channel { + string id = 1; + string workspace_id = 2; + optional string category_id = 3; + optional string parent_channel_id = 4; + string name = 5; + optional string topic = 6; + optional string description = 7; + ChannelType channel_type = 8; + ChannelKind channel_kind = 9; + Visibility visibility = 10; + int32 position = 11; + bool nsfw = 12; + bool read_only = 13; + bool archived = 14; + optional string created_by = 15; + optional int32 rate_limit_per_user = 16; + optional google.protobuf.Timestamp archived_at = 17; + optional string last_message_id = 18; + optional google.protobuf.Timestamp last_message_at = 19; + google.protobuf.Timestamp created_at = 20; + google.protobuf.Timestamp updated_at = 21; +} + +message ChannelStats { + string channel_id = 1; + int32 members_count = 2; + int32 messages_count = 3; + int32 threads_count = 4; + int32 reactions_count = 5; + int32 mentions_count = 6; + int32 files_count = 7; + optional google.protobuf.Timestamp last_activity_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message ChannelCategory { + string id = 1; + string workspace_id = 2; + string name = 3; + int32 position = 4; + bool collapsed = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +// ── Requests / Responses ────────────────────────────────────────────── + +message GetChannelRequest { + string channel_id = 1; +} + +message GetChannelResponse { + Channel channel = 1; +} + +message ListChannelsRequest { + string workspace_name = 1; + optional string category_id = 2; + optional ChannelType channel_type = 3; + optional ChannelKind channel_kind = 4; + int32 limit = 5; + int32 offset = 6; +} + +message ListChannelsResponse { + repeated Channel channels = 1; + int32 total = 2; +} + +message CreateChannelRequest { + string workspace_name = 1; + string name = 2; + optional string topic = 3; + optional string description = 4; + optional string channel_type = 5; + optional string channel_kind = 6; + optional string visibility = 7; + optional string category_id = 8; + optional string parent_channel_id = 9; + optional string created_by = 10; + optional int32 rate_limit_per_user = 11; +} + +message CreateChannelResponse { + Channel channel = 1; +} + +message UpdateChannelRequest { + string channel_id = 1; + optional string name = 2; + optional string topic = 3; + optional string description = 4; + optional string visibility = 5; + optional int32 position = 6; + optional bool nsfw = 7; + optional bool read_only = 8; + optional bool archived = 9; + optional string category_id = 10; + optional int32 rate_limit_per_user = 11; +} + +message UpdateChannelResponse { + Channel channel = 1; +} + +message DeleteChannelRequest { + string channel_id = 1; +} + +message DeleteChannelResponse {} + +message GetChannelStatsRequest { + string channel_id = 1; +} + +message GetChannelStatsResponse { + ChannelStats stats = 1; +} + +message ListCategoriesRequest { + string workspace_name = 1; +} + +message ListCategoriesResponse { + repeated ChannelCategory categories = 1; +} + +message CreateCategoryRequest { + string workspace_name = 1; + string name = 2; + optional int32 position = 3; +} + +message CreateCategoryResponse { + ChannelCategory category = 1; +} + +message UpdateCategoryRequest { + string category_id = 1; + optional string name = 2; + optional int32 position = 3; + optional bool collapsed = 4; +} + +message UpdateCategoryResponse { + ChannelCategory category = 1; +} + +message DeleteCategoryRequest { + string category_id = 1; +} + +message DeleteCategoryResponse {} + +// ── Service ─────────────────────────────────────────────────────────── + +service ChannelService { + rpc GetChannel(GetChannelRequest) returns (GetChannelResponse); + rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse); + rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse); + rpc UpdateChannel(UpdateChannelRequest) returns (UpdateChannelResponse); + rpc DeleteChannel(DeleteChannelRequest) returns (DeleteChannelResponse); + rpc GetChannelStats(GetChannelStatsRequest) returns (GetChannelStatsResponse); + rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse); + rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse); + rpc UpdateCategory(UpdateCategoryRequest) returns (UpdateCategoryResponse); + rpc DeleteCategory(DeleteCategoryRequest) returns (DeleteCategoryResponse); +} diff --git a/proto/this/im/channel_settings.proto b/proto/this/im/channel_settings.proto new file mode 100644 index 0000000..442595d --- /dev/null +++ b/proto/this/im/channel_settings.proto @@ -0,0 +1,412 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + +// ── ChannelMemberRole ────────────────────────────────────────────────── + +message ChannelRole { + string id = 1; + string channel_id = 2; + string name = 3; + repeated string permissions = 4; + bool assignable = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListChannelRolesRequest { string channel_id = 1; } +message ListChannelRolesResponse { repeated ChannelRole roles = 1; } + +message CreateChannelRoleRequest { + string channel_id = 1; + string name = 2; + repeated string permissions = 3; + bool assignable = 4; +} +message CreateChannelRoleResponse { ChannelRole role = 1; } + +message UpdateChannelRoleRequest { + string role_id = 1; + optional string name = 2; + repeated string permissions = 3; + optional bool assignable = 4; +} +message UpdateChannelRoleResponse { ChannelRole role = 1; } + +message DeleteChannelRoleRequest { string role_id = 1; } +message DeleteChannelRoleResponse {} + +service ChannelRoleService { + rpc ListChannelRoles(ListChannelRolesRequest) returns (ListChannelRolesResponse); + rpc CreateChannelRole(CreateChannelRoleRequest) returns (CreateChannelRoleResponse); + rpc UpdateChannelRole(UpdateChannelRoleRequest) returns (UpdateChannelRoleResponse); + rpc DeleteChannelRole(DeleteChannelRoleRequest) returns (DeleteChannelRoleResponse); +} + +// ── ChannelInvitation ────────────────────────────────────────────────── + +message ChannelInvitation { + string id = 1; + string channel_id = 2; + string invited_by = 3; + string invited_user_id = 4; + string role = 5; + string status = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListInvitationsRequest { string channel_id = 1; } +message ListInvitationsResponse { repeated ChannelInvitation invitations = 1; } + +message CreateInvitationRequest { + string channel_id = 1; + string invited_user_id = 2; + string role = 3; +} +message CreateInvitationResponse { ChannelInvitation invitation = 1; } + +message AcceptInvitationRequest { string invitation_id = 1; } +message AcceptInvitationResponse { ChannelInvitation invitation = 1; } + +message RevokeInvitationRequest { string invitation_id = 1; } +message RevokeInvitationResponse {} + +service ChannelInvitationService { + rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse); + rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse); + rpc AcceptInvitation(AcceptInvitationRequest) returns (AcceptInvitationResponse); + rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse); +} + +// ── ChannelWebhook ───────────────────────────────────────────────────── + +message ChannelWebhook { + string id = 1; + string channel_id = 2; + string name = 3; + string url = 4; + string secret = 5; + repeated string events = 6; + bool active = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message ListWebhooksRequest { string channel_id = 1; } +message ListWebhooksResponse { repeated ChannelWebhook webhooks = 1; } + +message CreateWebhookRequest { + string channel_id = 1; + string name = 2; + string url = 3; + optional string secret = 4; + repeated string events = 5; +} +message CreateWebhookResponse { ChannelWebhook webhook = 1; } + +message UpdateWebhookRequest { + string webhook_id = 1; + optional string name = 2; + optional string url = 3; + optional string secret = 4; + repeated string events = 5; + optional bool active = 6; +} +message UpdateWebhookResponse { ChannelWebhook webhook = 1; } + +message DeleteWebhookRequest { string webhook_id = 1; } +message DeleteWebhookResponse {} + +service ChannelWebhookService { + rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse); + rpc CreateWebhook(CreateWebhookRequest) returns (CreateWebhookResponse); + rpc UpdateWebhook(UpdateWebhookRequest) returns (UpdateWebhookResponse); + rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse); +} + +// ── ChannelSlashCommand ──────────────────────────────────────────────── + +message ChannelSlashCommand { + string id = 1; + string channel_id = 2; + string command = 3; + string description = 4; + string request_url = 5; + repeated string scopes = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListSlashCommandsRequest { string channel_id = 1; } +message ListSlashCommandsResponse { repeated ChannelSlashCommand commands = 1; } + +message CreateSlashCommandRequest { + string channel_id = 1; + string command = 2; + string description = 3; + string request_url = 4; + repeated string scopes = 5; +} +message CreateSlashCommandResponse { ChannelSlashCommand command = 1; } + +message UpdateSlashCommandRequest { + string command_id = 1; + optional string description = 2; + optional string request_url = 3; + repeated string scopes = 4; +} +message UpdateSlashCommandResponse { ChannelSlashCommand command = 1; } + +message DeleteSlashCommandRequest { string command_id = 1; } +message DeleteSlashCommandResponse {} + +service ChannelSlashCommandService { + rpc ListSlashCommands(ListSlashCommandsRequest) returns (ListSlashCommandsResponse); + rpc CreateSlashCommand(CreateSlashCommandRequest) returns (CreateSlashCommandResponse); + rpc UpdateSlashCommand(UpdateSlashCommandRequest) returns (UpdateSlashCommandResponse); + rpc DeleteSlashCommand(DeleteSlashCommandRequest) returns (DeleteSlashCommandResponse); +} + +// ── ChannelRepoLink ──────────────────────────────────────────────────── + +message ChannelRepoLink { + string id = 1; + string channel_id = 2; + string repo_id = 3; + string link_type = 4; + repeated string events = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListRepoLinksRequest { string channel_id = 1; } +message ListRepoLinksResponse { repeated ChannelRepoLink links = 1; } + +message CreateRepoLinkRequest { + string channel_id = 1; + string repo_id = 2; + string link_type = 3; + repeated string events = 4; +} +message CreateRepoLinkResponse { ChannelRepoLink link = 1; } + +message DeleteRepoLinkRequest { string link_id = 1; } +message DeleteRepoLinkResponse {} + +service ChannelRepoLinkService { + rpc ListRepoLinks(ListRepoLinksRequest) returns (ListRepoLinksResponse); + rpc CreateRepoLink(CreateRepoLinkRequest) returns (CreateRepoLinkResponse); + rpc DeleteRepoLink(DeleteRepoLinkRequest) returns (DeleteRepoLinkResponse); +} + +// ── ImIntegration ────────────────────────────────────────────────────── + +message ImIntegration { + string id = 1; + string channel_id = 2; + string provider = 3; + string external_channel_id = 4; + string sync_direction = 5; + bool active = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListIntegrationsRequest { string channel_id = 1; } +message ListIntegrationsResponse { repeated ImIntegration integrations = 1; } + +message CreateIntegrationRequest { + string channel_id = 1; + string provider = 2; + string external_channel_id = 3; + string sync_direction = 4; +} +message CreateIntegrationResponse { ImIntegration integration = 1; } + +message UpdateIntegrationRequest { + string integration_id = 1; + optional string sync_direction = 2; + optional bool active = 3; +} +message UpdateIntegrationResponse { ImIntegration integration = 1; } + +message DeleteIntegrationRequest { string integration_id = 1; } +message DeleteIntegrationResponse {} + +service ImIntegrationService { + rpc ListIntegrations(ListIntegrationsRequest) returns (ListIntegrationsResponse); + rpc CreateIntegration(CreateIntegrationRequest) returns (CreateIntegrationResponse); + rpc UpdateIntegration(UpdateIntegrationRequest) returns (UpdateIntegrationResponse); + rpc DeleteIntegration(DeleteIntegrationRequest) returns (DeleteIntegrationResponse); +} + +// ── CustomEmoji ──────────────────────────────────────────────────────── + +message CustomEmoji { + string id = 1; + string workspace_id = 2; + string name = 3; + string image_url = 4; + google.protobuf.Timestamp created_at = 5; +} + +message ListCustomEmojisRequest { string workspace_id = 1; } +message ListCustomEmojisResponse { repeated CustomEmoji emojis = 1; } + +message CreateCustomEmojiRequest { + string workspace_id = 1; + string name = 2; + string image_url = 3; +} +message CreateCustomEmojiResponse { CustomEmoji emoji = 1; } + +message DeleteCustomEmojiRequest { string emoji_id = 1; } +message DeleteCustomEmojiResponse {} + +service CustomEmojiService { + rpc ListCustomEmojis(ListCustomEmojisRequest) returns (ListCustomEmojisResponse); + rpc CreateCustomEmoji(CreateCustomEmojiRequest) returns (CreateCustomEmojiResponse); + rpc DeleteCustomEmoji(DeleteCustomEmojiRequest) returns (DeleteCustomEmojiResponse); +} + +// ── ForumTag ─────────────────────────────────────────────────────────── + +message ForumTag { + string id = 1; + string channel_id = 2; + string name = 3; + bool moderated = 4; + int32 position = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListForumTagsRequest { string channel_id = 1; } +message ListForumTagsResponse { repeated ForumTag tags = 1; } + +message CreateForumTagRequest { + string channel_id = 1; + string name = 2; + bool moderated = 3; + optional int32 position = 4; +} +message CreateForumTagResponse { ForumTag tag = 1; } + +message UpdateForumTagRequest { + string tag_id = 1; + optional string name = 2; + optional bool moderated = 3; + optional int32 position = 4; +} +message UpdateForumTagResponse { ForumTag tag = 1; } + +message DeleteForumTagRequest { string tag_id = 1; } +message DeleteForumTagResponse {} + +service ForumTagService { + rpc ListForumTags(ListForumTagsRequest) returns (ListForumTagsResponse); + rpc CreateForumTag(CreateForumTagRequest) returns (CreateForumTagResponse); + rpc UpdateForumTag(UpdateForumTagRequest) returns (UpdateForumTagResponse); + rpc DeleteForumTag(DeleteForumTagRequest) returns (DeleteForumTagResponse); +} + +// ── VoiceParticipant ─────────────────────────────────────────────────── + +message VoiceParticipant { + string id = 1; + string channel_id = 2; + string user_id = 3; + bool muted = 4; + bool deafened = 5; + google.protobuf.Timestamp joined_at = 6; +} + +message ListVoiceParticipantsRequest { string channel_id = 1; } +message ListVoiceParticipantsResponse { repeated VoiceParticipant participants = 1; } + +message UpdateVoiceStateRequest { + string channel_id = 1; + string user_id = 2; + optional bool muted = 3; + optional bool deafened = 4; +} +message UpdateVoiceStateResponse { VoiceParticipant participant = 1; } + +service VoiceService { + rpc ListVoiceParticipants(ListVoiceParticipantsRequest) returns (ListVoiceParticipantsResponse); + rpc UpdateVoiceState(UpdateVoiceStateRequest) returns (UpdateVoiceStateResponse); +} + +// ── Stage ────────────────────────────────────────────────────────────── + +message Stage { + string id = 1; + string channel_id = 2; + string topic = 3; + string privacy_level = 4; + bool discoverable = 5; + google.protobuf.Timestamp started_at = 6; + google.protobuf.Timestamp ended_at = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message GetStageRequest { string channel_id = 1; } +message GetStageResponse { Stage stage = 1; } + +message CreateStageRequest { + string channel_id = 1; + string topic = 2; + string privacy_level = 3; + bool discoverable = 4; +} +message CreateStageResponse { Stage stage = 1; } + +message UpdateStageRequest { + string stage_id = 1; + optional string topic = 2; + optional string privacy_level = 3; + optional bool discoverable = 4; +} +message UpdateStageResponse { Stage stage = 1; } + +message DeleteStageRequest { string stage_id = 1; } +message DeleteStageResponse {} + +service StageService { + rpc GetStage(GetStageRequest) returns (GetStageResponse); + rpc CreateStage(CreateStageRequest) returns (CreateStageResponse); + rpc UpdateStage(UpdateStageRequest) returns (UpdateStageResponse); + rpc DeleteStage(DeleteStageRequest) returns (DeleteStageResponse); +} + +// ── ChannelEvent (Audit Log) ─────────────────────────────────────────── + +message ChannelAuditEvent { + string id = 1; + string channel_id = 2; + string actor_id = 3; + string event_type = 4; + string target_type = 5; + string target_id = 6; + optional string old_value = 7; + optional string new_value = 8; + google.protobuf.Timestamp created_at = 9; +} + +message ListChannelEventsRequest { + string channel_id = 1; + int32 limit = 2; + int32 offset = 3; +} +message ListChannelEventsResponse { + repeated ChannelAuditEvent events = 1; + int32 total = 2; +} + +service ChannelAuditService { + rpc ListChannelEvents(ListChannelEventsRequest) returns (ListChannelEventsResponse); +} diff --git a/proto/this/im/member.proto b/proto/this/im/member.proto new file mode 100644 index 0000000..b8efdf1 --- /dev/null +++ b/proto/this/im/member.proto @@ -0,0 +1,131 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + +// Member management service for the IM microservice. +// Provides CRUD for channel members, join/leave, and membership checks. + +// ── Enums ────────────────────────────────────────────────────────────── + +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_OWNER = 1; + ROLE_ADMIN = 2; + ROLE_MAINTAINER = 3; + ROLE_MODERATOR = 4; + ROLE_MEMBER = 5; + ROLE_CONTRIBUTOR = 6; + ROLE_VIEWER = 7; + ROLE_GUEST = 8; + ROLE_BOT = 9; +} + +enum MemberStatus { + MEMBER_STATUS_UNSPECIFIED = 0; + MEMBER_STATUS_ACTIVE = 1; + MEMBER_STATUS_INVITED = 2; + MEMBER_STATUS_LEFT = 3; + MEMBER_STATUS_KICKED = 4; + MEMBER_STATUS_BANNED = 5; +} + +// ── Messages ─────────────────────────────────────────────────────────── + +message ChannelMember { + string id = 1; + string channel_id = 2; + string user_id = 3; + string role = 4; + string status = 5; + bool muted = 6; + bool pinned = 7; + optional string last_read_message_id = 8; + optional google.protobuf.Timestamp last_read_at = 9; + optional google.protobuf.Timestamp joined_at = 10; + optional google.protobuf.Timestamp left_at = 11; + google.protobuf.Timestamp created_at = 12; + google.protobuf.Timestamp updated_at = 13; +} + +// ── Requests / Responses ────────────────────────────────────────────── + +message ListMembersRequest { + string channel_id = 1; + optional string status = 2; + int32 limit = 3; + int32 offset = 4; +} + +message ListMembersResponse { + repeated ChannelMember members = 1; + int32 total = 2; +} + +message InviteMemberRequest { + string channel_id = 1; + string user_id = 2; + optional string role = 3; +} + +message InviteMemberResponse { + ChannelMember member = 1; +} + +message UpdateMemberRequest { + string channel_id = 1; + string user_id = 2; + optional string role = 3; + optional bool muted = 4; + optional bool pinned = 5; +} + +message UpdateMemberResponse { + ChannelMember member = 1; +} + +message KickMemberRequest { + string channel_id = 1; + string user_id = 2; +} + +message KickMemberResponse {} + +message JoinChannelRequest { + string channel_id = 1; + string user_id = 2; +} + +message JoinChannelResponse { + ChannelMember member = 1; +} + +message LeaveChannelRequest { + string channel_id = 1; + string user_id = 2; +} + +message LeaveChannelResponse {} + +message IsMemberRequest { + string channel_id = 1; + string user_id = 2; +} + +message IsMemberResponse { + bool is_member = 1; + string role = 2; +} + +// ── Service ─────────────────────────────────────────────────────────── + +service MemberService { + rpc ListMembers(ListMembersRequest) returns (ListMembersResponse); + rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse); + rpc UpdateMember(UpdateMemberRequest) returns (UpdateMemberResponse); + rpc KickMember(KickMemberRequest) returns (KickMemberResponse); + rpc JoinChannel(JoinChannelRequest) returns (JoinChannelResponse); + rpc LeaveChannel(LeaveChannelRequest) returns (LeaveChannelResponse); + rpc IsMember(IsMemberRequest) returns (IsMemberResponse); +} diff --git a/proto/this/im/permission.proto b/proto/this/im/permission.proto new file mode 100644 index 0000000..bec0c10 --- /dev/null +++ b/proto/this/im/permission.proto @@ -0,0 +1,128 @@ +syntax = "proto3"; + +package appks.im.v1; + +// IM-specific permissions for channel operations. +// Separate from the general Permission enum used for repo/workspace access. +enum ImPermission { + IM_PERMISSION_UNSPECIFIED = 0; + IM_PERMISSION_READ_CHANNEL = 1; + IM_PERMISSION_SEND_MESSAGE = 2; + IM_PERMISSION_MANAGE_THREADS = 3; + IM_PERMISSION_MANAGE_REACTIONS = 4; + IM_PERMISSION_MANAGE_PINS = 5; + IM_PERMISSION_INVITE_MEMBERS = 6; + IM_PERMISSION_KICK_MEMBERS = 7; + IM_PERMISSION_MANAGE_CHANNEL = 8; + IM_PERMISSION_MANAGE_ROLES = 9; + IM_PERMISSION_MANAGE_WEBHOOKS = 10; + IM_PERMISSION_MANAGE_EMOJIS = 11; + IM_PERMISSION_VIEW_AUDIT_LOG = 12; + IM_PERMISSION_MANAGE_INTEGRATIONS = 13; + IM_PERMISSION_SEND_TTS = 14; + IM_PERMISSION_USE_SLASH_COMMANDS = 15; + IM_PERMISSION_ATTACH_FILES = 16; + IM_PERMISSION_MENTION_EVERYONE = 17; + IM_PERMISSION_MANAGE_MESSAGES = 18; + IM_PERMISSION_ADMIN = 19; +} + +// ── Messages ─────────────────────────────────────────────────────────── + +message PermissionOverwrite { + string id = 1; + string channel_id = 2; + string target_type = 3; + string target_id = 4; + repeated ImPermission allow = 5; + repeated ImPermission deny = 6; + string created_at = 7; + string updated_at = 8; +} + +// ── Requests / Responses ────────────────────────────────────────────── + +message CheckPermissionRequest { + string channel_id = 1; + string user_id = 2; + ImPermission permission = 3; +} + +message CheckPermissionResponse { + bool allowed = 1; + string role = 2; +} + +message GetPermissionsRequest { + string channel_id = 1; + string user_id = 2; +} + +message GetPermissionsResponse { + repeated ImPermission permissions = 1; + string role = 2; +} + +message SetPermissionOverwriteRequest { + string channel_id = 1; + string target_type = 2; + string target_id = 3; + repeated ImPermission allow = 4; + repeated ImPermission deny = 5; +} + +message SetPermissionOverwriteResponse { + PermissionOverwrite overwrite = 1; +} + +message GetPermissionOverwritesRequest { + string channel_id = 1; +} + +message GetPermissionOverwritesResponse { + repeated PermissionOverwrite overwrites = 1; +} + +message DeletePermissionOverwriteRequest { + string channel_id = 1; + string target_type = 2; + string target_id = 3; +} + +message DeletePermissionOverwriteResponse {} + +message ResolveChannelRequest { + string channel_id = 1; +} + +message ResolveChannelResponse { + string channel_id = 1; + string workspace_id = 2; + string name = 3; + string visibility = 4; + string channel_type = 5; + bool read_only = 6; + bool archived = 7; + optional string created_by = 8; +} + +message EnsureReadableRequest { + string channel_id = 1; + string user_id = 2; +} + +message EnsureReadableResponse { + bool allowed = 1; +} + +// ── Service ─────────────────────────────────────────────────────────── + +service PermissionService { + rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse); + rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse); + rpc SetPermissionOverwrite(SetPermissionOverwriteRequest) returns (SetPermissionOverwriteResponse); + rpc GetPermissionOverwrites(GetPermissionOverwritesRequest) returns (GetPermissionOverwritesResponse); + rpc DeletePermissionOverwrite(DeletePermissionOverwriteRequest) returns (DeletePermissionOverwriteResponse); + rpc ResolveChannel(ResolveChannelRequest) returns (ResolveChannelResponse); + rpc EnsureReadable(EnsureReadableRequest) returns (EnsureReadableResponse); +} diff --git a/proto/this/repo.proto b/proto/this/repo.proto new file mode 100644 index 0000000..f1f4385 --- /dev/null +++ b/proto/this/repo.proto @@ -0,0 +1,247 @@ +syntax = "proto3"; + +package appks.v1; + +import "google/protobuf/timestamp.proto"; + +// Repository-related services for gitshell. +// gitshell calls these RPCs to: +// 1. Check branch protection rules before accepting a push. +// 2. Locate which storage node hosts a given repository. +// 3. Verify user/agent permissions on a repository. +// 4. Acquire / release push locks for concurrency control. + +// ── Enums ────────────────────────────────────────────────────────────── + +enum PushLockStatus { + PUSH_LOCK_STATUS_UNSPECIFIED = 0; + PUSH_LOCK_STATUS_QUEUED = 1; + PUSH_LOCK_STATUS_ACTIVE = 2; + PUSH_LOCK_STATUS_FINISHED = 3; + PUSH_LOCK_STATUS_FAILED = 4; +} + +enum MergeStrategy { + MERGE_STRATEGY_UNSPECIFIED = 0; + MERGE_STRATEGY_MERGE = 1; + MERGE_STRATEGY_SQUASH = 2; + MERGE_STRATEGY_REBASE = 3; + MERGE_STRATEGY_FAST_FORWARD = 4; +} + +// ── Branch Protection ────────────────────────────────────────────────── + +message BranchProtectionRule { + string id = 1; + string repo_id = 2; + string pattern = 3; + int32 require_approvals = 4; + bool require_status_checks = 5; + repeated string required_status_checks = 6; + bool require_linear_history = 7; + bool allow_force_pushes = 8; + bool allow_deletions = 9; + bool require_signed_commits = 10; + bool require_code_owner_review = 11; + bool dismiss_stale_reviews = 12; + bool restrict_pushes = 13; + repeated string push_allowances = 14; + bool restrict_review_dismissal = 15; + repeated string dismissal_allowances = 16; + bool require_conversation_resolution = 17; +} + +message CheckBranchProtectionRequest { + string workspace_name = 1; + string repo_name = 2; + string branch_name = 3; + // The user attempting the push (for push-allowance checks). + optional string user_id = 4; +} + +message CheckBranchProtectionResponse { + bool protected = 1; + BranchProtectionRule rule = 2; + // Human-readable reasons why the push would be blocked. + repeated string block_reasons = 3; + // Whether the given user is exempt (in push_allowances). + bool user_allowed = 4; +} + +// ── Repository Locate ───────────────────────────────────────────────── + +message StorageNode { + string node_id = 1; + string address = 2; + // Labels for routing decisions (e.g. region, disk-type). + map labels = 3; + bool healthy = 4; +} + +message LocateRepositoryRequest { + string workspace_name = 1; + string repo_name = 2; +} + +message LocateRepositoryResponse { + bool found = 1; + string repo_id = 2; + // The storage path on the node (e.g. "ab/cd/12345.git"). + string storage_path = 3; + // Primary storage node that hosts the repository. + StorageNode primary_node = 4; + // Additional replica / failover nodes. + repeated StorageNode replica_nodes = 5; +} + +// ── Permission Check ────────────────────────────────────────────────── + +message PermissionScope { + string scope = 1; // e.g. "repo:read", "repo:write" + optional string resource = 2; // e.g. specific repo name if scoped +} + +message CheckRepoPermissionRequest { + string workspace_name = 1; + string repo_name = 2; + // The principal to check — either a user_id or a deploy_key_id. + oneof principal { + string user_id = 3; + string deploy_key_id = 4; + } + // The required permission level. + string required_permission = 5; +} + +message CheckRepoPermissionResponse { + bool allowed = 1; + // The actual resolved permission (may be higher than required). + string resolved_permission = 2; + // If not allowed, a human-readable reason. + string reason = 3; +} + +// ── Push Lock ────────────────────────────────────────────────────────── + +message PushLock { + string id = 1; + string repo_id = 2; + string pusher_id = 3; + string ref_name = 4; + PushLockStatus status = 5; + int32 queue_position = 6; + google.protobuf.Timestamp queued_at = 7; + google.protobuf.Timestamp started_at = 8; + google.protobuf.Timestamp finished_at = 9; + string storage_node_id = 10; + string lease_token = 11; + string error_message = 12; +} + +message AcquirePushLockRequest { + string workspace_name = 1; + string repo_name = 2; + string ref_name = 3; + string pusher_id = 4; +} + +message AcquirePushLockResponse { + bool acquired = 1; + PushLock lock = 2; + // If not immediately acquired, estimated wait in seconds. + int32 estimated_wait_seconds = 3; + string error = 4; +} + +message ReleasePushLockRequest { + string lock_id = 1; + // Must match the lease_token from AcquirePushLock. + string lease_token = 2; + // Whether the push succeeded. + bool success = 3; + optional string error_message = 4; +} + +message ReleasePushLockResponse { + bool released = 1; + string error = 2; +} + +message GetPushLockRequest { + string lock_id = 1; +} + +message GetPushLockResponse { + PushLock lock = 1; +} + +message ListPushLocksRequest { + string workspace_name = 1; + string repo_name = 2; + // Filter by status; if unspecified, returns all active locks. + optional PushLockStatus status = 3; +} + +message ListPushLocksResponse { + repeated PushLock locks = 1; +} + +// ── Repository Metadata ─────────────────────────────────────────────── + +message RepoInfo { + string id = 1; + string workspace_id = 2; + string owner_id = 3; + string name = 4; + optional string description = 5; + string default_branch = 6; + string visibility = 7; + string status = 8; + bool is_fork = 9; + optional string forked_from_repo_id = 10; + string storage_path = 11; + string git_service = 12; + google.protobuf.Timestamp archived_at = 13; + google.protobuf.Timestamp created_at = 14; + google.protobuf.Timestamp updated_at = 15; +} + +message GetRepoInfoRequest { + string workspace_name = 1; + string repo_name = 2; +} + +message GetRepoInfoResponse { + bool found = 1; + RepoInfo repo = 2; +} + +// ── Service ──────────────────────────────────────────────────────────── + +service RepoService { + // ── Branch Protection ── + // Check whether a branch is protected and whether a push is allowed. + rpc CheckBranchProtection(CheckBranchProtectionRequest) returns (CheckBranchProtectionResponse); + + // ── Repository Locate ── + // Find which storage node(s) host a repository. + rpc LocateRepository(LocateRepositoryRequest) returns (LocateRepositoryResponse); + + // ── Permission Check ── + // Verify that a user or deploy key has the required permission on a repo. + rpc CheckRepoPermission(CheckRepoPermissionRequest) returns (CheckRepoPermissionResponse); + + // ── Push Lock ── + // Acquire an exclusive push lock for a ref. + rpc AcquirePushLock(AcquirePushLockRequest) returns (AcquirePushLockResponse); + // Release a previously acquired push lock. + rpc ReleasePushLock(ReleasePushLockRequest) returns (ReleasePushLockResponse); + // Get the current state of a push lock. + rpc GetPushLock(GetPushLockRequest) returns (GetPushLockResponse); + // List active push locks for a repository. + rpc ListPushLocks(ListPushLocksRequest) returns (ListPushLocksResponse); + + // ── Repository Metadata ── + // Get lightweight repository metadata (for gitshell to resolve repo names). + rpc GetRepoInfo(GetRepoInfoRequest) returns (GetRepoInfoResponse); +} \ No newline at end of file