feat: Repo level management (#1828)

* move dev server scripts onto project repos

* wip: move scripts onto Repo structs

* wip: repo settings page

* i18n and fixes

* fix refresh

* i18n

* nits

* clickable repo cards

* view logs for all dev servers

* updates to workspaces

* Let's make some changes to the way applications are tested: (vibe-kanban 4592de6c)

- When the user starts a dev server, we should automatically open the `PreviewPanel.tsx`
- In the preview panel, if no dev server script is set for any of the repos in the workspace show a message along these lines:

Vibe Kanban can run dev servers to help you test your changes. You can set this up in the repo settings area. You can learn more about testing applications here: https://www.vibekanban.com/docs/core-features/testing-your-application

- We should also not show the `PreviewControlsContainer.tsx` if none of the repos have a dev server script

`vibe-kanban/frontend/src/components/panels/PreviewPanel.tsx`

`vibe-kanban/frontend/src/components/ui-new/actions/index.ts`

`vibe-kanban/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx`

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Gabriel Gordon-Hall
2026-01-11 08:03:19 +00:00
committed by GitHub
parent 4e20df9823
commit c5554610a9
87 changed files with 2552 additions and 2172 deletions

View File

@@ -21,9 +21,9 @@ fn generate_types_content() -> String {
db::models::project::SearchResult::decl(),
db::models::project::SearchMatchType::decl(),
db::models::repo::Repo::decl(),
db::models::repo::UpdateRepo::decl(),
db::models::project_repo::ProjectRepo::decl(),
db::models::project_repo::CreateProjectRepo::decl(),
db::models::project_repo::UpdateProjectRepo::decl(),
db::models::workspace_repo::WorkspaceRepo::decl(),
db::models::workspace_repo::CreateWorkspaceRepo::decl(),
db::models::workspace_repo::RepoWithTargetBranch::decl(),
@@ -134,7 +134,6 @@ fn generate_types_content() -> String {
server::routes::task_attempts::pr::PrError::decl(),
server::routes::task_attempts::BranchStatus::decl(),
server::routes::task_attempts::RunScriptError::decl(),
server::routes::task_attempts::DeleteWorkspaceError::decl(),
server::routes::task_attempts::pr::AttachPrResponse::decl(),
server::routes::task_attempts::pr::AttachExistingPrRequest::decl(),
server::routes::task_attempts::pr::PrCommentsResponse::decl(),

View File

@@ -14,7 +14,7 @@ use axum::{
};
use db::models::{
project::{CreateProject, Project, ProjectError, SearchResult, UpdateProject},
project_repo::{CreateProjectRepo, ProjectRepo, UpdateProjectRepo},
project_repo::{CreateProjectRepo, ProjectRepo},
repo::Repo,
};
use deployment::Deployment;
@@ -568,20 +568,6 @@ pub async fn get_project_repository(
}
}
pub async fn update_project_repository(
State(deployment): State<DeploymentImpl>,
Path((project_id, repo_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<UpdateProjectRepo>,
) -> Result<ResponseJson<ApiResponse<ProjectRepo>>, ApiError> {
match ProjectRepo::update(&deployment.db().pool, project_id, repo_id, &payload).await {
Ok(project_repo) => Ok(ResponseJson(ApiResponse::success(project_repo))),
Err(db::models::project_repo::ProjectRepoError::NotFound) => Err(ApiError::BadRequest(
"Repository not found in project".to_string(),
)),
Err(e) => Err(e.into()),
}
}
pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
let project_id_router = Router::new()
.route(
@@ -609,9 +595,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.route("/", get(get_projects).post(create_project))
.route(
"/{project_id}/repositories/{repo_id}",
get(get_project_repository)
.put(update_project_repository)
.delete(delete_project_repository),
get(get_project_repository).delete(delete_project_repository),
)
.route("/stream/ws", get(stream_projects_ws))
.nest("/{id}", project_id_router);

View File

@@ -4,7 +4,7 @@ use axum::{
response::Json as ResponseJson,
routing::{get, post},
};
use db::models::repo::Repo;
use db::models::repo::{Repo, UpdateRepo};
use deployment::Deployment;
use serde::Deserialize;
use services::services::git::GitBranch;
@@ -92,6 +92,33 @@ pub async fn get_repos_batch(
Ok(ResponseJson(ApiResponse::success(repos)))
}
pub async fn get_repos(
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<Vec<Repo>>>, ApiError> {
let repos = Repo::list_all(&deployment.db().pool).await?;
Ok(ResponseJson(ApiResponse::success(repos)))
}
pub async fn get_repo(
State(deployment): State<DeploymentImpl>,
Path(repo_id): Path<Uuid>,
) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {
let repo = deployment
.repo()
.get_by_id(&deployment.db().pool, repo_id)
.await?;
Ok(ResponseJson(ApiResponse::success(repo)))
}
pub async fn update_repo(
State(deployment): State<DeploymentImpl>,
Path(repo_id): Path<Uuid>,
ResponseJson(payload): ResponseJson<UpdateRepo>,
) -> Result<ResponseJson<ApiResponse<Repo>>, ApiError> {
let repo = Repo::update(&deployment.db().pool, repo_id, &payload).await?;
Ok(ResponseJson(ApiResponse::success(repo)))
}
pub async fn open_repo_in_editor(
State(deployment): State<DeploymentImpl>,
Path(repo_id): Path<Uuid>,
@@ -141,9 +168,10 @@ pub async fn open_repo_in_editor(
pub fn router() -> Router<DeploymentImpl> {
Router::new()
.route("/repos", post(register_repo))
.route("/repos", get(get_repos).post(register_repo))
.route("/repos/init", post(init_repo))
.route("/repos/batch", post(get_repos_batch))
.route("/repos/{repo_id}", get(get_repo).put(update_repo))
.route("/repos/{repo_id}/branches", get(get_repo_branches))
.route("/repos/{repo_id}/open-editor", post(open_repo_in_editor))
}

View File

@@ -11,10 +11,10 @@ use axum::{
};
use db::models::{
execution_process::{ExecutionProcess, ExecutionProcessRunReason},
project_repo::ProjectRepo,
scratch::{Scratch, ScratchType},
session::{CreateSession, Session},
workspace::{Workspace, WorkspaceError},
workspace_repo::WorkspaceRepo,
};
use deployment::Deployment;
use executors::{
@@ -26,7 +26,6 @@ use executors::{
};
use serde::Deserialize;
use services::services::container::ContainerService;
use sqlx::Error as SqlxError;
use ts_rs::TS;
use utils::response::ApiResponse;
use uuid::Uuid;
@@ -144,18 +143,6 @@ pub async fn follow_up(
variant: payload.variant,
};
// Get parent task
let task = workspace
.parent_task(pool)
.await?
.ok_or(SqlxError::RowNotFound)?;
// Get parent project
let project = task
.parent_project(pool)
.await?
.ok_or(SqlxError::RowNotFound)?;
// If retry settings provided, perform replace-logic before proceeding
if let Some(proc_id) = payload.retry_process_id {
// Validate process belongs to this session
@@ -196,10 +183,8 @@ pub async fn follow_up(
let prompt = payload.prompt;
let project_repos = ProjectRepo::find_by_project_id_with_names(pool, project.id).await?;
let cleanup_action = deployment
.container()
.cleanup_actions_for_repos(&project_repos);
let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;
let cleanup_action = deployment.container().cleanup_actions_for_repos(&repos);
let working_dir = workspace
.agent_working_dir

View File

@@ -26,7 +26,6 @@ use db::models::{
coding_agent_turn::CodingAgentTurn,
execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus},
merge::{Merge, MergeStatus, PrMerge, PullRequestInfo},
project_repo::ProjectRepo,
repo::{Repo, RepoError},
session::{CreateSession, Session},
task::{Task, TaskRelationships, TaskStatus},
@@ -1230,33 +1229,18 @@ pub async fn start_dev_server(
}
}
// Get dev script from project (dev_script is project-level, not per-repo)
let dev_script = match &project.dev_script {
Some(script) if !script.is_empty() => script.clone(),
_ => {
return Ok(ResponseJson(ApiResponse::error(
"No dev server script configured for this project",
)));
}
};
let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;
let repos_with_dev_script: Vec<_> = repos
.iter()
.filter(|r| r.dev_server_script.as_ref().is_some_and(|s| !s.is_empty()))
.collect();
let working_dir = project
.dev_script_working_dir
.as_ref()
.filter(|dir| !dir.is_empty())
.cloned();
if repos_with_dev_script.is_empty() {
return Ok(ResponseJson(ApiResponse::error(
"No dev server script configured for any repository in this workspace",
)));
}
let executor_action = ExecutorAction::new(
ExecutorActionType::ScriptRequest(ScriptRequest {
script: dev_script,
language: ScriptRequestLanguage::Bash,
context: ScriptContext::DevServer,
working_dir,
}),
None,
);
// Get or create a session for dev server
let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? {
Some(s) => s,
None => {
@@ -1272,15 +1256,27 @@ pub async fn start_dev_server(
}
};
deployment
.container()
.start_execution(
&workspace,
&session,
&executor_action,
&ExecutionProcessRunReason::DevServer,
)
.await?;
for repo in repos_with_dev_script {
let executor_action = ExecutorAction::new(
ExecutorActionType::ScriptRequest(ScriptRequest {
script: repo.dev_server_script.clone().unwrap(),
language: ScriptRequestLanguage::Bash,
context: ScriptContext::DevServer,
working_dir: Some(repo.name.clone()),
}),
None,
);
deployment
.container()
.start_execution(
&workspace,
&session,
&executor_action,
&ExecutionProcessRunReason::DevServer,
)
.await?;
}
deployment
.track_if_analytics_allowed(
@@ -1373,7 +1369,6 @@ pub async fn run_setup_script(
.ensure_container_exists(&workspace)
.await?;
// Get parent task and project
let task = workspace
.parent_task(pool)
.await?
@@ -1383,11 +1378,9 @@ pub async fn run_setup_script(
.parent_project(pool)
.await?
.ok_or(SqlxError::RowNotFound)?;
let project_repos = ProjectRepo::find_by_project_id_with_names(pool, project.id).await?;
let executor_action = match deployment
.container()
.setup_actions_for_repos(&project_repos)
{
let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;
let executor_action = match deployment.container().setup_actions_for_repos(&repos) {
Some(action) => action,
None => {
return Ok(ResponseJson(ApiResponse::error_with_data(
@@ -1457,7 +1450,6 @@ pub async fn run_cleanup_script(
.ensure_container_exists(&workspace)
.await?;
// Get parent task and project
let task = workspace
.parent_task(pool)
.await?
@@ -1467,11 +1459,9 @@ pub async fn run_cleanup_script(
.parent_project(pool)
.await?
.ok_or(SqlxError::RowNotFound)?;
let project_repos = ProjectRepo::find_by_project_id_with_names(pool, project.id).await?;
let executor_action = match deployment
.container()
.cleanup_actions_for_repos(&project_repos)
{
let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?;
let executor_action = match deployment.container().cleanup_actions_for_repos(&repos) {
Some(action) => action,
None => {
return Ok(ResponseJson(ApiResponse::error_with_data(
@@ -1580,34 +1570,19 @@ pub async fn get_first_user_message(
Ok(ResponseJson(ApiResponse::success(message)))
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type", rename_all = "snake_case")]
pub enum DeleteWorkspaceError {
HasRunningProcesses,
}
pub async fn delete_workspace(
Extension(workspace): Extension<Workspace>,
State(deployment): State<DeploymentImpl>,
) -> Result<
(
StatusCode,
ResponseJson<ApiResponse<(), DeleteWorkspaceError>>,
),
ApiError,
> {
) -> Result<(StatusCode, ResponseJson<ApiResponse<()>>), ApiError> {
let pool = &deployment.db().pool;
// Check for running execution processes
if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id)
.await?
{
return Ok((
StatusCode::CONFLICT,
ResponseJson(ApiResponse::error_with_data(
DeleteWorkspaceError::HasRunningProcesses,
)),
return Err(ApiError::Conflict(
"Cannot delete workspace while processes are running. Stop all processes first."
.to_string(),
));
}