From 6a8d7d8a194712a3a006b23cf700f94d7ef3a249 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 1 Jul 2025 16:28:15 +0100 Subject: [PATCH] Create PR from comparison screen (#41) * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes * Prettier * Cargo fmt * Clippy --- backend/Cargo.toml | 1 + backend/src/bin/generate_types.rs | 1 + backend/src/models/config.rs | 18 ++ backend/src/models/task_attempt.rs | 220 +++++++++++++++++++- backend/src/routes/task_attempts.rs | 93 ++++++++- frontend/src/pages/Settings.tsx | 67 +++++- frontend/src/pages/task-attempt-compare.tsx | 208 ++++++++++++++++-- shared/types.ts | 4 +- 8 files changed, 592 insertions(+), 20 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c88e94d1..0804ca4e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -41,6 +41,7 @@ rmcp = { version = "0.1.5", features = ["server", "transport-io"] } schemars = "0.8" regex = "1.11.1" notify-rust = "4.11" +octocrab = "0.44" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 0bf7f6b7..5639fc4f 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -76,6 +76,7 @@ fn main() { vibe_kanban::models::config::Config::decl(), vibe_kanban::models::config::ThemeMode::decl(), vibe_kanban::models::config::EditorConfig::decl(), + vibe_kanban::models::config::GitHubConfig::decl(), vibe_kanban::models::config::EditorType::decl(), vibe_kanban::models::config::EditorConstants::decl(), vibe_kanban::models::config::SoundFile::decl(), diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index e3076983..820eeab9 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -16,6 +16,7 @@ pub struct Config { pub sound_file: SoundFile, pub push_notifications: bool, pub editor: EditorConfig, + pub github: GitHubConfig, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -39,6 +40,13 @@ pub struct EditorConfig { pub custom_command: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct GitHubConfig { + pub token: Option, + pub default_pr_base: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] #[serde(rename_all = "lowercase")] @@ -150,6 +158,7 @@ impl Default for Config { sound_file: SoundFile::AbstractSound4, push_notifications: true, editor: EditorConfig::default(), + github: GitHubConfig::default(), } } } @@ -163,6 +172,15 @@ impl Default for EditorConfig { } } +impl Default for GitHubConfig { + fn default() -> Self { + Self { + token: None, + default_pr_base: Some("main".to_string()), + } + } +} + impl EditorConfig { pub fn get_command(&self) -> Vec { match &self.editor_type { diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 853d7eff..25651254 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -87,6 +87,17 @@ pub struct UpdateTaskAttempt { // Currently no updateable fields, but keeping struct for API compatibility } +/// GitHub PR creation parameters +pub struct CreatePrParams<'a> { + pub attempt_id: Uuid, + pub task_id: Uuid, + pub project_id: Uuid, + pub github_token: &'a str, + pub title: &'a str, + pub body: Option<&'a str>, + pub base_branch: Option<&'a str>, +} + #[derive(Debug, Deserialize, TS)] #[ts(export)] pub struct CreateFollowUpAttempt { @@ -403,9 +414,9 @@ impl TaskAttempt { FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, - attempt_id, - task_id, - project_id + attempt_id, + task_id, + project_id ) .fetch_optional(pool) .await? @@ -1523,4 +1534,207 @@ impl TaskAttempt { Ok(commit_id.to_string()) } + + /// Create a GitHub PR for this task attempt + pub async fn create_github_pr( + pool: &SqlitePool, + params: CreatePrParams<'_>, + ) -> Result { + // Get the task attempt with validation + let attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, + params.attempt_id, + params.task_id, + params.project_id + ) + .fetch_optional(pool) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Get the project to access the repository path + let project = Project::find_by_id(pool, params.project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + // Extract GitHub repository information from the project path + let (owner, repo_name) = Self::extract_github_repo_info(&project.git_repo_path)?; + + // Push the branch to GitHub first + Self::push_branch_to_github(&attempt.worktree_path, &attempt.branch, params.github_token)?; + + // Create the PR using Octocrab + Self::create_pr_with_octocrab( + params.github_token, + &owner, + &repo_name, + &attempt.branch, + params.base_branch.unwrap_or("main"), + params.title, + params.body, + ) + .await + } + + /// Extract GitHub owner and repo name from git repo path + fn extract_github_repo_info(git_repo_path: &str) -> Result<(String, String), TaskAttemptError> { + // Try to extract from remote origin URL + let repo = Repository::open(git_repo_path)?; + let remote = repo.find_remote("origin").map_err(|_| { + TaskAttemptError::ValidationError("No 'origin' remote found".to_string()) + })?; + + let url = remote.url().ok_or_else(|| { + TaskAttemptError::ValidationError("Remote origin has no URL".to_string()) + })?; + + // Parse GitHub URL (supports both HTTPS and SSH formats) + let github_regex = regex::Regex::new(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?/?$") + .map_err(|e| TaskAttemptError::ValidationError(format!("Regex error: {}", e)))?; + + if let Some(captures) = github_regex.captures(url) { + let owner = captures.get(1).unwrap().as_str().to_string(); + let repo_name = captures.get(2).unwrap().as_str().to_string(); + Ok((owner, repo_name)) + } else { + Err(TaskAttemptError::ValidationError(format!( + "Not a GitHub repository: {}", + url + ))) + } + } + + /// Push the branch to GitHub remote + fn push_branch_to_github( + worktree_path: &str, + branch_name: &str, + github_token: &str, + ) -> Result<(), TaskAttemptError> { + let repo = Repository::open(worktree_path)?; + + // Get the remote + let remote = repo.find_remote("origin")?; + let remote_url = remote.url().ok_or_else(|| { + TaskAttemptError::ValidationError("Remote origin has no URL".to_string()) + })?; + + // Convert SSH URL to HTTPS URL if necessary + let https_url = if remote_url.starts_with("git@github.com:") { + // Convert git@github.com:owner/repo.git to https://github.com/owner/repo.git + remote_url.replace("git@github.com:", "https://github.com/") + } else if remote_url.starts_with("ssh://git@github.com/") { + // Convert ssh://git@github.com/owner/repo.git to https://github.com/owner/repo.git + remote_url.replace("ssh://git@github.com/", "https://github.com/") + } else { + remote_url.to_string() + }; + + // Create a temporary remote with HTTPS URL for pushing + let temp_remote_name = "temp_https_origin"; + + // Remove any existing temp remote + let _ = repo.remote_delete(temp_remote_name); + + // Create temporary HTTPS remote + let mut temp_remote = repo.remote(temp_remote_name, &https_url)?; + + // Create refspec for pushing the branch + let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); + + // Set up authentication callback using the GitHub token + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + git2::Cred::userpass_plaintext(username_from_url.unwrap_or("git"), github_token) + }); + + // Configure push options + let mut push_options = git2::PushOptions::new(); + push_options.remote_callbacks(callbacks); + + // Push the branch + let push_result = temp_remote.push(&[&refspec], Some(&mut push_options)); + + // Clean up the temporary remote + let _ = repo.remote_delete(temp_remote_name); + + // Check push result + push_result.map_err(TaskAttemptError::Git)?; + + info!("Pushed branch {} to GitHub using HTTPS", branch_name); + Ok(()) + } + + /// Create a PR using Octocrab + async fn create_pr_with_octocrab( + github_token: &str, + owner: &str, + repo_name: &str, + head_branch: &str, + base_branch: &str, + title: &str, + body: Option<&str>, + ) -> Result { + let octocrab = octocrab::OctocrabBuilder::new() + .personal_token(github_token.to_string()) + .build() + .map_err(|e| { + TaskAttemptError::ValidationError(format!("Failed to create GitHub client: {}", e)) + })?; + + // Verify repository access + octocrab.repos(owner, repo_name).get().await.map_err(|e| { + TaskAttemptError::ValidationError(format!( + "Cannot access repository {}/{}: {}", + owner, repo_name, e + )) + })?; + + // Check if the base branch exists + octocrab + .repos(owner, repo_name) + .get_ref(&octocrab::params::repos::Reference::Branch( + base_branch.to_string(), + )) + .await + .map_err(|e| { + TaskAttemptError::ValidationError(format!( + "Base branch '{}' does not exist: {}", + base_branch, e + )) + })?; + + // Check if the head branch exists + octocrab.repos(owner, repo_name) + .get_ref(&octocrab::params::repos::Reference::Branch(head_branch.to_string())).await + .map_err(|e| TaskAttemptError::ValidationError(format!("Head branch '{}' does not exist. Make sure the branch was pushed successfully: {}", head_branch, e)))?; + + let pr = octocrab + .pulls(owner, repo_name) + .create(title, head_branch, base_branch) + .body(body.unwrap_or("")) + .send() + .await + .map_err(|e| match e { + octocrab::Error::GitHub { source, .. } => { + TaskAttemptError::ValidationError(format!( + "GitHub API error: {} (status: {})", + source.message, + source.status_code.as_u16() + )) + } + _ => TaskAttemptError::ValidationError(format!("Failed to create PR: {}", e)), + })?; + + info!( + "Created GitHub PR #{} for branch {}", + pr.number, head_branch + ); + Ok(pr + .html_url + .map(|url| url.to_string()) + .unwrap_or_else(|| "".to_string())) + } } diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 61aff8d1..e7f45e3e 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -13,11 +13,12 @@ use tokio::sync::RwLock; use uuid::Uuid; use crate::models::{ + config::Config, execution_process::{ExecutionProcess, ExecutionProcessSummary}, task::Task, task_attempt::{ - BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, - WorktreeDiff, + BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt, + TaskAttemptStatus, WorktreeDiff, }, task_attempt_activity::{ CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt, @@ -30,6 +31,13 @@ pub struct RebaseTaskAttemptRequest { pub new_base_branch: Option, } +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateGitHubPRRequest { + pub title: String, + pub body: Option, + pub base_branch: Option, +} + pub async fn get_task_attempts( Path((project_id, task_id)): Path<(Uuid, Uuid)>, Extension(pool): Extension, @@ -257,6 +265,83 @@ pub async fn merge_task_attempt( } } +pub async fn create_github_pr( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, + Json(request): Json, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + + // Load the user's GitHub configuration + let config = match Config::load(&crate::utils::config_path()) { + Ok(config) => config, + Err(e) => { + tracing::error!("Failed to load config: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let github_token = match config.github.token { + Some(token) => token, + None => { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some( + "GitHub token not configured. Please set your GitHub token in settings." + .to_string(), + ), + })); + } + }; + + let base_branch = request + .base_branch + .or(config.github.default_pr_base) + .unwrap_or_else(|| "main".to_string()); + + match TaskAttempt::create_github_pr( + &pool, + CreatePrParams { + attempt_id, + task_id, + project_id, + github_token: &github_token, + title: &request.title, + body: request.body.as_deref(), + base_branch: Some(&base_branch), + }, + ) + .await + { + Ok(pr_url) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(pr_url), + message: Some("GitHub PR created successfully".to_string()), + })), + Err(e) => { + tracing::error!( + "Failed to create GitHub PR for attempt {}: {}", + attempt_id, + e + ); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to create PR: {}", e)), + })) + } + } +} + #[derive(serde::Deserialize)] pub struct OpenEditorRequest { editor_type: Option, @@ -900,6 +985,10 @@ pub fn task_attempts_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/delete-file", post(delete_task_attempt_file), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/create-pr", + post(create_github_pr), + ) .route( "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/execution-processes", get(get_task_attempt_execution_processes), diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 883c37ea..c48578b0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -18,7 +18,7 @@ import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { Loader2, Volume2 } from 'lucide-react'; +import { Loader2, Volume2, Key } from 'lucide-react'; import type { ThemeMode, EditorType, SoundFile } from 'shared/types'; import { EXECUTOR_TYPES, @@ -271,6 +271,71 @@ export function Settings() { + + + + + GitHub Integration + + + Configure GitHub settings for creating pull requests from task + attempts. + + + +
+ + + updateConfig({ + github: { + ...config.github, + token: e.target.value || null, + }, + }) + } + /> +

+ GitHub Personal Access Token with 'repo' permissions. Required + for creating pull requests.{' '} + + Create token here + +

+
+ +
+ + + updateConfig({ + github: { + ...config.github, + default_pr_base: e.target.value || null, + }, + }) + } + /> +

+ Default base branch for pull requests. Defaults to 'main' if + not specified. +

+
+
+
+ Notifications diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index 24b8c563..fc9693c5 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -10,6 +10,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; import { ArrowLeft, FileText, @@ -20,6 +23,7 @@ import { Trash2, Eye, EyeOff, + GitPullRequest, } from 'lucide-react'; import { makeRequest } from '@/lib/api'; import type { @@ -58,8 +62,38 @@ export function TaskAttemptComparePage() { const [deletingFiles, setDeletingFiles] = useState>(new Set()); const [fileToDelete, setFileToDelete] = useState(null); const [showUncommittedWarning, setShowUncommittedWarning] = useState(false); + const [creatingPR, setCreatingPR] = useState(false); + const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const [prBaseBranch, setPrBaseBranch] = useState('main'); + const [taskDetails, setTaskDetails] = useState<{ + title: string; + description: string | null; + } | null>(null); // Define callbacks first + const fetchTaskDetails = useCallback(async () => { + if (!projectId || !taskId) return; + + try { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}` + ); + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setTaskDetails({ + title: result.data.title, + description: result.data.description, + }); + } + } + } catch (err) { + // Silently fail - not critical for the main functionality + } + }, [projectId, taskId]); + const fetchDiff = useCallback(async () => { if (!projectId || !taskId || !attemptId) return; @@ -114,10 +148,18 @@ export function TaskAttemptComparePage() { useEffect(() => { if (projectId && taskId && attemptId) { + fetchTaskDetails(); fetchDiff(); fetchBranchStatus(); } - }, [projectId, taskId, attemptId, fetchDiff, fetchBranchStatus]); + }, [ + projectId, + taskId, + attemptId, + fetchTaskDetails, + fetchDiff, + fetchBranchStatus, + ]); const handleBackClick = () => { navigate(`/projects/${projectId}/tasks/${taskId}`); @@ -207,6 +249,73 @@ export function TaskAttemptComparePage() { } }; + const handleCreatePRClick = async () => { + if (!projectId || !taskId || !attemptId) return; + + // Auto-fill with task details if available + if (taskDetails) { + setPrTitle(`${taskDetails.title} (vibe-kanban)`); + setPrBody(taskDetails.description || ''); + } else { + // Fallback if task details aren't available + setPrTitle('Task completion (vibe-kanban)'); + setPrBody(''); + } + + setShowCreatePRDialog(true); + }; + + const handleConfirmCreatePR = async () => { + if (!projectId || !taskId || !attemptId) return; + + try { + setCreatingPR(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/create-pr`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: prTitle, + body: prBody || null, + base_branch: prBaseBranch || null, + }), + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + // Open the PR URL in a new tab + window.open(result.data, '_blank'); + setShowCreatePRDialog(false); + // Reset form + setPrTitle(''); + setPrBody(''); + setPrBaseBranch('main'); + } else { + setError(result.message || 'Failed to create GitHub PR'); + } + } else { + setError('Failed to create GitHub PR'); + } + } catch (err) { + setError('Failed to create GitHub PR'); + } finally { + setCreatingPR(false); + } + }; + + const handleCancelCreatePR = () => { + setShowCreatePRDialog(false); + // Reset form to empty state - will be auto-filled again when reopened + setPrTitle(''); + setPrBody(''); + setPrBaseBranch('main'); + }; + const getChunkClassName = (chunkType: DiffChunkType) => { const baseClass = 'font-mono text-sm whitespace-pre py-1 flex'; @@ -547,18 +656,34 @@ export function TaskAttemptComparePage() { )} {!branchStatus?.merged && ( - + <> + + + )} @@ -788,6 +913,63 @@ export function TaskAttemptComparePage() { + + {/* Create PR Dialog */} + handleCancelCreatePR()} + > + + + Create GitHub Pull Request + + Create a pull request for this task attempt on GitHub. + + +
+
+ + setPrTitle(e.target.value)} + placeholder="Enter PR title" + /> +
+
+ +