From 958416dce1c0e9e63e8e0839d8b5519c38a80edf Mon Sep 17 00:00:00 2001 From: Pavel Soukup Date: Sat, 3 Jan 2026 19:23:23 +0100 Subject: [PATCH 1/7] Add task completion workflow with PR and merge support Introduces a new task completion workflow including endpoints and UI for previewing and completing tasks via GitHub PR, local merge, or marking as done. Adds backend support for diff summaries, user mode (developer/basic), and workspace VCS operations. Implements new frontend components for action selection, PR options, diff summary, and completion success feedback. --- crates/github/src/gh_cli.rs | 167 +++++++ crates/github/src/lib.rs | 2 + crates/server/src/config.rs | 16 + crates/server/src/lib.rs | 29 ++ crates/server/src/project_manager.rs | 17 +- crates/server/src/routes/complete.rs | 445 ++++++++++++++++++ crates/server/src/routes/mod.rs | 2 + crates/vcs/src/git.rs | 137 +++++- crates/vcs/src/jj.rs | 64 ++- crates/vcs/src/lib.rs | 3 +- crates/vcs/src/traits.rs | 22 + crates/vcs/src/workspace.rs | 5 + .../components/complete/ActionSelector.tsx | 216 +++++++++ .../components/complete/CompletionSuccess.tsx | 140 ++++++ .../components/complete/DiffSummaryCard.tsx | 81 ++++ .../src/components/complete/PrOptionsForm.tsx | 117 +++++ frontend/src/components/complete/index.ts | 4 + .../components/dialogs/CompleteTaskDialog.tsx | 417 ++++++++++++++++ .../task-detail/TaskDetailPanel.tsx | 26 +- 19 files changed, 1896 insertions(+), 14 deletions(-) create mode 100644 crates/github/src/gh_cli.rs create mode 100644 crates/server/src/routes/complete.rs create mode 100644 frontend/src/components/complete/ActionSelector.tsx create mode 100644 frontend/src/components/complete/CompletionSuccess.tsx create mode 100644 frontend/src/components/complete/DiffSummaryCard.tsx create mode 100644 frontend/src/components/complete/PrOptionsForm.tsx create mode 100644 frontend/src/components/complete/index.ts create mode 100644 frontend/src/components/dialogs/CompleteTaskDialog.tsx diff --git a/crates/github/src/gh_cli.rs b/crates/github/src/gh_cli.rs new file mode 100644 index 0000000..091e421 --- /dev/null +++ b/crates/github/src/gh_cli.rs @@ -0,0 +1,167 @@ +use std::path::Path; +use tokio::process::Command; +use tracing::{debug, info}; + +use crate::error::{GitHubError, Result}; +use crate::types::{CreatePrRequest, PullRequest, RepoConfig}; + +/// GitHub CLI wrapper that uses the user's local `gh` authentication +pub struct GhCli { + #[allow(dead_code)] + repo: RepoConfig, + /// Working directory for gh commands (usually the repo root) + cwd: std::path::PathBuf, +} + +impl GhCli { + pub fn new(repo: RepoConfig, cwd: impl AsRef) -> Self { + Self { + repo, + cwd: cwd.as_ref().to_path_buf(), + } + } + + /// Check if gh CLI is available and authenticated + pub async fn is_available() -> bool { + let output = Command::new("gh").args(["auth", "status"]).output().await; + + match output { + Ok(o) => o.status.success(), + Err(_) => false, + } + } + + /// Create a pull request using gh CLI + pub async fn create_pull_request(&self, request: CreatePrRequest) -> Result { + info!( + "Creating PR via gh CLI: {} ({} -> {})", + request.title, request.head, request.base + ); + + let mut args = vec![ + "pr".to_string(), + "create".to_string(), + "--title".to_string(), + request.title.clone(), + "--body".to_string(), + request.body.clone(), + "--base".to_string(), + request.base.clone(), + "--head".to_string(), + request.head.clone(), + ]; + + if request.draft { + args.push("--draft".to_string()); + } + + let output = Command::new("gh") + .args(&args) + .current_dir(&self.cwd) + .output() + .await + .map_err(|e| GitHubError::Api(format!("Failed to run gh CLI: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitHubError::Api(format!("gh pr create failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let pr_url = stdout.trim().to_string(); + + // Extract PR number from URL (e.g., https://github.com/owner/repo/pull/123) + let number = pr_url + .split('/') + .last() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + debug!("Created PR #{} at {}", number, pr_url); + + Ok(PullRequest { + number, + title: request.title, + body: Some(request.body), + state: crate::types::PrState::Open, + head_branch: request.head, + base_branch: request.base, + html_url: pr_url, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + merged_at: None, + ci_status: None, + }) + } + + /// Get repository info from gh CLI + pub async fn get_repo_info(cwd: impl AsRef) -> Result { + let output = Command::new("gh") + .args(["repo", "view", "--json", "owner,name"]) + .current_dir(cwd.as_ref()) + .output() + .await + .map_err(|e| GitHubError::Api(format!("Failed to run gh CLI: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitHubError::Api(format!("gh repo view failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + #[derive(serde::Deserialize)] + struct RepoInfo { + owner: Owner, + name: String, + } + + #[derive(serde::Deserialize)] + struct Owner { + login: String, + } + + let info: RepoInfo = serde_json::from_str(&stdout) + .map_err(|e| GitHubError::Api(format!("Failed to parse gh output: {}", e)))?; + + Ok(RepoConfig { + owner: info.owner.login, + repo: info.name, + }) + } + + /// Push branch and create PR in one command + pub async fn push_and_create_pr(&self, request: CreatePrRequest) -> Result { + // First push the branch + info!("Pushing branch {} via git", request.head); + + let push_output = Command::new("git") + .args(["push", "-u", "origin", &request.head]) + .current_dir(&self.cwd) + .output() + .await + .map_err(|e| GitHubError::Api(format!("Failed to push: {}", e)))?; + + if !push_output.status.success() { + let stderr = String::from_utf8_lossy(&push_output.stderr); + // Ignore "already up to date" type errors + if !stderr.contains("Everything up-to-date") { + return Err(GitHubError::Api(format!("git push failed: {}", stderr))); + } + } + + // Then create the PR + self.create_pull_request(request).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_gh_cli_availability() { + // This test just checks the function doesn't panic + let _available = GhCli::is_available().await; + } +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 3be316c..11e5c10 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -1,9 +1,11 @@ pub mod client; pub mod error; +pub mod gh_cli; pub mod types; pub use client::GitHubClient; pub use error::{GitHubError, Result}; +pub use gh_cli::GhCli; pub use types::{ CheckRun, CiState, CiStatus, CreatePrRequest, Issue, IssueState, PrState, PullRequest, RepoConfig, diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 7e66c66..43e828d 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -36,6 +36,19 @@ pub struct PhaseModels { pub fix: Option, } +/// User interface mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +#[serde(rename_all = "lowercase")] +pub enum UserMode { + /// Full control with all options visible + #[default] + Developer, + /// Simplified interface for non-developers + Basic, +} + /// Project-level configuration stored in .opencode-studio/config.json #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -44,6 +57,9 @@ pub struct ProjectConfig { /// Per-phase model settings #[serde(default)] pub phase_models: PhaseModels, + /// User interface mode (developer or basic) + #[serde(default)] + pub user_mode: UserMode, } impl ProjectConfig { diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f38ec73..73a0805 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -67,6 +67,10 @@ use state::AppState; routes::opencode::get_providers, routes::settings::get_phase_models, routes::settings::update_phase_models, + routes::complete::get_complete_preview, + routes::complete::complete_task, + routes::complete::get_user_mode, + routes::complete::update_user_mode, ), components(schemas( routes::HealthResponse, @@ -116,6 +120,18 @@ use state::AppState; config::ModelSelection, config::PhaseModels, config::ProjectConfig, + config::UserMode, + routes::complete::CompletePreviewResponse, + routes::complete::CompleteAction, + routes::complete::PrOptions, + routes::complete::MergeOptions, + routes::complete::CompleteTaskRequest, + routes::complete::CompleteTaskResponse, + routes::complete::PrInfo, + routes::complete::MergeResultInfo, + routes::complete::UserModeResponse, + routes::complete::UpdateUserModeRequest, + vcs::DiffSummary, opencode_core::Task, opencode_core::TaskStatus, opencode_core::CreateTaskRequest, @@ -136,6 +152,7 @@ use state::AppState; (name = "filesystem", description = "Filesystem browsing endpoints"), (name = "opencode", description = "OpenCode integration endpoints"), (name = "settings", description = "Project settings endpoints"), + (name = "complete", description = "Task completion and Git workflow endpoints"), ) )] pub struct ApiDoc; @@ -239,6 +256,18 @@ pub fn create_router(state: AppState) -> Router { "/api/settings/models", get(routes::settings::get_phase_models).put(routes::settings::update_phase_models), ) + .route( + "/api/settings/user-mode", + get(routes::complete::get_user_mode).put(routes::complete::update_user_mode), + ) + .route( + "/api/tasks/{id}/complete/preview", + get(routes::complete::get_complete_preview), + ) + .route( + "/api/tasks/{id}/complete", + post(routes::complete::complete_task), + ) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/crates/server/src/project_manager.rs b/crates/server/src/project_manager.rs index e7366e2..71df42d 100644 --- a/crates/server/src/project_manager.rs +++ b/crates/server/src/project_manager.rs @@ -5,7 +5,9 @@ use db::{SessionActivityRepository, SessionRepository, TaskRepository}; use events::EventBus; use opencode_client::apis::configuration::Configuration as OpenCodeConfig; -use orchestrator::{ExecutorConfig, ModelSelection, PhaseModels, SessionActivityRegistry, TaskExecutor}; +use orchestrator::{ + ExecutorConfig, ModelSelection, PhaseModels, SessionActivityRegistry, TaskExecutor, +}; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use std::path::{Path, PathBuf}; @@ -306,6 +308,19 @@ impl ProjectContext { }) } + /// Get the JSON config (phase models, user mode, etc.) + pub async fn get_config(&self) -> JsonProjectConfig { + JsonProjectConfig::read(&self.project_path).await + } + + /// Save the JSON config + pub async fn save_config(&self, config: &JsonProjectConfig) -> Result<(), ProjectError> { + config + .write(&self.project_path) + .await + .map_err(|e| ProjectError::Config(e.to_string())) + } + /// Get project info for API responses. pub async fn info(&self) -> ProjectInfo { let name = self diff --git a/crates/server/src/routes/complete.rs b/crates/server/src/routes/complete.rs new file mode 100644 index 0000000..8dd1db7 --- /dev/null +++ b/crates/server/src/routes/complete.rs @@ -0,0 +1,445 @@ +use axum::extract::{Path, State}; +use axum::Json; +use github::{CreatePrRequest, GhCli, RepoConfig}; +use opencode_core::{TaskStatus, UpdateTaskRequest}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use vcs::DiffSummary; + +use crate::config::UserMode; +use crate::error::AppError; +use crate::state::AppState; + +// ============================================================================ +// Complete Preview Endpoint +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct CompletePreviewResponse { + pub task_id: String, + pub branch_name: String, + pub base_branch: String, + pub diff_summary: DiffSummary, + pub suggested_pr_title: String, + pub suggested_pr_body: String, + pub github_available: bool, + pub has_uncommitted_changes: bool, +} + +#[utoipa::path( + get, + path = "/api/tasks/{task_id}/complete/preview", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 200, description = "Complete preview data", body = CompletePreviewResponse), + (status = 404, description = "Task or workspace not found"), + (status = 400, description = "Task not in review status") + ), + tag = "complete" +)] +pub async fn get_complete_preview( + State(state): State, + Path(task_id): Path, +) -> Result, AppError> { + let project = state.project().await?; + + // Get task + let task = project + .task_repository + .find_by_id(task_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("Task not found: {}", task_id)))?; + + // Validate task status + if task.status != TaskStatus::Review { + return Err(AppError::BadRequest( + "Task must be in review status to complete".to_string(), + )); + } + + // Find workspace + let workspaces = project.workspace_manager.list_workspaces().await?; + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id.to_string()) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found for task: {}", task_id)))?; + + // Get diff summary + let diff_summary = project + .workspace_manager + .vcs() + .get_diff_summary(&workspace) + .await?; + + // Check for uncommitted changes + let has_uncommitted_changes = project + .workspace_manager + .vcs() + .has_uncommitted_changes(&workspace) + .await?; + + // Check if GitHub is available (via token OR gh CLI) + let github_available = state.github_client().await.is_ok() || GhCli::is_available().await; + + // Get main branch + let base_branch = project.workspace_manager.vcs().main_branch().to_string(); + + // Generate suggested PR content + let suggested_pr_title = task.title.clone(); + let suggested_pr_body = + generate_pr_body(&task.title, Some(task.description.as_str()), &diff_summary); + + Ok(Json(CompletePreviewResponse { + task_id: task_id.to_string(), + branch_name: workspace.branch_name, + base_branch, + diff_summary, + suggested_pr_title, + suggested_pr_body, + github_available, + has_uncommitted_changes, + })) +} + +fn generate_pr_body(title: &str, description: Option<&str>, summary: &DiffSummary) -> String { + let mut body = String::new(); + + body.push_str("## Summary\n\n"); + if let Some(desc) = description { + body.push_str(desc); + body.push_str("\n\n"); + } else { + body.push_str(&format!("{}\n\n", title)); + } + + body.push_str("## Changes\n\n"); + body.push_str(&format!("- **{}** files changed\n", summary.files_changed)); + body.push_str(&format!("- **+{}** additions\n", summary.additions)); + body.push_str(&format!("- **-{}** deletions\n", summary.deletions)); + + body +} + +// ============================================================================ +// Complete Task Endpoint +// ============================================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +#[serde(rename_all = "snake_case")] +pub enum CompleteAction { + /// Create a pull request on GitHub + CreatePr, + /// Merge changes to local main branch + MergeLocal, + /// Just mark as complete without merging + CompleteOnly, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct PrOptions { + pub title: String, + pub body: String, + pub base_branch: String, + pub draft: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct MergeOptions { + pub commit_message: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct CompleteTaskRequest { + pub action: CompleteAction, + pub pr_options: Option, + pub merge_options: Option, + pub cleanup_worktree: bool, +} + +#[derive(Debug, Serialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct PrInfo { + pub number: u64, + pub url: String, + pub title: String, +} + +#[derive(Debug, Serialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MergeResultInfo { + Success { commit_sha: Option }, + Conflicts { files: Vec }, +} + +#[derive(Debug, Serialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct CompleteTaskResponse { + pub success: bool, + pub pr: Option, + pub merge_result: Option, + pub worktree_cleaned: bool, +} + +#[utoipa::path( + post, + path = "/api/tasks/{task_id}/complete", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + request_body = CompleteTaskRequest, + responses( + (status = 200, description = "Task completed successfully", body = CompleteTaskResponse), + (status = 404, description = "Task or workspace not found"), + (status = 400, description = "Invalid request or task not in review status"), + (status = 409, description = "Merge conflicts") + ), + tag = "complete" +)] +pub async fn complete_task( + State(state): State, + Path(task_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let project = state.project().await?; + + // Get task + let task = project + .task_repository + .find_by_id(task_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("Task not found: {}", task_id)))?; + + // Validate task status + if task.status != TaskStatus::Review { + return Err(AppError::BadRequest( + "Task must be in review status to complete".to_string(), + )); + } + + // Find workspace + let workspaces = project.workspace_manager.list_workspaces().await?; + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id.to_string()) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found for task: {}", task_id)))?; + + let mut response = CompleteTaskResponse { + success: false, + pr: None, + merge_result: None, + worktree_cleaned: false, + }; + + match payload.action { + CompleteAction::CreatePr => { + let pr_opts = payload.pr_options.ok_or_else(|| { + AppError::BadRequest("PR options required for create_pr action".to_string()) + })?; + + // Commit any uncommitted changes + let has_changes = project + .workspace_manager + .vcs() + .has_uncommitted_changes(&workspace) + .await?; + + if has_changes { + let commit_msg = format!("{}\n\n{}", pr_opts.title, pr_opts.body); + project + .workspace_manager + .vcs() + .commit(&workspace, &commit_msg) + .await?; + } + + // Build PR request + let pr_request = + CreatePrRequest::new(&pr_opts.title, &workspace.branch_name, &pr_opts.base_branch) + .with_body(&pr_opts.body); + + let pr_request = if pr_opts.draft { + pr_request.as_draft() + } else { + pr_request + }; + + // Try GitHub API first, fall back to gh CLI + let pr = if let Ok(github_client) = state.github_client().await { + // Push branch to remote first + project + .workspace_manager + .vcs() + .push(&workspace, "origin") + .await + .map_err(|e| AppError::Internal(format!("Failed to push branch: {}", e)))?; + + // Create PR via GitHub API (with token) + github_client + .create_pull_request(pr_request) + .await + .map_err(|e| AppError::Internal(format!("Failed to create PR: {}", e)))? + } else if GhCli::is_available().await { + // Use gh CLI (uses user's local authentication) + let repo_config = RepoConfig::from_git_remote(&project.path) + .await + .ok_or_else(|| { + AppError::BadRequest( + "Could not detect GitHub repository from git remote".to_string(), + ) + })?; + + let gh_cli = GhCli::new(repo_config, &workspace.path); + + // gh CLI handles push + PR creation + gh_cli + .push_and_create_pr(pr_request) + .await + .map_err(|e| AppError::Internal(format!("Failed to create PR via gh: {}", e)))? + } else { + return Err(AppError::BadRequest( + "GitHub not available. Please set GITHUB_TOKEN or install and authenticate gh CLI.".to_string(), + )); + }; + + response.pr = Some(PrInfo { + number: pr.number, + url: pr.html_url, + title: pr.title, + }); + } + + CompleteAction::MergeLocal => { + let merge_opts = payload.merge_options.unwrap_or_else(|| MergeOptions { + commit_message: format!("Merge task: {}", task.title), + }); + + let merge_result = project + .workspace_manager + .merge_workspace(&workspace, &merge_opts.commit_message) + .await?; + + match merge_result { + vcs::MergeResult::Success => { + response.merge_result = Some(MergeResultInfo::Success { commit_sha: None }); + } + vcs::MergeResult::Conflicts { files } => { + let conflict_paths: Vec = + files.iter().map(|f| f.path.display().to_string()).collect(); + response.merge_result = Some(MergeResultInfo::Conflicts { + files: conflict_paths.clone(), + }); + return Err(AppError::Conflict(format!( + "Merge conflicts in: {}", + conflict_paths.join(", ") + ))); + } + } + } + + CompleteAction::CompleteOnly => { + // Just transition to done, no merge/PR + } + } + + // Cleanup worktree if requested + if payload.cleanup_worktree { + project + .workspace_manager + .cleanup_workspace(&workspace) + .await?; + response.worktree_cleaned = true; + } + + // Transition task to done + let update_request = UpdateTaskRequest { + title: None, + description: None, + status: Some(TaskStatus::Done), + workspace_path: Some(String::new()), // Clear workspace path + }; + project + .task_repository + .update(task_id, &update_request) + .await?; + + response.success = true; + Ok(Json(response)) +} + +// ============================================================================ +// User Mode Endpoints +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct UserModeResponse { + pub mode: UserMode, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct UpdateUserModeRequest { + pub mode: UserMode, +} + +#[utoipa::path( + get, + path = "/api/settings/user-mode", + responses( + (status = 200, description = "Current user mode", body = UserModeResponse) + ), + tag = "settings" +)] +pub async fn get_user_mode( + State(state): State, +) -> Result, AppError> { + let project = state.project().await?; + let config = project.get_config().await; + + Ok(Json(UserModeResponse { + mode: config.user_mode, + })) +} + +#[utoipa::path( + put, + path = "/api/settings/user-mode", + request_body = UpdateUserModeRequest, + responses( + (status = 200, description = "User mode updated", body = UserModeResponse) + ), + tag = "settings" +)] +pub async fn update_user_mode( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let project = state.project().await?; + let mut config = project.get_config().await; + + config.user_mode = payload.mode; + project.save_config(&config).await?; + + Ok(Json(UserModeResponse { + mode: config.user_mode, + })) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 95ec178..3ac9c3d 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -1,4 +1,5 @@ mod comments; +pub mod complete; pub mod filesystem; mod health; pub mod opencode; @@ -11,6 +12,7 @@ mod tasks; mod workspaces; pub use comments::*; +pub use complete::*; pub use filesystem::*; pub use health::*; pub use opencode::*; diff --git a/crates/vcs/src/git.rs b/crates/vcs/src/git.rs index 7219ccc..9b79b0a 100644 --- a/crates/vcs/src/git.rs +++ b/crates/vcs/src/git.rs @@ -4,7 +4,9 @@ use tokio::process::Command; use tracing::{debug, warn}; use crate::error::{Result, VcsError}; -use crate::traits::{ConflictFile, ConflictType, MergeResult, VersionControl, Workspace}; +use crate::traits::{ + ConflictFile, ConflictType, DiffSummary, MergeResult, VersionControl, Workspace, +}; pub struct GitVcs { repo_path: PathBuf, @@ -148,6 +150,7 @@ impl VersionControl for GitVcs { return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); } + // Commit any uncommitted changes in the workspace let status = self.get_status(workspace).await?; if !status.is_empty() { self.run_git(&["add", "-A"], &workspace.path).await?; @@ -155,6 +158,83 @@ impl VersionControl for GitVcs { .await?; } + // Check if main branch has uncommitted changes in the main repo + // This would prevent checkout, so we use a different strategy + let main_status = self + .run_git(&["status", "--porcelain"], &self.repo_path) + .await?; + + if !main_status.trim().is_empty() { + // Main repo has uncommitted changes - use fetch + merge strategy + // First, fetch the workspace branch into main repo + // Then merge using git merge without checkout + + // Get the commit SHA from workspace + let workspace_sha = self + .run_git(&["rev-parse", "HEAD"], &workspace.path) + .await? + .trim() + .to_string(); + + // Update the branch ref in main repo to point to workspace's HEAD + self.run_git( + &[ + "fetch", + workspace.path.to_str().unwrap_or("."), + &format!("{}:{}", workspace.branch_name, workspace.branch_name), + ], + &self.repo_path, + ) + .await?; + + // Now try to merge using git merge-tree to check for conflicts first + // If there are conflicts, we abort. Otherwise, we do the merge. + + // Check if fast-forward is possible + let merge_base = self + .run_git( + &["merge-base", &self.main_branch, &workspace.branch_name], + &self.repo_path, + ) + .await? + .trim() + .to_string(); + + let main_sha = self + .run_git(&["rev-parse", &self.main_branch], &self.repo_path) + .await? + .trim() + .to_string(); + + if merge_base == main_sha { + // Fast-forward is possible - update main branch ref directly + self.run_git( + &[ + "update-ref", + &format!("refs/heads/{}", self.main_branch), + &workspace_sha, + ], + &self.repo_path, + ) + .await?; + + debug!( + "Fast-forwarded {} to {}", + self.main_branch, workspace.branch_name + ); + return Ok(MergeResult::Success); + } + + // Non-fast-forward merge needed - this is more complex + // For safety, we'll return an error asking user to resolve manually + // or stash their changes first + return Err(VcsError::CommandFailed( + "Cannot merge: main branch has diverged and your working directory has uncommitted changes. \ + Please commit or stash your changes in the main repository first, then try again.".to_string() + )); + } + + // Main repo is clean - use standard checkout + merge approach self.run_git(&["checkout", &self.main_branch], &self.repo_path) .await?; @@ -293,6 +373,61 @@ impl VersionControl for GitVcs { Ok(()) } + + async fn get_diff_summary(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + // Get diff stats comparing workspace branch to main branch + // Use --numstat for machine-readable output: additions deletions filename + let output = self + .run_git( + &["diff", "--numstat", &self.main_branch, "HEAD"], + &workspace.path, + ) + .await?; + + let mut files_changed: u32 = 0; + let mut additions: u32 = 0; + let mut deletions: u32 = 0; + + for line in output.lines() { + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 2 { + files_changed += 1; + // Binary files show "-" for additions/deletions + if let Ok(add) = parts[0].parse::() { + additions += add; + } + if let Ok(del) = parts[1].parse::() { + deletions += del; + } + } + } + + Ok(DiffSummary { + files_changed, + additions, + deletions, + }) + } + + fn main_branch(&self) -> &str { + &self.main_branch + } + + async fn has_uncommitted_changes(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + let status = self.get_status(workspace).await?; + Ok(!status.trim().is_empty()) + } } #[cfg(test)] diff --git a/crates/vcs/src/jj.rs b/crates/vcs/src/jj.rs index 917a8f7..4bfeb8f 100644 --- a/crates/vcs/src/jj.rs +++ b/crates/vcs/src/jj.rs @@ -4,7 +4,9 @@ use tokio::process::Command; use tracing::{debug, warn}; use crate::error::{Result, VcsError}; -use crate::traits::{ConflictFile, ConflictType, MergeResult, VersionControl, Workspace}; +use crate::traits::{ + ConflictFile, ConflictType, DiffSummary, MergeResult, VersionControl, Workspace, +}; pub struct JujutsuVcs { repo_path: PathBuf, @@ -269,6 +271,66 @@ impl VersionControl for JujutsuVcs { Ok(()) } + + async fn get_diff_summary(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + // Get diff stats using jj diff --stat + let output = self + .run_jj(&["diff", "--from", "main", "--stat"], &workspace.path) + .await?; + + let mut files_changed: u32 = 0; + let mut additions: u32 = 0; + let mut deletions: u32 = 0; + + // Parse jj diff --stat output + // Last line is summary: "X files changed, Y insertions(+), Z deletions(-)" + for line in output.lines() { + if line.contains("files changed") || line.contains("file changed") { + // Parse summary line + for part in line.split(',') { + let part = part.trim(); + if part.contains("file") { + if let Some(num) = part.split_whitespace().next() { + files_changed = num.parse().unwrap_or(0); + } + } else if part.contains("insertion") { + if let Some(num) = part.split_whitespace().next() { + additions = num.parse().unwrap_or(0); + } + } else if part.contains("deletion") { + if let Some(num) = part.split_whitespace().next() { + deletions = num.parse().unwrap_or(0); + } + } + } + } + } + + Ok(DiffSummary { + files_changed, + additions, + deletions, + }) + } + + fn main_branch(&self) -> &str { + "main" + } + + async fn has_uncommitted_changes(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + // In jj, check if there are any changes in the working copy + let status = self.get_status(workspace).await?; + // jj status shows "Working copy changes:" if there are changes + Ok(status.contains("Working copy changes:")) + } } #[cfg(test)] diff --git a/crates/vcs/src/lib.rs b/crates/vcs/src/lib.rs index c285617..8adf1ea 100644 --- a/crates/vcs/src/lib.rs +++ b/crates/vcs/src/lib.rs @@ -8,6 +8,7 @@ pub use error::{Result, VcsError}; pub use git::GitVcs; pub use jj::JujutsuVcs; pub use traits::{ - ConflictFile, ConflictType, MergeResult, VersionControl, Workspace, WorkspaceStatus, + ConflictFile, ConflictType, DiffSummary, MergeResult, VersionControl, Workspace, + WorkspaceStatus, }; pub use workspace::{WorkspaceConfig, WorkspaceManager}; diff --git a/crates/vcs/src/traits.rs b/crates/vcs/src/traits.rs index b177760..2953c81 100644 --- a/crates/vcs/src/traits.rs +++ b/crates/vcs/src/traits.rs @@ -85,6 +85,19 @@ pub enum ConflictType { Rename, } +/// Summary of changes in a workspace +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export))] +pub struct DiffSummary { + /// Number of files changed + pub files_changed: u32, + /// Number of lines added + pub additions: u32, + /// Number of lines deleted + pub deletions: u32, +} + /// Trait for version control system operations #[async_trait] pub trait VersionControl: Send + Sync { @@ -123,6 +136,15 @@ pub trait VersionControl: Send + Sync { /// Push changes to remote (if applicable) async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()>; + + /// Get a summary of changes in a workspace (files changed, additions, deletions) + async fn get_diff_summary(&self, workspace: &Workspace) -> Result; + + /// Get the main/default branch name + fn main_branch(&self) -> &str; + + /// Check if there are uncommitted changes in a workspace + async fn has_uncommitted_changes(&self, workspace: &Workspace) -> Result; } #[cfg(test)] diff --git a/crates/vcs/src/workspace.rs b/crates/vcs/src/workspace.rs index 084f137..b587a35 100644 --- a/crates/vcs/src/workspace.rs +++ b/crates/vcs/src/workspace.rs @@ -214,6 +214,11 @@ impl WorkspaceManager { pub async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()> { self.vcs.push(workspace, remote).await } + + /// Get a reference to the underlying VCS implementation + pub fn vcs(&self) -> &dyn VersionControl { + self.vcs.as_ref() + } } #[cfg(test)] diff --git a/frontend/src/components/complete/ActionSelector.tsx b/frontend/src/components/complete/ActionSelector.tsx new file mode 100644 index 0000000..4cc8539 --- /dev/null +++ b/frontend/src/components/complete/ActionSelector.tsx @@ -0,0 +1,216 @@ +import { cn } from "@/lib/utils"; + +export type CompleteAction = "create_pr" | "merge_local" | "complete_only"; + +interface ActionOption { + value: CompleteAction; + label: string; + description: string; + icon: React.ReactNode; + disabled?: boolean; + disabledReason?: string; +} + +interface ActionSelectorProps { + value: CompleteAction; + onChange: (value: CompleteAction) => void; + githubAvailable: boolean; + mode: "developer" | "basic"; +} + +const GitHubIcon = () => ( + + + +); + +const GitIcon = () => ( + + + + + + +); + +const CheckIcon = () => ( + + + + +); + +export function ActionSelector({ + value, + onChange, + githubAvailable, + mode, +}: ActionSelectorProps) { + const options: ActionOption[] = [ + { + value: "create_pr", + label: "Create Pull Request", + description: "Push to GitHub and create a PR for code review", + icon: , + disabled: !githubAvailable, + disabledReason: "GitHub token not configured", + }, + { + value: "merge_local", + label: "Merge to Main", + description: "Apply changes directly to your main branch", + icon: , + }, + ...(mode === "developer" + ? [ + { + value: "complete_only" as CompleteAction, + label: "Just Complete", + description: "Mark as done without merging (keep worktree)", + icon: , + }, + ] + : []), + ]; + + if (mode === "basic") { + // Basic mode: card-style buttons + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + } + + // Developer mode: radio buttons + return ( +
+
+ What would you like to do? +
+
+ {options.map((option) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/complete/CompletionSuccess.tsx b/frontend/src/components/complete/CompletionSuccess.tsx new file mode 100644 index 0000000..9f1aea8 --- /dev/null +++ b/frontend/src/components/complete/CompletionSuccess.tsx @@ -0,0 +1,140 @@ +import { Button } from "@/components/ui/button"; + +interface PrInfo { + number: number; + url: string; + title: string; +} + +interface CompletionSuccessProps { + pr?: PrInfo | null; + worktreeCleaned: boolean; + onClose: () => void; + onViewPr?: () => void; +} + +export function CompletionSuccess({ + pr, + worktreeCleaned, + onClose, + onViewPr, +}: CompletionSuccessProps) { + return ( +
+ {/* Success icon */} +
+ + + + +
+ + {/* Title */} +

+ {pr ? "Pull Request Created!" : "Task Completed!"} +

+ + {/* PR info */} + {pr && ( +
+
+
+ + + + + PR #{pr.number} + +
+

{pr.title}

+ + {pr.url} + +
+
+ )} + + {/* Status list */} +
+
+
+ + + + Task marked as done +
+ {pr && ( +
+ + + + Changes pushed to GitHub +
+ )} + {worktreeCleaned && ( +
+ + + + Worktree cleaned up +
+ )} +
+
+ + {/* Actions */} +
+ {pr && onViewPr && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/complete/DiffSummaryCard.tsx b/frontend/src/components/complete/DiffSummaryCard.tsx new file mode 100644 index 0000000..6275ae9 --- /dev/null +++ b/frontend/src/components/complete/DiffSummaryCard.tsx @@ -0,0 +1,81 @@ +import { cn } from "@/lib/utils"; + +interface DiffSummaryCardProps { + branchName: string; + baseBranch: string; + filesChanged: number; + additions: number; + deletions: number; + className?: string; +} + +export function DiffSummaryCard({ + branchName, + baseBranch, + filesChanged, + additions, + deletions, + className, +}: DiffSummaryCardProps) { + return ( +
+
+
+
Branch
+
+ {branchName} +
+
+
+ + + +
+
+
Base
+
+ {baseBranch} +
+
+
+ +
+
+ +{additions} + lines +
+
+ -{deletions} + lines +
+
+
+ + + + + + {filesChanged} {filesChanged === 1 ? "file" : "files"} + +
+
+
+ ); +} diff --git a/frontend/src/components/complete/PrOptionsForm.tsx b/frontend/src/components/complete/PrOptionsForm.tsx new file mode 100644 index 0000000..d722254 --- /dev/null +++ b/frontend/src/components/complete/PrOptionsForm.tsx @@ -0,0 +1,117 @@ +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +interface PrOptionsFormProps { + title: string; + onTitleChange: (value: string) => void; + body: string; + onBodyChange: (value: string) => void; + baseBranch: string; + onBaseBranchChange: (value: string) => void; + isDraft: boolean; + onIsDraftChange: (value: boolean) => void; + availableBranches?: string[]; + mode: "developer" | "basic"; +} + +export function PrOptionsForm({ + title, + onTitleChange, + body, + onBodyChange, + baseBranch, + onBaseBranchChange, + isDraft, + onIsDraftChange, + availableBranches = ["main", "master", "develop"], + mode, +}: PrOptionsFormProps) { + if (mode === "basic") { + // Basic mode: minimal form, just title preview + return ( +
+
+ PR will be created with title: +
+
{title}
+
+ ); + } + + // Developer mode: full form + return ( +
+
+ + onTitleChange(e.target.value)} + placeholder="Pull request title" + /> +
+ +
+ +