feat: environment toggle (#325)

* - add git clone logic
- add logic to list Github repos
- toggle between local and cloud envs

* ci
This commit is contained in:
Gabriel Gordon-Hall
2025-07-23 12:05:41 +01:00
committed by GitHub
parent 5febd6b17b
commit 693f85ba26
17 changed files with 1092 additions and 127 deletions

View File

@@ -55,6 +55,7 @@ lazy_static = "1.4"
futures-util = "0.3"
async-stream = "0.3"
json-patch = "2.0"
backon = "1.5.1"
[dev-dependencies]
tempfile = "3.8"

View File

@@ -1,11 +1,14 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
#[cfg(unix)]
use nix::{sys::signal::Signal, unistd::Pid};
use tokio::sync::{Mutex, RwLock as TokioRwLock};
use uuid::Uuid;
use crate::services::{generate_user_id, AnalyticsConfig, AnalyticsService};
use crate::{
models::Environment,
services::{generate_user_id, AnalyticsConfig, AnalyticsService},
};
#[derive(Debug)]
pub enum ExecutionType {
@@ -29,12 +32,14 @@ pub struct AppState {
config: Arc<tokio::sync::RwLock<crate::models::config::Config>>,
pub analytics: Arc<TokioRwLock<AnalyticsService>>,
user_id: String,
pub mode: Environment,
}
impl AppState {
pub async fn new(
db_pool: sqlx::SqlitePool,
config: Arc<tokio::sync::RwLock<crate::models::config::Config>>,
mode: Environment,
) -> Self {
// Initialize analytics with user preferences
let user_enabled = {
@@ -51,6 +56,7 @@ impl AppState {
config,
analytics,
user_id: generate_user_id(),
mode,
}
}
@@ -215,4 +221,34 @@ impl AppState {
scope.set_user(Some(sentry_user));
});
}
/// Get the workspace directory path, creating it if it doesn't exist in cloud mode
pub async fn get_workspace_path(
&self,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
if !self.mode.is_cloud() {
return Err("Workspace directory only available in cloud mode".into());
}
let workspace_path = {
let config = self.config.read().await;
match &config.workspace_dir {
Some(dir) => PathBuf::from(dir),
None => {
// Use default workspace directory
let home_dir = dirs::home_dir().ok_or("Could not find home directory")?;
home_dir.join(".vibe-kanban").join("projects")
}
}
};
// Create the workspace directory if it doesn't exist
if !workspace_path.exists() {
std::fs::create_dir_all(&workspace_path)
.map_err(|e| format!("Failed to create workspace directory: {}", e))?;
tracing::info!("Created workspace directory: {}", workspace_path.display());
}
Ok(workspace_path)
}
}

View File

@@ -74,11 +74,12 @@ fn generate_types_content() -> String {
// Do not edit this file manually.\n\
// Auto-generated from Rust backend types using ts-rs\n\n";
// 5. Add `export` if its missing, then join
// 5. Add `export` if it's missing, then join
let decls = [
vibe_kanban::models::ApiResponse::<()>::decl(),
vibe_kanban::models::config::Config::decl(),
vibe_kanban::models::config::EnvironmentInfo::decl(),
vibe_kanban::models::config::Environment::decl(),
vibe_kanban::models::config::ThemeMode::decl(),
vibe_kanban::models::config::EditorConfig::decl(),
vibe_kanban::models::config::GitHubConfig::decl(),
@@ -90,6 +91,7 @@ fn generate_types_content() -> String {
vibe_kanban::executor::ExecutorConfig::decl(),
vibe_kanban::executor::ExecutorConstants::decl(),
vibe_kanban::models::project::CreateProject::decl(),
vibe_kanban::models::project::CreateProjectFromGitHub::decl(),
vibe_kanban::models::project::Project::decl(),
vibe_kanban::models::project::ProjectWithBranch::decl(),
vibe_kanban::models::project::UpdateProject::decl(),
@@ -114,6 +116,7 @@ fn generate_types_content() -> String {
vibe_kanban::routes::filesystem::DirectoryEntry::decl(),
vibe_kanban::routes::filesystem::DirectoryListResponse::decl(),
vibe_kanban::routes::auth::DeviceStartResponse::decl(),
vibe_kanban::services::github_service::RepositoryInfo::decl(),
vibe_kanban::routes::task_attempts::ProcessLogsResponse::decl(),
vibe_kanban::models::task_attempt::DiffChunkType::decl(),
vibe_kanban::models::task_attempt::DiffChunk::decl(),

View File

@@ -33,9 +33,10 @@ use middleware::{
load_execution_process_simple_middleware, load_project_middleware,
load_task_attempt_middleware, load_task_middleware, load_task_template_middleware,
};
use models::{ApiResponse, Config};
use models::{ApiResponse, Config, Environment};
use routes::{
auth, config, filesystem, health, projects, stream, task_attempts, task_templates, tasks,
auth, config, filesystem, github, health, projects, stream, task_attempts, task_templates,
tasks,
};
use services::PrMonitorService;
@@ -167,8 +168,13 @@ fn main() -> anyhow::Result<()> {
let config = Config::load(&config_path)?;
let config_arc = Arc::new(RwLock::new(config));
let env = std::env::var("ENVIRONMENT")
.unwrap_or_else(|_| "local".to_string());
let mode = env.parse().unwrap_or(Environment::Local);
tracing::info!("Running in {mode} mode" );
// Create app state
let app_state = AppState::new(pool.clone(), config_arc.clone()).await;
let app_state = AppState::new(pool.clone(), config_arc.clone(), mode).await;
app_state.update_sentry_scope().await;
@@ -245,16 +251,24 @@ fn main() -> anyhow::Result<()> {
.merge(task_attempts::task_attempts_with_id_router(app_state.clone())
.layer(from_fn_with_state(app_state.clone(), load_task_attempt_middleware)));
// Conditionally add GitHub routes for cloud mode
let mut api_routes = Router::new()
.merge(base_routes)
.merge(template_routes)
.merge(project_routes)
.merge(task_routes)
.merge(task_attempt_routes);
if mode.is_cloud() {
api_routes = api_routes.merge(github::github_router());
tracing::info!("GitHub repository routes enabled (cloud mode)");
}
// All routes (no auth required)
let app_routes = Router::new()
.nest(
"/api",
Router::new()
.merge(base_routes)
.merge(template_routes)
.merge(project_routes)
.merge(task_routes)
.merge(task_attempt_routes)
api_routes
.layer(from_fn_with_state(app_state.clone(), auth::sentry_user_context_middleware)),
);

View File

@@ -1,10 +1,49 @@
use std::path::PathBuf;
use std::{path::PathBuf, str::FromStr};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::executor::ExecutorConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
Local,
Cloud,
}
impl FromStr for Environment {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Environment::Local),
"cloud" => Ok(Environment::Cloud),
_ => Err(format!("Invalid environment: {}", s)),
}
}
}
impl Environment {
pub fn is_cloud(&self) -> bool {
matches!(self, Environment::Cloud)
}
pub fn is_local(&self) -> bool {
matches!(self, Environment::Local)
}
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Environment::Local => write!(f, "local"),
Environment::Cloud => write!(f, "cloud"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct EnvironmentInfo {
@@ -30,6 +69,7 @@ pub struct Config {
pub github: GitHubConfig,
pub analytics_enabled: Option<bool>,
pub environment: EnvironmentInfo,
pub workspace_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -186,6 +226,7 @@ impl Default for Config {
architecture: info.architecture().unwrap_or("unknown").to_string(),
bitness: info.bitness().to_string(),
},
workspace_dir: None,
}
}
}

View File

@@ -9,4 +9,4 @@ pub mod task_attempt;
pub mod task_template;
pub use api_response::ApiResponse;
pub use config::Config;
pub use config::{Config, Environment};

View File

@@ -42,6 +42,17 @@ pub struct UpdateProject {
pub cleanup_script: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateProjectFromGitHub {
pub repository_id: i64,
pub name: String,
pub clone_url: String,
pub setup_script: Option<String>,
pub dev_script: Option<String>,
pub cleanup_script: Option<String>,
}
#[derive(Debug, Serialize, TS)]
#[ts(export)]
pub struct ProjectWithBranch {

View File

@@ -16,7 +16,7 @@ use crate::{
executor::ExecutorConfig,
models::{
config::{Config, EditorConstants, SoundConstants},
ApiResponse,
ApiResponse, Environment,
},
utils,
};
@@ -70,12 +70,16 @@ async fn update_config(
pub struct ConfigConstants {
pub editor: EditorConstants,
pub sound: SoundConstants,
pub mode: Environment,
}
async fn get_config_constants() -> ResponseJson<ApiResponse<ConfigConstants>> {
async fn get_config_constants(
State(app_state): State<AppState>,
) -> ResponseJson<ApiResponse<ConfigConstants>> {
let constants = ConfigConstants {
editor: EditorConstants::new(),
sound: SoundConstants::new(),
mode: app_state.mode,
};
ResponseJson(ApiResponse::success(constants))

View File

@@ -0,0 +1,207 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::Json as ResponseJson,
routing::{get, post},
Json, Router,
};
use uuid::Uuid;
use crate::{
app_state::AppState,
models::{
project::{CreateProject, CreateProjectFromGitHub, Project},
ApiResponse,
},
services::{
git_service::GitService,
github_service::{GitHubService, RepositoryInfo},
GitHubServiceError,
},
};
#[derive(serde::Deserialize)]
pub struct RepositoryQuery {
pub page: Option<u8>,
}
/// List GitHub repositories for the authenticated user
pub async fn list_repositories(
State(app_state): State<AppState>,
Query(params): Query<RepositoryQuery>,
) -> Result<ResponseJson<ApiResponse<Vec<RepositoryInfo>>>, StatusCode> {
// Only available in cloud mode
if app_state.mode.is_local() {
return Err(StatusCode::NOT_FOUND);
}
let page = params.page.unwrap_or(1);
// Get GitHub configuration
let github_config = {
let config = app_state.get_config().read().await;
config.github.clone()
};
// Check if GitHub is configured
if github_config.token.is_none() {
return Ok(ResponseJson(ApiResponse::error(
"GitHub token not configured. Please authenticate with GitHub first.",
)));
}
// Create GitHub service with token
let github_token = github_config.token.as_deref().unwrap();
let github_service = match GitHubService::new(github_token) {
Ok(service) => service,
Err(e) => {
tracing::error!("Failed to create GitHub service: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// List repositories
match github_service.list_repositories(page).await {
Ok(repositories) => {
tracing::info!(
"Retrieved {} repositories from GitHub (page {})",
repositories.len(),
page
);
Ok(ResponseJson(ApiResponse::success(repositories)))
}
Err(GitHubServiceError::TokenInvalid) => Ok(ResponseJson(ApiResponse::error(
"GitHub token is invalid or expired. Please re-authenticate with GitHub.",
))),
Err(e) => {
tracing::error!("Failed to list GitHub repositories: {}", e);
Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to retrieve repositories: {}",
e
))))
}
}
}
/// Create a project from a GitHub repository
pub async fn create_project_from_github(
State(app_state): State<AppState>,
Json(payload): Json<CreateProjectFromGitHub>,
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
// Only available in cloud mode
if app_state.mode.is_local() {
return Err(StatusCode::NOT_FOUND);
}
tracing::debug!("Creating project '{}' from GitHub repository", payload.name);
// Get workspace path
let workspace_path = match app_state.get_workspace_path().await {
Ok(path) => path,
Err(e) => {
tracing::error!("Failed to get workspace path: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let target_path = workspace_path.join(&payload.name);
// Check if project directory already exists
if target_path.exists() {
return Ok(ResponseJson(ApiResponse::error(
"A project with this name already exists in the workspace",
)));
}
// Check if git repo path is already used by another project
match Project::find_by_git_repo_path(&app_state.db_pool, &target_path.to_string_lossy()).await {
Ok(Some(_)) => {
return Ok(ResponseJson(ApiResponse::error(
"A project with this git repository path already exists",
)));
}
Ok(None) => {
// Path is available, continue
}
Err(e) => {
tracing::error!("Failed to check for existing git repo path: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
// Get GitHub token
let github_token = {
let config = app_state.get_config().read().await;
config.github.token.clone()
};
// Clone the repository
match GitService::clone_repository(&payload.clone_url, &target_path, github_token.as_deref()) {
Ok(_) => {
tracing::info!(
"Successfully cloned repository {} to {}",
payload.clone_url,
target_path.display()
);
}
Err(e) => {
tracing::error!("Failed to clone repository: {}", e);
return Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to clone repository: {}",
e
))));
}
}
// Create project record in database
let has_setup_script = payload.setup_script.is_some();
let has_dev_script = payload.dev_script.is_some();
let project_data = CreateProject {
name: payload.name.clone(),
git_repo_path: target_path.to_string_lossy().to_string(),
use_existing_repo: true, // Since we just cloned it
setup_script: payload.setup_script,
dev_script: payload.dev_script,
cleanup_script: payload.cleanup_script,
};
let project_id = Uuid::new_v4();
match Project::create(&app_state.db_pool, &project_data, project_id).await {
Ok(project) => {
// Track project creation event
app_state
.track_analytics_event(
"project_created_from_github",
Some(serde_json::json!({
"project_id": project.id.to_string(),
"repository_id": payload.repository_id,
"clone_url": payload.clone_url,
"has_setup_script": has_setup_script,
"has_dev_script": has_dev_script,
})),
)
.await;
Ok(ResponseJson(ApiResponse::success(project)))
}
Err(e) => {
tracing::error!("Failed to create project: {}", e);
// Clean up cloned repository if project creation failed
if target_path.exists() {
if let Err(cleanup_err) = std::fs::remove_dir_all(&target_path) {
tracing::error!("Failed to cleanup cloned repository: {}", cleanup_err);
}
}
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Create router for GitHub-related endpoints (only registered in cloud mode)
pub fn github_router() -> Router<AppState> {
Router::new()
.route("/github/repositories", get(list_repositories))
.route("/projects/from-github", post(create_project_from_github))
}

View File

@@ -1,6 +1,7 @@
pub mod auth;
pub mod config;
pub mod filesystem;
pub mod github;
pub mod health;
pub mod projects;
pub mod stream;

View File

@@ -1279,6 +1279,58 @@ impl GitService {
Ok(())
}
/// Clone a repository to the specified directory
pub fn clone_repository(
clone_url: &str,
target_path: &Path,
token: Option<&str>,
) -> Result<Repository, GitServiceError> {
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Set up callbacks for authentication if token is provided
let mut callbacks = RemoteCallbacks::new();
if let Some(token) = token {
callbacks.credentials(|_url, username_from_url, _allowed_types| {
Cred::userpass_plaintext(username_from_url.unwrap_or("git"), token)
});
} else {
// Fallback to SSH agent and key file authentication
callbacks.credentials(|_url, username_from_url, _| {
// Try SSH agent first
if let Some(username) = username_from_url {
if let Ok(cred) = Cred::ssh_key_from_agent(username) {
return Ok(cred);
}
}
// Fallback to key file (~/.ssh/id_rsa)
let home = dirs::home_dir()
.ok_or_else(|| git2::Error::from_str("Could not find home directory"))?;
let key_path = home.join(".ssh").join("id_rsa");
Cred::ssh_key(username_from_url.unwrap_or("git"), None, &key_path, None)
});
}
// Set up fetch options with our callbacks
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
// Create a repository builder with fetch options
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_opts);
let repo = builder.clone(clone_url, target_path)?;
tracing::info!(
"Successfully cloned repository from {} to {}",
clone_url,
target_path.display()
);
Ok(repo)
}
}
#[cfg(test)]

View File

@@ -1,9 +1,10 @@
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use octocrab::{Octocrab, OctocrabBuilder};
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use tracing::{info, warn};
use tracing::info;
use ts_rs::TS;
#[derive(Debug)]
pub enum GitHubServiceError {
@@ -75,27 +76,23 @@ pub struct PullRequestInfo {
pub merge_commit_sha: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct RepositoryInfo {
pub id: i64,
pub name: String,
pub full_name: String,
pub owner: String,
pub description: Option<String>,
pub clone_url: String,
pub ssh_url: String,
pub default_branch: String,
pub private: bool,
}
#[derive(Debug, Clone)]
pub struct GitHubService {
client: Octocrab,
retry_config: RetryConfig,
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
}
}
}
impl GitHubService {
@@ -108,10 +105,7 @@ impl GitHubService {
GitHubServiceError::Auth(format!("Failed to create GitHub client: {}", e))
})?;
Ok(Self {
client,
retry_config: RetryConfig::default(),
})
Ok(Self { client })
}
/// Create a pull request on GitHub
@@ -120,7 +114,22 @@ impl GitHubService {
repo_info: &GitHubRepoInfo,
request: &CreatePrRequest,
) -> Result<PullRequestInfo, GitHubServiceError> {
self.with_retry(|| async { self.create_pr_internal(repo_info, request).await })
(|| async { self.create_pr_internal(repo_info, request).await })
.retry(
&ExponentialBuilder::default()
.with_min_delay(Duration::from_secs(1))
.with_max_delay(Duration::from_secs(30))
.with_max_times(3)
.with_jitter(),
)
.when(|e| !matches!(e, GitHubServiceError::TokenInvalid))
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
dur.as_secs_f64(),
err
);
})
.await
}
@@ -225,7 +234,22 @@ impl GitHubService {
repo_info: &GitHubRepoInfo,
pr_number: i64,
) -> Result<PullRequestInfo, GitHubServiceError> {
self.with_retry(|| async { self.update_pr_status_internal(repo_info, pr_number).await })
(|| async { self.update_pr_status_internal(repo_info, pr_number).await })
.retry(
&ExponentialBuilder::default()
.with_min_delay(Duration::from_secs(1))
.with_max_delay(Duration::from_secs(30))
.with_max_times(3)
.with_jitter(),
)
.when(|e| !matches!(e, GitHubServiceError::TokenInvalid))
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
dur.as_secs_f64(),
err
);
})
.await
}
@@ -268,40 +292,73 @@ impl GitHubService {
Ok(pr_info)
}
/// Retry wrapper for GitHub API calls with exponential backoff
async fn with_retry<F, Fut, T>(&self, operation: F) -> Result<T, GitHubServiceError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, GitHubServiceError>>,
{
let mut last_error = None;
/// List repositories for the authenticated user with pagination
pub async fn list_repositories(
&self,
page: u8,
) -> Result<Vec<RepositoryInfo>, GitHubServiceError> {
(|| async { self.list_repositories_internal(page).await })
.retry(
&ExponentialBuilder::default()
.with_min_delay(Duration::from_secs(1))
.with_max_delay(Duration::from_secs(30))
.with_max_times(3)
.with_jitter(),
)
.when(|e| !matches!(e, GitHubServiceError::TokenInvalid))
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
dur.as_secs_f64(),
err
);
})
.await
}
for attempt in 0..=self.retry_config.max_retries {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
last_error = Some(e);
async fn list_repositories_internal(
&self,
page: u8,
) -> Result<Vec<RepositoryInfo>, GitHubServiceError> {
let repos_page = self
.client
.current()
.list_repos_for_authenticated_user()
.type_("all")
.sort("updated")
.direction("desc")
.per_page(50)
.page(page)
.send()
.await
.map_err(|e| {
GitHubServiceError::Repository(format!("Failed to list repositories: {}", e))
})?;
if attempt < self.retry_config.max_retries {
let delay = std::cmp::min(
self.retry_config.base_delay * 2_u32.pow(attempt),
self.retry_config.max_delay,
);
let repositories: Vec<RepositoryInfo> = repos_page
.items
.into_iter()
.map(|repo| RepositoryInfo {
id: repo.id.0 as i64,
name: repo.name,
full_name: repo.full_name.unwrap_or_default(),
owner: repo.owner.map(|o| o.login).unwrap_or_default(),
description: repo.description,
clone_url: repo
.clone_url
.map(|url| url.to_string())
.unwrap_or_default(),
ssh_url: repo.ssh_url.unwrap_or_default(),
default_branch: repo.default_branch.unwrap_or_else(|| "main".to_string()),
private: repo.private.unwrap_or(false),
})
.collect();
warn!(
"GitHub API call failed (attempt {}/{}), retrying in {:?}: {}",
attempt + 1,
self.retry_config.max_retries + 1,
delay,
last_error.as_ref().unwrap()
);
sleep(delay).await;
}
}
}
}
Err(last_error.unwrap())
tracing::info!(
"Retrieved {} repositories from GitHub (page {})",
repositories.len(),
page
);
Ok(repositories)
}
}