diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 252a2901..e26f5992 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, env, fs, path::Path}; use schemars::{JsonSchema, Schema, SchemaGenerator, generate::SchemaSettings}; +use server::routes::task_attempts::pr::DEFAULT_PR_DESCRIPTION_PROMPT; use ts_rs::TS; fn generate_types_content() -> String { @@ -103,7 +104,7 @@ fn generate_types_content() -> String { server::routes::shared_tasks::AssignSharedTaskRequest::decl(), server::routes::tasks::ShareTaskResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), - server::routes::task_attempts::CreateGitHubPrRequest::decl(), + server::routes::task_attempts::pr::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), server::routes::images::ImageMetadata::decl(), server::routes::task_attempts::CreateTaskAttemptBody::decl(), @@ -113,12 +114,12 @@ fn generate_types_content() -> String { server::routes::task_attempts::RebaseTaskAttemptRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), server::routes::task_attempts::PushError::decl(), - server::routes::task_attempts::CreatePrError::decl(), + server::routes::task_attempts::pr::CreatePrError::decl(), server::routes::task_attempts::BranchStatus::decl(), server::routes::task_attempts::RunScriptError::decl(), - server::routes::task_attempts::AttachPrResponse::decl(), - server::routes::task_attempts::PrCommentsResponse::decl(), - server::routes::task_attempts::GetPrCommentsError::decl(), + server::routes::task_attempts::pr::AttachPrResponse::decl(), + server::routes::task_attempts::pr::PrCommentsResponse::decl(), + server::routes::task_attempts::pr::GetPrCommentsError::decl(), services::services::github::UnifiedPrComment::decl(), services::services::filesystem::DirectoryEntry::decl(), services::services::filesystem::DirectoryListResponse::decl(), @@ -198,7 +199,16 @@ fn generate_types_content() -> String { .collect::>() .join("\n\n"); - format!("{HEADER}\n\n{body}") + // Append exported constants + let prompt_escaped = DEFAULT_PR_DESCRIPTION_PROMPT + .replace('\\', "\\\\") + .replace('`', "\\`"); + let constants = format!( + "export const DEFAULT_PR_DESCRIPTION_PROMPT = `{}`;", + prompt_escaped + ); + + format!("{HEADER}\n\n{body}\n\n{constants}") } fn generate_json_schema() -> Result { diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 5c8c1e53..72233119 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -2,6 +2,7 @@ pub mod codex_setup; pub mod cursor_setup; pub mod gh_cli_setup; pub mod images; +pub mod pr; pub mod queue; pub mod util; @@ -39,7 +40,7 @@ use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, git::{ConflictOp, GitCliError, GitServiceError, WorktreeResetOptions}, - github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment}, + github::GitHubService, }; use sqlx::Error as SqlxError; use ts_rs::TS; @@ -67,14 +68,6 @@ pub enum GitOperationError { RebaseInProgress, } -#[derive(Debug, Deserialize, Serialize, TS)] -pub struct CreateGitHubPrRequest { - pub title: String, - pub body: Option, - pub target_branch: Option, - pub draft: Option, -} - #[derive(Debug, Deserialize)] pub struct TaskAttemptQuery { pub task_id: Option, @@ -623,179 +616,6 @@ pub enum PushError { ForcePushRequired, } -#[derive(Debug, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type", rename_all = "snake_case")] -pub enum CreatePrError { - GithubCliNotInstalled, - GithubCliNotLoggedIn, - GitCliNotLoggedIn, - GitCliNotInstalled, - TargetBranchNotFound { branch: String }, -} - -pub async fn create_github_pr( - Extension(task_attempt): Extension, - State(deployment): State, - Json(request): Json, -) -> Result>, ApiError> { - let github_config = deployment.config().read().await.github.clone(); - // Get the task attempt to access the stored target branch - let target_branch = request.target_branch.unwrap_or_else(|| { - // Use the stored target branch from the task attempt as the default - // Fall back to config default or "main" only if stored target branch is somehow invalid - if !task_attempt.target_branch.trim().is_empty() { - task_attempt.target_branch.clone() - } else { - github_config - .default_pr_base - .as_ref() - .map_or_else(|| "main".to_string(), |b| b.to_string()) - } - }); - - let pool = &deployment.db().pool; - let task = task_attempt - .parent_task(pool) - .await? - .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; - let project = Project::find_by_id(pool, task.project_id) - .await? - .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; - - let workspace_path = ensure_worktree_path(&deployment, &task_attempt).await?; - - match deployment - .git() - .check_remote_branch_exists(&project.git_repo_path, &target_branch) - { - Ok(false) => { - return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::TargetBranchNotFound { - branch: target_branch.clone(), - }, - ))); - } - Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { - return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, - ))); - } - Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { - return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, - ))); - } - Err(e) => return Err(ApiError::GitService(e)), - Ok(true) => {} - } - - // Push the branch to GitHub first - if let Err(e) = deployment - .git() - .push_to_github(&workspace_path, &task_attempt.branch, false) - { - tracing::error!("Failed to push branch to GitHub: {}", e); - match e { - GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { - return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotLoggedIn, - ))); - } - GitServiceError::GitCLI(GitCliError::NotAvailable) => { - return Ok(ResponseJson(ApiResponse::error_with_data( - CreatePrError::GitCliNotInstalled, - ))); - } - _ => return Err(ApiError::GitService(e)), - } - } - - let norm_target_branch_name = if matches!( - deployment - .git() - .find_branch_type(&project.git_repo_path, &target_branch)?, - BranchType::Remote - ) { - // Remote branches are formatted as {remote}/{branch} locally. - // For PR APIs, we must provide just the branch name. - let remote = deployment - .git() - .get_remote_name_from_branch_name(&workspace_path, &target_branch)?; - let remote_prefix = format!("{}/", remote); - target_branch - .strip_prefix(&remote_prefix) - .unwrap_or(&target_branch) - .to_string() - } else { - target_branch - }; - // Create the PR using GitHub service - let pr_request = CreatePrRequest { - title: request.title.clone(), - body: request.body.clone(), - head_branch: task_attempt.branch.clone(), - base_branch: norm_target_branch_name.clone(), - draft: request.draft, - }; - // Use GitService to get the remote URL, then create GitHubRepoInfo - let repo_info = deployment - .git() - .get_github_repo_info(&project.git_repo_path)?; - - // Use GitHubService to create the PR - let github_service = GitHubService::new()?; - match github_service.create_pr(&repo_info, &pr_request).await { - Ok(pr_info) => { - // Update the task attempt with PR information - if let Err(e) = Merge::create_pr( - pool, - task_attempt.id, - &norm_target_branch_name, - pr_info.number, - &pr_info.url, - ) - .await - { - tracing::error!("Failed to update task attempt PR status: {}", e); - } - - // Auto-open PR in browser - if let Err(e) = utils::browser::open_browser(&pr_info.url).await { - tracing::warn!("Failed to open PR in browser: {}", e); - } - deployment - .track_if_analytics_allowed( - "github_pr_created", - serde_json::json!({ - "task_id": task.id.to_string(), - "project_id": project.id.to_string(), - "attempt_id": task_attempt.id.to_string(), - }), - ) - .await; - - Ok(ResponseJson(ApiResponse::success(pr_info.url))) - } - Err(e) => { - tracing::error!( - "Failed to create GitHub PR for attempt {}: {}", - task_attempt.id, - e - ); - match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled), - )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), - } - } - } -} - #[derive(serde::Deserialize, TS)] pub struct OpenEditorRequest { editor_type: Option, @@ -1431,185 +1251,6 @@ pub async fn stop_task_attempt_execution( Ok(ResponseJson(ApiResponse::success(()))) } -#[derive(Debug, Serialize, TS)] -pub struct AttachPrResponse { - pub pr_attached: bool, - pub pr_url: Option, - pub pr_number: Option, - pub pr_status: Option, -} - -#[derive(Debug, Serialize, TS)] -pub struct PrCommentsResponse { - pub comments: Vec, -} - -#[derive(Debug, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type", rename_all = "snake_case")] -pub enum GetPrCommentsError { - NoPrAttached, - GithubCliNotInstalled, - GithubCliNotLoggedIn, -} - -pub async fn attach_existing_pr( - Extension(task_attempt): Extension, - State(deployment): State, -) -> Result>, ApiError> { - let pool = &deployment.db().pool; - - // Check if PR already attached - if let Some(Merge::Pr(pr_merge)) = - Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await? - { - return Ok(ResponseJson(ApiResponse::success(AttachPrResponse { - pr_attached: true, - pr_url: Some(pr_merge.pr_info.url.clone()), - pr_number: Some(pr_merge.pr_info.number), - pr_status: Some(pr_merge.pr_info.status.clone()), - }))); - } - - // Get project and repo info - let Some(task) = task_attempt.parent_task(pool).await? else { - return Err(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound)); - }; - let Some(project) = Project::find_by_id(pool, task.project_id).await? else { - return Err(ApiError::Project(ProjectError::ProjectNotFound)); - }; - - let github_service = GitHubService::new()?; - let repo_info = deployment - .git() - .get_github_repo_info(&project.git_repo_path)?; - - // List all PRs for branch (open, closed, and merged) - let prs = github_service - .list_all_prs_for_branch(&repo_info, &task_attempt.branch) - .await?; - - // Take the first PR (prefer open, but also accept merged/closed) - if let Some(pr_info) = prs.into_iter().next() { - // Save PR info to database - let merge = Merge::create_pr( - pool, - task_attempt.id, - &task_attempt.target_branch, - pr_info.number, - &pr_info.url, - ) - .await?; - - // Update status if not open - if !matches!(pr_info.status, MergeStatus::Open) { - Merge::update_status( - pool, - merge.id, - pr_info.status.clone(), - pr_info.merge_commit_sha.clone(), - ) - .await?; - } - - // If PR is merged, mark task as done - if matches!(pr_info.status, MergeStatus::Merged) { - Task::update_status(pool, task.id, TaskStatus::Done).await?; - - // Try broadcast update to other users in organization - if let Ok(publisher) = deployment.share_publisher() { - if let Err(err) = publisher.update_shared_task_by_id(task.id).await { - tracing::warn!( - ?err, - "Failed to propagate shared task update for {}", - task.id - ); - } - } else { - tracing::debug!( - "Share publisher unavailable; skipping remote update for {}", - task.id - ); - } - } - - Ok(ResponseJson(ApiResponse::success(AttachPrResponse { - pr_attached: true, - pr_url: Some(pr_info.url), - pr_number: Some(pr_info.number), - pr_status: Some(pr_info.status), - }))) - } else { - Ok(ResponseJson(ApiResponse::success(AttachPrResponse { - pr_attached: false, - pr_url: None, - pr_number: None, - pr_status: None, - }))) - } -} - -pub async fn get_pr_comments( - Extension(task_attempt): Extension, - State(deployment): State, -) -> Result>, ApiError> { - let pool = &deployment.db().pool; - - // Find the latest merge for this task attempt - let merge = Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await?; - - // Ensure there's an attached PR - let pr_info = match merge { - Some(Merge::Pr(pr_merge)) => pr_merge.pr_info, - _ => { - return Ok(ResponseJson(ApiResponse::error_with_data( - GetPrCommentsError::NoPrAttached, - ))); - } - }; - - // Get project and repo info - let task = task_attempt - .parent_task(pool) - .await? - .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; - let project = Project::find_by_id(pool, task.project_id) - .await? - .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; - - let github_service = GitHubService::new()?; - let repo_info = deployment - .git() - .get_github_repo_info(&project.git_repo_path)?; - - // Fetch comments from GitHub - match github_service - .get_pr_comments(&repo_info, pr_info.number) - .await - { - Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse { - comments, - }))), - Err(e) => { - tracing::error!( - "Failed to fetch PR comments for attempt {}, PR #{}: {}", - task_attempt.id, - pr_info.number, - e - ); - match &e { - GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled), - )), - GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( - ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn), - )), - _ => Err(ApiError::GitHubService(e)), - } - } - } -} - #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] @@ -1814,9 +1455,9 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/push/force", post(force_push_task_attempt_branch)) .route("/rebase", post(rebase_task_attempt)) .route("/conflicts/abort", post(abort_conflicts_task_attempt)) - .route("/pr", post(create_github_pr)) - .route("/pr/attach", post(attach_existing_pr)) - .route("/pr/comments", get(get_pr_comments)) + .route("/pr", post(pr::create_github_pr)) + .route("/pr/attach", post(pr::attach_existing_pr)) + .route("/pr/comments", get(pr::get_pr_comments)) .route("/open-editor", post(open_task_attempt_in_editor)) .route("/children", get(get_task_attempt_children)) .route("/stop", post(stop_task_attempt_execution)) diff --git a/crates/server/src/routes/task_attempts/pr.rs b/crates/server/src/routes/task_attempts/pr.rs new file mode 100644 index 00000000..8494c25e --- /dev/null +++ b/crates/server/src/routes/task_attempts/pr.rs @@ -0,0 +1,479 @@ +use axum::{Extension, Json, extract::State, response::Json as ResponseJson}; +use db::models::{ + execution_process::{ExecutionProcess, ExecutionProcessRunReason}, + merge::{Merge, MergeStatus}, + project::{Project, ProjectError}, + task::{Task, TaskStatus}, + task_attempt::{TaskAttempt, TaskAttemptError}, +}; +use deployment::Deployment; +use executors::actions::{ + ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, + coding_agent_initial::CodingAgentInitialRequest, +}; +use git2::BranchType; +use serde::{Deserialize, Serialize}; +use services::services::{ + container::ContainerService, + git::{GitCliError, GitServiceError}, + github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment}, +}; +use ts_rs::TS; +use utils::response::ApiResponse; + +use super::util::ensure_worktree_path; +use crate::{DeploymentImpl, error::ApiError}; + +#[derive(Debug, Deserialize, Serialize, TS)] +pub struct CreateGitHubPrRequest { + pub title: String, + pub body: Option, + pub target_branch: Option, + pub draft: Option, + #[serde(default)] + pub auto_generate_description: bool, +} + +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +pub enum CreatePrError { + GithubCliNotInstalled, + GithubCliNotLoggedIn, + GitCliNotLoggedIn, + GitCliNotInstalled, + TargetBranchNotFound { branch: String }, +} + +#[derive(Debug, Serialize, TS)] +pub struct AttachPrResponse { + pub pr_attached: bool, + pub pr_url: Option, + pub pr_number: Option, + pub pr_status: Option, +} + +#[derive(Debug, Serialize, TS)] +pub struct PrCommentsResponse { + pub comments: Vec, +} + +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +pub enum GetPrCommentsError { + NoPrAttached, + GithubCliNotInstalled, + GithubCliNotLoggedIn, +} + +pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the GitHub PR that was just created with a better title and description. +The PR number is #{pr_number} and the URL is {pr_url}. + +Analyze the changes in this branch and write: +1. A concise, descriptive title that summarizes the changes, postfixed with "(Vibe Kanban)" +2. A detailed description that explains: + - What changes were made + - Why they were made (based on the task context) + - Any important implementation details + - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" + +Use `gh pr edit` to update the PR."#; + +async fn trigger_pr_description_follow_up( + deployment: &DeploymentImpl, + task_attempt: &TaskAttempt, + pr_number: i64, + pr_url: &str, +) -> Result<(), ApiError> { + // Get the custom prompt from config, or use default + let config = deployment.config().read().await; + let prompt_template = config + .pr_auto_description_prompt + .as_deref() + .unwrap_or(DEFAULT_PR_DESCRIPTION_PROMPT); + + // Replace placeholders in prompt + let prompt = prompt_template + .replace("{pr_number}", &pr_number.to_string()) + .replace("{pr_url}", pr_url); + + drop(config); // Release the lock before async operations + + // Get executor profile from the latest coding agent process + let executor_profile_id = ExecutionProcess::latest_executor_profile_for_attempt( + &deployment.db().pool, + task_attempt.id, + ) + .await?; + + // Get latest session ID if one exists + let latest_session_id = ExecutionProcess::find_latest_session_id_by_task_attempt( + &deployment.db().pool, + task_attempt.id, + ) + .await?; + + // Build the action type (follow-up if session exists, otherwise initial) + let action_type = if let Some(session_id) = latest_session_id { + ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { + prompt, + session_id, + executor_profile_id, + }) + } else { + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt, + executor_profile_id, + }) + }; + + let action = ExecutorAction::new(action_type, None); + + deployment + .container() + .start_execution( + task_attempt, + &action, + &ExecutionProcessRunReason::CodingAgent, + ) + .await?; + + Ok(()) +} + +pub async fn create_github_pr( + Extension(task_attempt): Extension, + State(deployment): State, + Json(request): Json, +) -> Result>, ApiError> { + let github_config = deployment.config().read().await.github.clone(); + // Get the task attempt to access the stored target branch + let target_branch = request.target_branch.unwrap_or_else(|| { + // Use the stored target branch from the task attempt as the default + // Fall back to config default or "main" only if stored target branch is somehow invalid + if !task_attempt.target_branch.trim().is_empty() { + task_attempt.target_branch.clone() + } else { + github_config + .default_pr_base + .as_ref() + .map_or_else(|| "main".to_string(), |b| b.to_string()) + } + }); + + let pool = &deployment.db().pool; + let task = task_attempt + .parent_task(pool) + .await? + .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; + let project = Project::find_by_id(pool, task.project_id) + .await? + .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; + + let workspace_path = ensure_worktree_path(&deployment, &task_attempt).await?; + + match deployment + .git() + .check_remote_branch_exists(&project.git_repo_path, &target_branch) + { + Ok(false) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::TargetBranchNotFound { + branch: target_branch.clone(), + }, + ))); + } + Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotLoggedIn, + ))); + } + Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotInstalled, + ))); + } + Err(e) => return Err(ApiError::GitService(e)), + Ok(true) => {} + } + + // Push the branch to GitHub first + if let Err(e) = deployment + .git() + .push_to_github(&workspace_path, &task_attempt.branch, false) + { + tracing::error!("Failed to push branch to GitHub: {}", e); + match e { + GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotLoggedIn, + ))); + } + GitServiceError::GitCLI(GitCliError::NotAvailable) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotInstalled, + ))); + } + _ => return Err(ApiError::GitService(e)), + } + } + + let norm_target_branch_name = if matches!( + deployment + .git() + .find_branch_type(&project.git_repo_path, &target_branch)?, + BranchType::Remote + ) { + // Remote branches are formatted as {remote}/{branch} locally. + // For PR APIs, we must provide just the branch name. + let remote = deployment + .git() + .get_remote_name_from_branch_name(&workspace_path, &target_branch)?; + let remote_prefix = format!("{}/", remote); + target_branch + .strip_prefix(&remote_prefix) + .unwrap_or(&target_branch) + .to_string() + } else { + target_branch + }; + // Create the PR using GitHub service + let pr_request = CreatePrRequest { + title: request.title.clone(), + body: request.body.clone(), + head_branch: task_attempt.branch.clone(), + base_branch: norm_target_branch_name.clone(), + draft: request.draft, + }; + // Use GitService to get the remote URL, then create GitHubRepoInfo + let repo_info = deployment + .git() + .get_github_repo_info(&project.git_repo_path)?; + + // Use GitHubService to create the PR + let github_service = GitHubService::new()?; + match github_service.create_pr(&repo_info, &pr_request).await { + Ok(pr_info) => { + // Update the task attempt with PR information + if let Err(e) = Merge::create_pr( + pool, + task_attempt.id, + &norm_target_branch_name, + pr_info.number, + &pr_info.url, + ) + .await + { + tracing::error!("Failed to update task attempt PR status: {}", e); + } + + // Auto-open PR in browser + if let Err(e) = utils::browser::open_browser(&pr_info.url).await { + tracing::warn!("Failed to open PR in browser: {}", e); + } + deployment + .track_if_analytics_allowed( + "github_pr_created", + serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": project.id.to_string(), + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + // Trigger auto-description follow-up if enabled + if request.auto_generate_description + && let Err(e) = trigger_pr_description_follow_up( + &deployment, + &task_attempt, + pr_info.number, + &pr_info.url, + ) + .await + { + tracing::warn!( + "Failed to trigger PR description follow-up for attempt {}: {}", + task_attempt.id, + e + ); + } + + Ok(ResponseJson(ApiResponse::success(pr_info.url))) + } + Err(e) => { + tracing::error!( + "Failed to create GitHub PR for attempt {}: {}", + task_attempt.id, + e + ); + match &e { + GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( + ApiResponse::error_with_data(CreatePrError::GithubCliNotInstalled), + )), + GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( + ApiResponse::error_with_data(CreatePrError::GithubCliNotLoggedIn), + )), + _ => Err(ApiError::GitHubService(e)), + } + } + } +} + +pub async fn attach_existing_pr( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + // Check if PR already attached + if let Some(Merge::Pr(pr_merge)) = + Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await? + { + return Ok(ResponseJson(ApiResponse::success(AttachPrResponse { + pr_attached: true, + pr_url: Some(pr_merge.pr_info.url.clone()), + pr_number: Some(pr_merge.pr_info.number), + pr_status: Some(pr_merge.pr_info.status.clone()), + }))); + } + + // Get project and repo info + let Some(task) = task_attempt.parent_task(pool).await? else { + return Err(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound)); + }; + let Some(project) = Project::find_by_id(pool, task.project_id).await? else { + return Err(ApiError::Project(ProjectError::ProjectNotFound)); + }; + + let github_service = GitHubService::new()?; + let repo_info = deployment + .git() + .get_github_repo_info(&project.git_repo_path)?; + + // List all PRs for branch (open, closed, and merged) + let prs = github_service + .list_all_prs_for_branch(&repo_info, &task_attempt.branch) + .await?; + + // Take the first PR (prefer open, but also accept merged/closed) + if let Some(pr_info) = prs.into_iter().next() { + // Save PR info to database + let merge = Merge::create_pr( + pool, + task_attempt.id, + &task_attempt.target_branch, + pr_info.number, + &pr_info.url, + ) + .await?; + + // Update status if not open + if !matches!(pr_info.status, MergeStatus::Open) { + Merge::update_status( + pool, + merge.id, + pr_info.status.clone(), + pr_info.merge_commit_sha.clone(), + ) + .await?; + } + + // If PR is merged, mark task as done + if matches!(pr_info.status, MergeStatus::Merged) { + Task::update_status(pool, task.id, TaskStatus::Done).await?; + + // Try broadcast update to other users in organization + if let Ok(publisher) = deployment.share_publisher() { + if let Err(err) = publisher.update_shared_task_by_id(task.id).await { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + task.id + ); + } + } else { + tracing::debug!( + "Share publisher unavailable; skipping remote update for {}", + task.id + ); + } + } + + Ok(ResponseJson(ApiResponse::success(AttachPrResponse { + pr_attached: true, + pr_url: Some(pr_info.url), + pr_number: Some(pr_info.number), + pr_status: Some(pr_info.status), + }))) + } else { + Ok(ResponseJson(ApiResponse::success(AttachPrResponse { + pr_attached: false, + pr_url: None, + pr_number: None, + pr_status: None, + }))) + } +} + +pub async fn get_pr_comments( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + // Find the latest merge for this task attempt + let merge = Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await?; + + // Ensure there's an attached PR + let pr_info = match merge { + Some(Merge::Pr(pr_merge)) => pr_merge.pr_info, + _ => { + return Ok(ResponseJson(ApiResponse::error_with_data( + GetPrCommentsError::NoPrAttached, + ))); + } + }; + + // Get project and repo info + let task = task_attempt + .parent_task(pool) + .await? + .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; + let project = Project::find_by_id(pool, task.project_id) + .await? + .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; + + let github_service = GitHubService::new()?; + let repo_info = deployment + .git() + .get_github_repo_info(&project.git_repo_path)?; + + // Fetch comments from GitHub + match github_service + .get_pr_comments(&repo_info, pr_info.number) + .await + { + Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse { + comments, + }))), + Err(e) => { + tracing::error!( + "Failed to fetch PR comments for attempt {}, PR #{}: {}", + task_attempt.id, + pr_info.number, + e + ); + match &e { + GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson( + ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled), + )), + GitHubServiceError::AuthFailed(_) => Ok(ResponseJson( + ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn), + )), + _ => Err(ApiError::GitHubService(e)), + } + } + } +} diff --git a/crates/services/src/services/config/versions/v8.rs b/crates/services/src/services/config/versions/v8.rs index 320f6820..74e9bc3d 100644 --- a/crates/services/src/services/config/versions/v8.rs +++ b/crates/services/src/services/config/versions/v8.rs @@ -13,6 +13,10 @@ fn default_git_branch_prefix() -> String { "vk".to_string() } +fn default_pr_auto_description_enabled() -> bool { + true +} + #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, @@ -33,6 +37,10 @@ pub struct Config { pub git_branch_prefix: String, #[serde(default)] pub showcases: ShowcaseState, + #[serde(default = "default_pr_auto_description_enabled")] + pub pr_auto_description_enabled: bool, + #[serde(default)] + pub pr_auto_description_prompt: Option, } impl Config { @@ -56,6 +64,8 @@ impl Config { language: old_config.language, git_branch_prefix: old_config.git_branch_prefix, showcases: old_config.showcases, + pr_auto_description_enabled: true, + pr_auto_description_prompt: None, } } @@ -104,6 +114,8 @@ impl Default for Config { language: UiLanguage::default(), git_branch_prefix: default_git_branch_prefix(), showcases: ShowcaseState::default(), + pr_auto_description_enabled: true, + pr_auto_description_prompt: None, } } } diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index 8a30d95a..cfa866ee 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -46,7 +46,7 @@ const CreatePRDialogImpl = NiceModal.create( const modal = useModal(); const { t } = useTranslation('tasks'); const { isLoaded } = useAuth(); - const { environment } = useUserSystem(); + const { environment, config } = useUserSystem(); const [prTitle, setPrTitle] = useState(''); const [prBody, setPrBody] = useState(''); const [prBaseBranch, setPrBaseBranch] = useState(''); @@ -58,6 +58,9 @@ const CreatePRDialogImpl = NiceModal.create( const [branches, setBranches] = useState([]); const [branchesLoading, setBranchesLoading] = useState(false); const [isDraft, setIsDraft] = useState(false); + const [autoGenerateDescription, setAutoGenerateDescription] = useState( + config?.pr_auto_description_enabled ?? false + ); const getGhCliHelpTitle = (variant: GhCliSupportVariant) => variant === 'homebrew' @@ -139,6 +142,7 @@ const CreatePRDialogImpl = NiceModal.create( body: prBody || null, target_branch: prBaseBranch || null, draft: isDraft, + auto_generate_description: autoGenerateDescription, }); if (result.success) { @@ -146,6 +150,9 @@ const CreatePRDialogImpl = NiceModal.create( setPrBody(''); setPrBaseBranch(''); setIsDraft(false); + setAutoGenerateDescription( + config?.pr_auto_description_enabled ?? false + ); setCreatingPR(false); modal.hide(); return; @@ -218,6 +225,8 @@ const CreatePRDialogImpl = NiceModal.create( prBody, prTitle, isDraft, + autoGenerateDescription, + config?.pr_auto_description_enabled, modal, isMacEnvironment, t, @@ -230,7 +239,8 @@ const CreatePRDialogImpl = NiceModal.create( setPrBody(''); setPrBaseBranch(''); setIsDraft(false); - }, [modal]); + setAutoGenerateDescription(config?.pr_auto_description_enabled ?? false); + }, [modal, config?.pr_auto_description_enabled]); return ( <> @@ -251,6 +261,20 @@ const CreatePRDialogImpl = NiceModal.create( ) : (
+
+ + +
@@ -272,6 +302,12 @@ const CreatePRDialogImpl = NiceModal.create( onChange={(e) => setPrBody(e.target.value)} placeholder={t('createPrDialog.descriptionPlaceholder')} rows={4} + disabled={autoGenerateDescription} + className={ + autoGenerateDescription + ? 'opacity-50 cursor-not-allowed' + : '' + } />
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index f96cf5aa..f3d62f73 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -135,6 +135,18 @@ } } }, + "pullRequests": { + "title": "Pull Requests", + "description": "Configure PR creation behavior", + "autoDescription": { + "label": "Auto-generate PR description by default", + "helper": "When enabled, the AI agent will automatically update the PR title and description after creation." + }, + "customPrompt": { + "useCustom": "Use custom prompt", + "helper": "Custom prompt for the AI agent when generating PR descriptions. Use {pr_number} and {pr_url} as placeholders." + } + }, "notifications": { "title": "Notifications", "description": "Control when and how you receive notifications.", diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index c3b0d363..f3446409 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -356,6 +356,7 @@ "loadingBranches": "Loading branches...", "selectBaseBranch": "Select base branch", "draftLabel": "Create as draft", + "autoGenerateLabel": "Auto-generate PR description with AI", "creating": "Creating...", "createButton": "Create PR", "errors": { diff --git a/frontend/src/i18n/locales/es/settings.json b/frontend/src/i18n/locales/es/settings.json index f5b71c8d..930e769a 100644 --- a/frontend/src/i18n/locales/es/settings.json +++ b/frontend/src/i18n/locales/es/settings.json @@ -135,6 +135,18 @@ } } }, + "pullRequests": { + "title": "Pull Requests", + "description": "Configura el comportamiento de creación de PR", + "autoDescription": { + "label": "Auto-generar descripción de PR por defecto", + "helper": "Cuando está habilitado, el agente de IA actualizará automáticamente el título y la descripción del PR después de la creación." + }, + "customPrompt": { + "useCustom": "Usar prompt personalizado", + "helper": "Prompt personalizado para el agente de IA al generar descripciones de PR. Usa {pr_number} y {pr_url} como marcadores de posición." + } + }, "notifications": { "title": "Notificaciones", "description": "Controla cuándo y cómo recibes notificaciones.", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index a624a0c3..c46dd8cb 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -113,6 +113,7 @@ "loadingBranches": "Cargando ramas...", "selectBaseBranch": "Seleccionar rama base", "draftLabel": "Crear como borrador", + "autoGenerateLabel": "Pedir al agente de IA que genere una mejor descripción del PR", "creating": "Creando...", "createButton": "Crear PR", "errors": { diff --git a/frontend/src/i18n/locales/ja/settings.json b/frontend/src/i18n/locales/ja/settings.json index abbd07be..8fc8b674 100644 --- a/frontend/src/i18n/locales/ja/settings.json +++ b/frontend/src/i18n/locales/ja/settings.json @@ -135,6 +135,18 @@ } } }, + "pullRequests": { + "title": "プルリクエスト", + "description": "PR作成の動作を設定", + "autoDescription": { + "label": "デフォルトでPR説明を自動生成", + "helper": "有効にすると、AIエージェントがPR作成後に自動的にタイトルと説明を更新します。" + }, + "customPrompt": { + "useCustom": "カスタムプロンプトを使用", + "helper": "PR説明生成時のAIエージェント用カスタムプロンプト。{pr_number}と{pr_url}をプレースホルダーとして使用できます。" + } + }, "notifications": { "title": "通知", "description": "通知を受け取るタイミングと方法を制御します。", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index d997e27a..43b0c733 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -113,6 +113,7 @@ "loadingBranches": "ブランチを読み込み中...", "selectBaseBranch": "ベースブランチを選択", "draftLabel": "下書きとして作成", + "autoGenerateLabel": "AIエージェントにより良いPR説明を生成させる", "creating": "作成中...", "createButton": "PRを作成", "errors": { diff --git a/frontend/src/i18n/locales/ko/settings.json b/frontend/src/i18n/locales/ko/settings.json index 71577f9b..224e196f 100644 --- a/frontend/src/i18n/locales/ko/settings.json +++ b/frontend/src/i18n/locales/ko/settings.json @@ -135,6 +135,18 @@ } } }, + "pullRequests": { + "title": "풀 리퀘스트", + "description": "PR 생성 동작 구성", + "autoDescription": { + "label": "기본적으로 PR 설명 자동 생성", + "helper": "활성화하면 AI 에이전트가 PR 생성 후 자동으로 제목과 설명을 업데이트합니다." + }, + "customPrompt": { + "useCustom": "사용자 정의 프롬프트 사용", + "helper": "PR 설명 생성 시 AI 에이전트용 사용자 정의 프롬프트. {pr_number}와 {pr_url}을 플레이스홀더로 사용하세요." + } + }, "notifications": { "title": "알림", "description": "알림을 받는 시기와 방법을 제어하세요.", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index b2887974..ee33266b 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -113,6 +113,7 @@ "loadingBranches": "브랜치 로딩 중...", "selectBaseBranch": "기본 브랜치 선택", "draftLabel": "초안으로 만들기", + "autoGenerateLabel": "AI 에이전트에게 더 나은 PR 설명 생성 요청", "creating": "생성 중...", "createButton": "PR 생성", "errors": { diff --git a/frontend/src/i18n/locales/zh-Hans/settings.json b/frontend/src/i18n/locales/zh-Hans/settings.json index 2818c06b..45f0613e 100644 --- a/frontend/src/i18n/locales/zh-Hans/settings.json +++ b/frontend/src/i18n/locales/zh-Hans/settings.json @@ -135,6 +135,18 @@ } } }, + "pullRequests": { + "title": "拉取请求", + "description": "配置PR创建行为", + "autoDescription": { + "label": "默认自动生成PR描述", + "helper": "启用后,AI代理将在创建PR后自动更新标题和描述。" + }, + "customPrompt": { + "useCustom": "使用自定义提示", + "helper": "生成PR描述时AI代理使用的自定义提示。使用{pr_number}和{pr_url}作为占位符。" + } + }, "notifications": { "title": "通知", "description": "控制何时以及如何接收通知。", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index 561ab891..3e8dbcd6 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -356,6 +356,7 @@ "loadingBranches": "加载分支中...", "selectBaseBranch": "选择基础分支", "draftLabel": "创建为草稿", + "autoGenerateLabel": "请求AI代理生成更好的PR描述", "creating": "创建中...", "createButton": "创建 PR", "errors": { diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 639d0311..ce563293 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -21,7 +21,13 @@ import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Checkbox } from '@/components/ui/checkbox'; import { Loader2, Volume2 } from 'lucide-react'; -import { EditorType, SoundFile, ThemeMode, UiLanguage } from 'shared/types'; +import { + DEFAULT_PR_DESCRIPTION_PROMPT, + EditorType, + SoundFile, + ThemeMode, + UiLanguage, +} from 'shared/types'; import { getLanguageOptions } from '@/i18n/languages'; import { toPrettyCase } from '@/utils/string'; @@ -458,6 +464,75 @@ export function GeneralSettings() { + + + {t('settings.general.pullRequests.title')} + + {t('settings.general.pullRequests.description')} + + + +
+ + updateDraft({ pr_auto_description_enabled: checked }) + } + /> +
+ +

+ {t('settings.general.pullRequests.autoDescription.helper')} +

+
+
+
+ { + if (checked) { + updateDraft({ + pr_auto_description_prompt: DEFAULT_PR_DESCRIPTION_PROMPT, + }); + } else { + updateDraft({ pr_auto_description_prompt: null }); + } + }} + /> + +
+
+