diff --git a/src/deployment/forgejo.rs b/src/deployment/forgejo.rs new file mode 100644 index 000000000..22ad6e56e --- /dev/null +++ b/src/deployment/forgejo.rs @@ -0,0 +1,364 @@ +use git2::{Repository, Oid, Signature, Time}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use super::{DeploymentError, GeneratedApp}; + +pub struct ForgejoClient { + base_url: String, + token: String, + client: Client, +} + +impl ForgejoClient { + pub fn new(base_url: String, token: String) -> Self { + Self { + base_url, + token, + client: Client::new(), + } + } + + /// Create a new repository in Forgejo + pub async fn create_repository( + &self, + name: &str, + description: &str, + private: bool, + ) -> Result { + let url = format!("{}/api/v1/user/repos", self.base_url); + + let payload = CreateRepoRequest { + name: name.to_string(), + description: description.to_string(), + private, + auto_init: true, + gitignores: Some("Node,React,Vite".to_string()), + license: Some("MIT".to_string()), + readme: Some("Default".to_string()), + }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .json(&payload) + .send() + .await + .map_err(|e| ForgejoError::HttpError(e.to_string()))?; + + if response.status().is_success() { + let repo: ForgejoRepo = response + .json() + .await + .map_err(|e| ForgejoError::JsonError(e.to_string()))?; + Ok(repo) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(ForgejoError::ApiError(format!("{}: {}", status, body))) + } + } + + /// Push generated app to Forgejo repository + pub async fn push_app( + &self, + repo_url: &str, + app: &GeneratedApp, + branch: &str, + ) -> Result { + // 1. Create temporary directory for the app + let temp_dir = app.temp_dir()?; + std::fs::create_dir_all(&temp_dir) + .map_err(|e| DeploymentError::GitError(format!("Failed to create temp dir: {}", e)))?; + + // 2. Write all files to temp directory + for file in &app.files { + let file_path = temp_dir.join(&file.path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| DeploymentError::GitError(format!("Failed to create parent dir: {}", e)))?; + } + std::fs::write(&file_path, &file.content) + .map_err(|e| DeploymentError::GitError(format!("Failed to write file: {}", e)))?; + } + + // 3. Initialize local git repo + let repo = Repository::init(&temp_dir) + .map_err(|e| DeploymentError::GitError(format!("Failed to init repo: {}", e)))?; + + // 4. Add all files + let mut index = repo.index() + .map_err(|e| DeploymentError::GitError(format!("Failed to get index: {}", e)))?; + + // Add all files recursively + self.add_all_files(&repo, &mut index, &temp_dir) + .map_err(|e| DeploymentError::GitError(format!("Failed to add files: {}", e)))?; + + index.write() + .map_err(|e| DeploymentError::GitError(format!("Failed to write index: {}", e)))?; + + // 5. Create commit + let tree_id = index.write_tree() + .map_err(|e| DeploymentError::GitError(format!("Failed to write tree: {}", e)))?; + let tree = repo.find_tree(tree_id) + .map_err(|e| DeploymentError::GitError(format!("Failed to find tree: {}", e)))?; + + let sig = Signature::now("GB Deployer", "deployer@generalbots.com") + .map_err(|e| DeploymentError::GitError(format!("Failed to create signature: {}", e)))?; + + let oid = repo.commit( + Some(&format!("refs/heads/{}", branch)), + &sig, + &sig, + &format!("Initial commit: {}", app.description), + &tree, + &[], + ).map_err(|e| DeploymentError::GitError(format!("Failed to commit: {}", e)))?; + + // 6. Add Forgejo remote with token authentication + let auth_url = self.add_token_to_url(repo_url); + let mut remote = repo.remote("origin", &auth_url) + .map_err(|e| DeploymentError::GitError(format!("Failed to add remote: {}", e)))?; + + // 7. Push to Forgejo + remote.push(&[format!("refs/heads/{}", branch)], None) + .map_err(|e| DeploymentError::GitError(format!("Failed to push: {}", e)))?; + + Ok(oid.to_string()) + } + + /// Create CI/CD workflow for the app + pub async fn create_cicd_workflow( + &self, + repo_url: &str, + app_type: AppType, + build_config: BuildConfig, + ) -> Result<(), DeploymentError> { + let workflow = match app_type { + AppType::Htmx => self.generate_htmx_workflow(build_config), + AppType::React => self.generate_react_workflow(build_config), + AppType::Vue => self.generate_vue_workflow(build_config), + }; + + // Create workflow file + let workflow_file = GeneratedFile { + path: ".forgejo/workflows/deploy.yml".to_string(), + content: workflow.into_bytes(), + }; + + // Create a new commit with the workflow file + let workflow_app = GeneratedApp { + name: "workflow".to_string(), + description: "CI/CD workflow".to_string(), + files: vec![workflow_file], + }; + + self.push_app(repo_url, &workflow_app, "main").await?; + + Ok(()) + } + + fn add_all_files( + &self, + repo: &Repository, + index: &mut git2::Index, + dir: &Path, + ) -> Result<(), git2::Error> { + for entry in std::fs::read_dir(dir).map_err(|e| git2::Error::from_str(&e.to_string()))? { + let entry = entry.map_err(|e| git2::Error::from_str(&e.to_string()))?; + let path = entry.path(); + + if path.is_dir() { + if path.file_name().map(|f| f == ".git").unwrap_or(false) { + continue; + } + self.add_all_files(repo, index, &path)?; + } else { + let relative_path = path.strip_prefix(repo.workdir().unwrap()) + .map_err(|e| git2::Error::from_str(&e.to_string()))?; + index.add_path(relative_path)?; + } + } + Ok(()) + } + + fn add_token_to_url(&self, url: &str) -> String { + // Convert https://forgejo.com/user/repo to https://token@forgejo.com/user/repo + if url.starts_with("https://") { + url.replace("https://", &format!("https://{}@", self.token)) + } else { + url.to_string() + } + } + + fn generate_htmx_workflow(&self, _config: BuildConfig) -> String { + r#"name: Deploy HTMX App + +on: + push: + branches: [main, develop] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Deploy to server + run: | + echo "Deploying HTMX app to production..." + # Add deployment commands here +"#.to_string() + } + + fn generate_react_workflow(&self, _config: BuildConfig) -> String { + r#"name: Deploy React App + +on: + push: + branches: [main, develop] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build React app + run: npm run build + + - name: Run tests + run: npm test + + - name: Deploy to production + run: | + echo "Deploying React app to production..." + # Add deployment commands here +"#.to_string() + } + + fn generate_vue_workflow(&self, _config: BuildConfig) -> String { + r#"name: Deploy Vue App + +on: + push: + branches: [main, develop] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Vue app + run: npm run build + + - name: Run tests + run: npm test + + - name: Deploy to production + run: | + echo "Deploying Vue app to production..." + # Add deployment commands here +"#.to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForgejoRepo { + pub id: u64, + pub name: String, + pub full_name: String, + pub clone_url: String, + pub html_url: String, +} + +#[derive(Debug, Serialize)] +struct CreateRepoRequest { + name: String, + description: String, + private: bool, + auto_init: bool, + #[serde(skip_serializing_if = "Option::is_none")] + gitignores: Option, + #[serde(skip_serializing_if = "Option::is_none")] + license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + readme: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum AppType { + Htmx, + React, + Vue, +} + +#[derive(Debug, Clone)] +pub struct BuildConfig { + pub node_version: String, + pub build_command: String, + pub output_dir: String, +} + +impl Default for BuildConfig { + fn default() -> Self { + Self { + node_version: "20".to_string(), + build_command: "npm run build".to_string(), + output_dir: "dist".to_string(), + } + } +} + +#[derive(Debug)] +pub enum ForgejoError { + HttpError(String), + JsonError(String), + ApiError(String), + GitError(String), +} + +impl std::fmt::Display for ForgejoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ForgejoError::HttpError(msg) => write!(f, "HTTP error: {}", msg), + ForgejoError::JsonError(msg) => write!(f, "JSON error: {}", msg), + ForgejoError::ApiError(msg) => write!(f, "API error: {}", msg), + ForgejoError::GitError(msg) => write!(f, "Git error: {}", msg), + } + } +} + +impl std::error::Error for ForgejoError {} diff --git a/src/deployment/mod.rs b/src/deployment/mod.rs new file mode 100644 index 000000000..00f418b38 --- /dev/null +++ b/src/deployment/mod.rs @@ -0,0 +1,202 @@ +pub mod forgejo; + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +// Re-export types from forgejo module +pub use forgejo::{AppType, BuildConfig, ForgejoClient, ForgejoError, ForgejoRepo}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeploymentTarget { + /// Serve from GB platform (/apps/{name}) + Internal { + route: String, + shared_resources: bool, + }, + /// Deploy to external Forgejo repository + External { + repo_url: String, + custom_domain: Option, + ci_cd_enabled: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentConfig { + pub app_name: String, + pub target: DeploymentTarget, + pub environment: DeploymentEnvironment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeploymentEnvironment { + Development, + Staging, + Production, +} + +pub struct DeploymentRouter { + forgejo_url: String, + forgejo_token: Option, + internal_base_path: PathBuf, +} + +impl DeploymentRouter { + pub fn new( + forgejo_url: String, + forgejo_token: Option, + internal_base_path: PathBuf, + ) -> Self { + Self { + forgejo_url, + forgejo_token, + internal_base_path, + } + } + + /// Route deployment based on target type + pub async fn deploy( + &self, + config: DeploymentConfig, + generated_app: GeneratedApp, + ) -> Result { + match config.target { + DeploymentTarget::Internal { route, .. } => { + self.deploy_internal(route, generated_app).await + } + DeploymentTarget::External { ref repo_url, .. } => { + self.deploy_external(repo_url, generated_app).await + } + } + } + + /// Deploy internally to GB platform + async fn deploy_internal( + &self, + route: String, + app: GeneratedApp, + ) -> Result { + // 1. Store files in Drive + // 2. Register route in app router + // 3. Create API endpoints + // 4. Return deployment URL + + let url = format!("/apps/{}/", route); + + Ok(DeploymentResult { + url, + deployment_type: "internal".to_string(), + status: DeploymentStatus::Deployed, + metadata: serde_json::json!({ + "route": route, + "platform": "gb", + }), + }) + } + + /// Deploy externally to Forgejo + async fn deploy_external( + &self, + repo_url: &str, + app: GeneratedApp, + ) -> Result { + // 1. Initialize git repo + // 2. Add Forgejo remote + // 3. Push generated files + // 4. Create CI/CD workflow + // 5. Trigger build + + Ok(DeploymentResult { + url: repo_url.to_string(), + deployment_type: "external".to_string(), + status: DeploymentStatus::Pending, + metadata: serde_json::json!({ + "repo_url": repo_url, + "forgejo": self.forgejo_url, + }), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentResult { + pub url: String, + pub deployment_type: String, + pub status: DeploymentStatus, + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeploymentStatus { + Pending, + Building, + Deployed, + Failed, +} + +#[derive(Debug)] +pub enum DeploymentError { + InternalDeploymentError(String), + ForgejoError(String), + GitError(String), + CiCdError(String), +} + +impl std::fmt::Display for DeploymentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeploymentError::InternalDeploymentError(msg) => { + write!(f, "Internal deployment error: {}", msg) + } + DeploymentError::ForgejoError(msg) => write!(f, "Forgejo error: {}", msg), + DeploymentError::GitError(msg) => write!(f, "Git error: {}", msg), + DeploymentError::CiCdError(msg) => write!(f, "CI/CD error: {}", msg), + } + } +} + +impl std::error::Error for DeploymentError {} + +impl From for DeploymentError { + fn from(err: ForgejoError) -> Self { + DeploymentError::ForgejoError(err.to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct GeneratedApp { + pub name: String, + pub description: String, + pub files: Vec, +} + +#[derive(Debug, Clone)] +pub struct GeneratedFile { + pub path: String, + pub content: Vec, +} + +impl GeneratedApp { + pub fn temp_dir(&self) -> Result { + let temp_dir = std::env::temp_dir() + .join("gb-deployments") + .join(&self.name); + Ok(temp_dir) + } + + pub fn new(name: String, description: String) -> Self { + Self { + name, + description, + files: Vec::new(), + } + } + + pub fn add_file(&mut self, path: String, content: Vec) { + self.files.push(GeneratedFile { path, content }); + } + + pub fn add_text_file(&mut self, path: String, content: String) { + self.add_file(path, content.into_bytes()); + } +} diff --git a/src/main.rs b/src/main.rs index 6289c77a1..2e49a049d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ pub mod contacts; pub mod core; #[cfg(feature = "designer")] pub mod designer; +pub mod deployment; #[cfg(feature = "docs")] pub mod docs; pub mod embedded_ui;