From 41ef9bd262c9de22536f4a0b733c32a7998f5eec Mon Sep 17 00:00:00 2001 From: Ghvst Date: Sun, 12 Apr 2026 16:51:59 +0100 Subject: [PATCH 1/4] feat(core,ui,tauri): worktree engine migration + PR import --- src-tauri/src/engine/git.rs | 99 +++++++- src-tauri/src/engine/mod.rs | 1 + src-tauri/src/engine/worker.rs | 94 +------- src-tauri/src/engine/worktree.rs | 170 ++++++++++++++ src-tauri/src/engine_commands.rs | 114 ++++++++- src-tauri/src/lib.rs | 4 + src-tauri/src/migrations.rs | 9 + src/core/api/useImportPr.ts | 60 +++++ src/core/api/useTasks.ts | 24 ++ src/core/db/tasks.ts | 64 ++++++ src/core/services/github.ts | 104 +++++++++ src/core/services/pr-import.ts | 217 ++++++++++++++++++ src/core/services/pr-lifecycle.ts | 64 +++++- src/core/types/task.ts | 3 +- src/ui/components/main/EmptyState.tsx | 6 +- .../components/onboarding/AddProjectStep.tsx | 11 + src/ui/components/sidebar/Sidebar.tsx | 38 ++- src/ui/lib/toast.tsx | 42 ++++ 18 files changed, 1014 insertions(+), 110 deletions(-) create mode 100644 src-tauri/src/engine/worktree.rs create mode 100644 src/core/api/useImportPr.ts create mode 100644 src/core/services/pr-import.ts diff --git a/src-tauri/src/engine/git.rs b/src-tauri/src/engine/git.rs index 3ffd224..68e64dd 100644 --- a/src-tauri/src/engine/git.rs +++ b/src-tauri/src/engine/git.rs @@ -8,7 +8,7 @@ pub struct GitResult { pub error: Option, } -fn run_git(cwd: &str, args: &[&str]) -> GitResult { +pub(crate) fn run_git(cwd: &str, args: &[&str]) -> GitResult { match Command::new("git").args(args).current_dir(cwd).output() { Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -257,6 +257,103 @@ pub fn detect_default_branch(cwd: &str) -> String { "main".to_string() } +/// Clone a repository. Runs in the parent directory of `destination`. +/// Uses GIT_TERMINAL_PROMPT=0 to prevent interactive credential prompts +/// from hanging the process. Skips LFS to avoid downloading large +/// binary files that aren't needed for code review. +pub fn clone_repo(url: &str, destination: &str) -> GitResult { + let dest_path = std::path::Path::new(destination); + + // Create parent directory if needed + if let Some(parent) = dest_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + match Command::new("git") + .args(["clone", url, destination]) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_LFS_SKIP_SMUDGE", "1") + .output() + { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + GitResult { + success: true, + output: destination.to_string(), + error: None, + } + } else { + GitResult { + success: false, + output: stdout, + error: Some(stderr), + } + } + } + Err(e) => GitResult { + success: false, + output: String::new(), + error: Some(format!("Failed to execute git clone: {}", e)), + }, + } +} + +/// Fetch a specific branch from origin and create a local branch. +/// Tries the branch name first, then falls back to `pull//head` +/// for fork-based PRs where the branch doesn't exist on the upstream remote. +pub fn fetch_branch(cwd: &str, branch_name: &str) -> GitResult { + fetch_branch_with_pr(cwd, branch_name, None) +} + +/// Fetch a branch, with an optional PR number for fork-based PR fallback. +pub fn fetch_branch_with_pr(cwd: &str, branch_name: &str, pr_number: Option) -> GitResult { + // Try fetching by branch name first (works for same-repo PRs) + let refspec = format!( + "refs/heads/{}:refs/remotes/origin/{}", + branch_name, branch_name + ); + let fetch = run_git(cwd, &["fetch", "origin", &refspec]); + + if !fetch.success { + if let Some(pr_num) = pr_number { + // Fallback: fetch via GitHub's PR ref (works for fork-based PRs) + println!( + "[git] branch {} not found on origin, trying pull/{}/head", + branch_name, pr_num + ); + let pr_refspec = format!( + "refs/pull/{}/head:refs/remotes/origin/{}", + pr_num, branch_name + ); + let pr_fetch = run_git(cwd, &["fetch", "origin", &pr_refspec]); + if !pr_fetch.success { + return pr_fetch; + } + } else { + return fetch; + } + } + + // Delete stale local branch if it exists (may point to wrong commit) + let _ = run_git(cwd, &["branch", "-D", branch_name]); + // Create local branch from the fetched ref + run_git( + cwd, + &[ + "branch", + branch_name, + &format!("origin/{}", branch_name), + ], + ) +} + +/// Get the remote URL for origin. +pub fn get_remote_url(cwd: &str) -> GitResult { + run_git(cwd, &["remote", "get-url", "origin"]) +} + /// Generate a branch name for a task. pub fn task_branch_name(task_id: &str) -> String { // Use first 8 chars of UUID for readability diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index 699a07f..cdbe971 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -5,6 +5,7 @@ pub mod prioritizer; pub mod scanner; pub mod scheduler; pub mod worker; +pub mod worktree; use serde::{Deserialize, Serialize}; use std::collections::HashSet; diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index 88e058e..3f0b7ae 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -79,87 +79,8 @@ pub async fn execute_task( ) -> WorkResult { println!("[worker] execute_task START — task_id={task_id}, title={task_title}, base_branch={base_branch}, branch={branch_name}, files={files_involved:?}, has_user_messages={}, resume_session={:?}", user_messages.is_some(), resume_session_id); - // Save original branch so we can return to it - let original_branch = git::current_branch(repo_path); - if !original_branch.success { - return WorkResult { - success: false, - phase_reached: TaskPhase::Planning, - branch_name: None, - commit_sha: None, - files_modified: vec![], - summary: None, - error: Some(format!( - "Could not determine current branch: {}", - original_branch.error.unwrap_or_else(|| "unknown error".to_string()) - )), - review_warnings: None, - session_id: None, - }; - } - let original_branch_name = original_branch.output.clone(); - - // Auto-stash if working tree is dirty (safety net for queue transitions) - if !git::is_clean(repo_path) { - println!("[worker] dirty working tree — auto-stashing"); - let stash = git::stash(repo_path); - if !stash.success { - return WorkResult { - success: false, - phase_reached: TaskPhase::Planning, - branch_name: None, - commit_sha: None, - files_modified: vec![], - summary: None, - error: Some( - "Working tree is dirty and auto-stash failed. Commit or stash changes manually.".to_string(), - ), - review_warnings: None, - session_id: None, - }; - } - } - - // Create task branch from the specified base branch - if git::branch_exists(repo_path, branch_name) { - // Branch already exists — checkout it - let checkout = git::checkout_branch(repo_path, branch_name); - if !checkout.success { - return WorkResult { - success: false, - phase_reached: TaskPhase::Planning, - branch_name: Some(branch_name.to_string()), - commit_sha: None, - files_modified: vec![], - summary: None, - error: Some(format!( - "Failed to checkout branch: {}", - checkout.error.unwrap_or_else(|| "unknown error".to_string()) - )), - review_warnings: None, - session_id: None, - }; - } - } else { - let create = git::create_branch_from(repo_path, branch_name, base_branch); - if !create.success { - return WorkResult { - success: false, - phase_reached: TaskPhase::Planning, - branch_name: Some(branch_name.to_string()), - commit_sha: None, - files_modified: vec![], - summary: None, - error: Some(format!( - "Failed to create branch from {}: {}", - base_branch, - create.error.unwrap_or_else(|| "unknown error".to_string()) - )), - review_warnings: None, - session_id: None, - }; - } - } + // repo_path is a worktree with the task branch already checked out. + // No need to save/restore branches or stash — the worktree is isolated. // Run the implement phase (we skip separate plan phase for now — // Claude Code is capable enough to plan inline during implementation) @@ -200,7 +121,6 @@ pub async fn execute_task( // fail early instead of running a pointless review. if !git::has_commits_ahead(repo_path, base_branch) { println!("[worker] NO COMMITS on branch — Claude likely did not commit"); - let _ = git::checkout_branch(repo_path, &original_branch_name); return WorkResult { success: false, phase_reached: TaskPhase::Implementing, @@ -240,10 +160,9 @@ pub async fn execute_task( match review_result { Ok(review) if review.passed.unwrap_or(false) => { - // Success — get commit SHA and return to original branch + // Success — get commit SHA let sha = git::latest_commit_sha(repo_path); - println!("[worker] REVIEW PASSED — sha={:?}, returning to branch {original_branch_name}", sha.output); - let _ = git::checkout_branch(repo_path, &original_branch_name); + println!("[worker] REVIEW PASSED — sha={:?}", sha.output); return WorkResult { success: true, @@ -277,7 +196,6 @@ pub async fn execute_task( if !has_critical { let sha = git::latest_commit_sha(repo_path); println!("[worker] SOFT-PASS — no critical issues after {attempt} attempts, accepting with warnings"); - let _ = git::checkout_branch(repo_path, &original_branch_name); return WorkResult { success: true, phase_reached: TaskPhase::Reviewing, @@ -298,8 +216,6 @@ pub async fn execute_task( } // Genuine critical issues that couldn't be fixed - let _ = - git::checkout_branch(repo_path, &original_branch_name); return WorkResult { success: false, phase_reached: TaskPhase::Reviewing, @@ -321,7 +237,6 @@ pub async fn execute_task( last_feedback = review.feedback.clone(); } Err(e) => { - let _ = git::checkout_branch(repo_path, &original_branch_name); return WorkResult { success: false, phase_reached: TaskPhase::Reviewing, @@ -337,7 +252,6 @@ pub async fn execute_task( } } Err(e) => { - let _ = git::checkout_branch(repo_path, &original_branch_name); return WorkResult { success: false, phase_reached: TaskPhase::Implementing, diff --git a/src-tauri/src/engine/worktree.rs b/src-tauri/src/engine/worktree.rs new file mode 100644 index 0000000..b6afc44 --- /dev/null +++ b/src-tauri/src/engine/worktree.rs @@ -0,0 +1,170 @@ +use std::fs; +use std::path::Path; + +use super::git; + +/// Compute the worktree directory path for a task. +/// Uses first 8 chars of the task ID for readability. +pub fn get_worktree_path(repo_path: &str, task_id: &str) -> String { + let short_id = if task_id.len() >= 8 { + &task_id[..8] + } else { + task_id + }; + format!("{}/.sustn/worktrees/{}", repo_path, short_id) +} + +/// Create a git worktree for a task. Idempotent — if the worktree already +/// exists, returns the existing path without error. +/// +/// For new branches: `git worktree add -b ` +/// For existing branches: `git worktree add ` +pub fn create_worktree( + repo_path: &str, + task_id: &str, + branch_name: &str, + base_branch: &str, +) -> Result { + let wt_path = get_worktree_path(repo_path, task_id); + + // If the worktree directory already exists, verify it's valid + if Path::new(&wt_path).exists() { + // Check if it's a valid git worktree by running a git command in it + let check = git::run_git(&wt_path, &["rev-parse", "--git-dir"]); + if check.success { + println!( + "[worktree] reusing existing worktree at {} for task {}", + wt_path, task_id + ); + return Ok(wt_path); + } + // Directory exists but isn't a valid worktree — clean it up + println!( + "[worktree] removing stale worktree directory at {}", + wt_path + ); + let _ = fs::remove_dir_all(&wt_path); + // Also prune stale worktree entries + let _ = git::run_git(repo_path, &["worktree", "prune"]); + } + + // Ensure parent directory exists + let parent = format!("{}/.sustn/worktrees", repo_path); + fs::create_dir_all(&parent) + .map_err(|e| format!("Failed to create worktree directory: {}", e))?; + + // Try to create the worktree + let result = if git::branch_exists(repo_path, branch_name) { + // Branch already exists — just check it out in the worktree + git::run_git(repo_path, &["worktree", "add", &wt_path, branch_name]) + } else { + // Create a new branch from base + git::run_git( + repo_path, + &[ + "worktree", + "add", + &wt_path, + "-b", + branch_name, + base_branch, + ], + ) + }; + + if result.success { + println!( + "[worktree] created worktree at {} (branch: {}, base: {})", + wt_path, branch_name, base_branch + ); + Ok(wt_path) + } else { + let err = result.error.unwrap_or_else(|| "unknown error".to_string()); + + // Handle "already checked out" — find the existing worktree + if err.contains("already checked out") || err.contains("is already used by worktree") { + println!( + "[worktree] branch {} already checked out, looking for existing worktree", + branch_name + ); + // The branch is checked out somewhere — list worktrees to find it + let list = git::run_git(repo_path, &["worktree", "list", "--porcelain"]); + if list.success { + for line in list.output.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + let branch_check = git::current_branch(path); + if branch_check.success && branch_check.output == branch_name { + println!( + "[worktree] found branch {} in existing worktree at {}", + branch_name, path + ); + return Ok(path.to_string()); + } + } + } + } + } + + Err(format!( + "Failed to create worktree for task {}: {}", + task_id, err + )) + } +} + +/// Remove a task's worktree. Tolerates missing worktrees. +pub fn remove_worktree(repo_path: &str, task_id: &str) -> Result<(), String> { + let wt_path = get_worktree_path(repo_path, task_id); + + if !Path::new(&wt_path).exists() { + // Already gone — prune any stale refs + let _ = git::run_git(repo_path, &["worktree", "prune"]); + return Ok(()); + } + + println!("[worktree] removing worktree at {}", wt_path); + let result = git::run_git(repo_path, &["worktree", "remove", "--force", &wt_path]); + + if result.success { + Ok(()) + } else { + // Force-remove the directory if git worktree remove fails + println!( + "[worktree] git worktree remove failed, falling back to directory removal: {:?}", + result.error + ); + let _ = fs::remove_dir_all(&wt_path); + let _ = git::run_git(repo_path, &["worktree", "prune"]); + Ok(()) + } +} + +/// Ensure `.sustn/` is in the repo's `.gitignore`. +/// Idempotent — does nothing if the entry already exists. +pub fn ensure_gitignore_entry(repo_path: &str) -> Result<(), String> { + let gitignore_path = format!("{}/.gitignore", repo_path); + let path = Path::new(&gitignore_path); + + if path.exists() { + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read .gitignore: {}", e))?; + // Check if .sustn/ is already ignored (any common form) + if content.lines().any(|line| { + let trimmed = line.trim(); + trimmed == ".sustn/" || trimmed == ".sustn" || trimmed == "/.sustn/" + }) { + return Ok(()); + } + // Append the entry + let suffix = if content.ends_with('\n') { "" } else { "\n" }; + fs::write(path, format!("{}{}.sustn/\n", content, suffix)) + .map_err(|e| format!("Failed to write .gitignore: {}", e))?; + } else { + // Create .gitignore with the entry + fs::write(path, ".sustn/\n") + .map_err(|e| format!("Failed to create .gitignore: {}", e))?; + } + + println!("[worktree] added .sustn/ to .gitignore"); + Ok(()) +} diff --git a/src-tauri/src/engine_commands.rs b/src-tauri/src/engine_commands.rs index 153d1bf..f17fe51 100644 --- a/src-tauri/src/engine_commands.rs +++ b/src-tauri/src/engine_commands.rs @@ -263,10 +263,28 @@ pub async fn engine_start_task( db::read_project_preferences(&app_data_dir, &repository_id) }; - // Execute the task + // Create worktree for task isolation + let _ = engine::worktree::ensure_gitignore_entry(&repo_path); + let worktree_path = engine::worktree::create_worktree( + &repo_path, + &task_id, + &branch_name, + &base_branch, + ) + .map_err(|e| { + // Clear current_task on worktree creation failure + let state_clone = state.inner().clone(); + tokio::spawn(async move { + *state_clone.current_task.lock().await = None; + }); + e + })?; + println!("[engine_start_task] worktree created at {worktree_path}"); + + // Execute the task in the worktree println!("[engine_start_task] calling worker::execute_task — max_retries=4"); let result = worker::execute_task( - &repo_path, + &worktree_path, &task_id, &task_title, &task_description, @@ -631,6 +649,7 @@ pub async fn engine_address_review( review_comments: String, pr_description: String, resume_session_id: Option, + pr_diff: Option, ) -> Result { println!( "[engine_address_review] task_id={task_id}, branch={branch_name}, comments_len={}, resume={:?}", @@ -699,14 +718,29 @@ pub async fn engine_address_review( _ => String::new(), }; + // For imported PRs on the first cycle (no session), include the full diff + // so Claude understands the PR before addressing comments. + let pr_context_section = match (&resume_session_id, &pr_diff) { + (None, Some(diff)) => format!( + r#" + +## Full PR Diff (you are taking over this PR — study it carefully) +```diff +{diff} +``` +"# + ), + _ => String::new(), + }; + let prompt = format!( r#"IMPORTANT: You are running as an automated background agent in non-interactive mode. Commit your changes directly — do NOT ask for permission. -A human reviewer has left comments on the PR you created. You need to handle EVERY comment — either by making code changes or by drafting a reply. +A human reviewer has left comments on a PR. You need to handle EVERY comment — either by making code changes or by drafting a reply. ## PR Description {pr_description} - +{pr_context_section} ## Review Comments Each comment below has a COMMENT_ID number that you MUST include in your response. @@ -740,17 +774,27 @@ After making any code changes and committing, output ONLY this JSON (no markdown The comment_id MUST be the numeric ID from the [COMMENT_ID: ] tag in each comment above. Do NOT use null."# ); - // Ensure we're on the right branch - if engine::git::branch_exists(&repo_path, &branch_name) { - engine::git::checkout_branch(&repo_path, &branch_name); - } else { - engine::git::create_branch_from(&repo_path, &branch_name, &base_branch); - } + // Create/reuse worktree for task isolation + let _ = engine::worktree::ensure_gitignore_entry(&repo_path); + let worktree_path = engine::worktree::create_worktree( + &repo_path, + &task_id, + &branch_name, + &base_branch, + ) + .map_err(|e| { + let state_clone = state.inner().clone(); + tokio::spawn(async move { + *state_clone.current_task.lock().await = None; + }); + e + })?; + println!("[engine_address_review] using worktree at {worktree_path}"); // Call Claude CLI directly with our exact prompt (not through worker, // which overrides the prompt with its own resume template) let cli_result = engine::invoke_claude_cli( - &repo_path, + &worktree_path, &prompt, 1800, // 30 min timeout None, @@ -760,7 +804,7 @@ The comment_id MUST be the numeric ID from the [COMMENT_ID: ] tag in eac .await; // Get commit SHA after Claude ran - let sha_result = engine::git::latest_commit_sha(&repo_path); + let sha_result = engine::git::latest_commit_sha(&worktree_path); let commit_sha = if sha_result.success { Some(sha_result.output) } else { None }; let session_id = cli_result.as_ref().ok().and_then(|r| r.session_id.clone()); @@ -817,6 +861,52 @@ The comment_id MUST be the numeric ID from the [COMMENT_ID: ] tag in eac Ok(result) } +/// Remove a task's worktree (cleanup after completion/dismissal). +#[tauri::command] +pub async fn engine_cleanup_worktree( + repo_path: String, + task_id: String, +) -> Result<(), String> { + engine::worktree::remove_worktree(&repo_path, &task_id) +} + +/// Clone a repository (non-blocking, no credential prompts). +#[tauri::command] +pub async fn engine_clone_repo( + url: String, + destination: String, +) -> Result { + // Run on blocking thread since clone can take a while + tokio::task::spawn_blocking(move || { + Ok(engine::git::clone_repo(&url, &destination)) + }) + .await + .map_err(|e| format!("Clone task failed: {}", e))? +} + +/// Fetch a specific branch from origin (with optional PR number for fork fallback). +#[tauri::command] +pub async fn engine_fetch_branch( + repo_path: String, + branch_name: String, + pr_number: Option, +) -> Result { + Ok(engine::git::fetch_branch_with_pr(&repo_path, &branch_name, pr_number)) +} + +/// Get the remote URL for origin. +#[tauri::command] +pub async fn engine_get_remote_url( + repo_path: String, +) -> Result { + let result = engine::git::get_remote_url(&repo_path); + if result.success { + Ok(result.output) + } else { + Err(result.error.unwrap_or_else(|| "Failed to get remote URL".to_string())) + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EngineStatusResponse { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bbdaf8a..c7d0665 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,10 @@ pub fn run() { engine_commands::engine_create_pr, engine_commands::engine_augment_tasks, engine_commands::engine_address_review, + engine_commands::engine_cleanup_worktree, + engine_commands::engine_clone_repo, + engine_commands::engine_fetch_branch, + engine_commands::engine_get_remote_url, engine_commands::run_gh_api, engine_commands::run_gh_api_post, engine_commands::run_terminal_command, diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index fb029bb..f3c1947 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -364,5 +364,14 @@ pub fn migrations() -> Vec { "#, kind: MigrationKind::Up, }, + // Migration 18: worktree path for task isolation + Migration { + version: 18, + description: "add worktree_path to tasks", + sql: r#" + ALTER TABLE tasks ADD COLUMN worktree_path TEXT; + "#, + kind: MigrationKind::Up, + }, ] } diff --git a/src/core/api/useImportPr.ts b/src/core/api/useImportPr.ts new file mode 100644 index 0000000..493fd07 --- /dev/null +++ b/src/core/api/useImportPr.ts @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { importPr } from "@core/services/pr-import"; +import { parseOwnerRepo } from "@core/services/github"; +import { useAppStore } from "@core/store/app-store"; +import { + prImportProgressToast, + prImportSuccessToast, + prImportErrorToast, +} from "@ui/lib/toast"; + +export function useImportPr() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (prUrl: string) => { + const parsed = parseOwnerRepo(prUrl); + const toastId = parsed + ? `pr-import-${parsed.owner}-${parsed.repo}-${parsed.number}` + : `pr-import-${Date.now()}`; + const label = parsed ? `PR #${parsed.number}` : "PR"; + + prImportProgressToast(toastId, `Importing ${label}...`); + + return importPr(prUrl, { + onProgress: (step) => prImportProgressToast(toastId, step), + onRepoReady: (repositoryId) => { + // Refresh sidebar immediately so the project appears + void queryClient.invalidateQueries({ + queryKey: ["repositories"], + }); + useAppStore.getState().setSelectedRepository(repositoryId); + }, + }).then( + (task) => { + prImportSuccessToast(toastId, task.prNumber ?? 0); + return task; + }, + (err) => { + prImportErrorToast( + toastId, + err instanceof Error ? err.message : "Import failed", + ); + throw err; + }, + ); + }, + onSuccess: (task) => { + void queryClient.invalidateQueries({ + queryKey: ["tasks", task.repositoryId], + }); + void queryClient.invalidateQueries({ + queryKey: ["repositories"], + }); + + // Select the imported task + useAppStore.getState().setSelectedRepository(task.repositoryId); + useAppStore.getState().setSelectedTask(task.id); + }, + }); +} diff --git a/src/core/api/useTasks.ts b/src/core/api/useTasks.ts index 9ab14ff..186c128 100644 --- a/src/core/api/useTasks.ts +++ b/src/core/api/useTasks.ts @@ -1,4 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; import { listTasks, getTask, @@ -11,6 +12,7 @@ import { createMessage as dbCreateMessage, listMessages as dbListMessages, } from "@core/db/tasks"; +import { listRepositories } from "@core/db/repositories"; import type { Task, TaskCategory, @@ -20,6 +22,23 @@ import type { } from "@core/types/task"; import { metrics } from "@core/services/metrics"; +async function cleanupWorktree( + repositoryId: string, + taskId: string, +): Promise { + try { + const repos = await listRepositories(); + const repo = repos.find((r) => r.id === repositoryId); + if (!repo) return; + await invoke("engine_cleanup_worktree", { + repoPath: repo.path, + taskId, + }); + } catch (e) { + console.warn("[useTasks] worktree cleanup failed:", e); + } +} + export function useTasks( repositoryId: string | undefined, baseBranch?: string, @@ -94,6 +113,11 @@ export function useUpdateTask() { void queryClient.invalidateQueries({ queryKey: ["task-events", task.id], }); + + // Clean up worktree when task reaches a terminal state + if (task.state === "done" || task.state === "dismissed") { + void cleanupWorktree(task.repositoryId, task.id); + } }, }); } diff --git a/src/core/db/tasks.ts b/src/core/db/tasks.ts index b6ea4b5..2803f50 100644 --- a/src/core/db/tasks.ts +++ b/src/core/db/tasks.ts @@ -30,6 +30,7 @@ interface TaskRow { files_involved: string | null; base_branch: string | null; branch_name: string | null; + worktree_path: string | null; commit_sha: string | null; session_id: string | null; linear_issue_id: string | null; @@ -109,6 +110,7 @@ function rowToTask(row: TaskRow): Task { filesInvolved: parseFilesInvolved(row.files_involved), baseBranch: row.base_branch ?? undefined, branchName: row.branch_name ?? undefined, + worktreePath: row.worktree_path ?? undefined, commitSha: row.commit_sha ?? undefined, sessionId: row.session_id ?? undefined, linearIssueId: row.linear_issue_id ?? undefined, @@ -268,6 +270,7 @@ export async function updateTask( | "category" | "baseBranch" | "branchName" + | "worktreePath" | "commitSha" | "sessionId" | "lastError" @@ -329,6 +332,10 @@ export async function updateTask( setClauses.push(`branch_name = $${paramIndex++}`); values.push(fields.branchName); } + if (fields.worktreePath !== undefined) { + setClauses.push(`worktree_path = $${paramIndex++}`); + values.push(fields.worktreePath); + } if (fields.commitSha !== undefined) { setClauses.push(`commit_sha = $${paramIndex++}`); values.push(fields.commitSha); @@ -582,6 +589,63 @@ export async function createScannedTask( return rowToTask(rows[0]); } +/** + * Create a task from an imported PR. + * The task starts in "review" state with PR fields pre-populated. + */ +export async function createImportedTask( + repositoryId: string, + task: { + title: string; + description: string | undefined; + baseBranch: string; + branchName: string; + prUrl: string; + prNumber: number; + }, + sortOrder: number, +): Promise { + const db = await getDb(); + const id = await invoke("generate_task_id"); + + await db.execute( + `INSERT INTO tasks (id, repository_id, title, description, category, sort_order, source, state, base_branch, branch_name, pr_url, pr_number, pr_state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + [ + id, + repositoryId, + task.title, + task.description ?? null, + "general", + sortOrder, + "imported", + "review", + task.baseBranch, + task.branchName, + task.prUrl, + task.prNumber, + "in_review", + ], + ); + + await recordEvent( + db, + id, + "created", + undefined, + undefined, + undefined, + "Imported from PR", + ); + + const rows = await db.select( + "SELECT * FROM tasks WHERE id = $1", + [id], + ); + + return rowToTask(rows[0]); +} + /** * Insert a single Linear-sourced task into the DB. */ diff --git a/src/core/services/github.ts b/src/core/services/github.ts index 366320e..4d22dd5 100644 --- a/src/core/services/github.ts +++ b/src/core/services/github.ts @@ -78,6 +78,39 @@ async function ghApiPost( return JSON.parse(result.stdout) as T; } +// ── PR Metadata ──────────────────────────────────────────── + +export interface GhPrMetadata { + title: string; + body: string; + headBranch: string; + baseBranch: string; + user: { login: string }; + state: "open" | "closed"; + merged: boolean; +} + +export async function getPrMetadata( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + const raw = await ghApi>( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}`, + ); + return { + title: raw.title as string, + body: (raw.body as string) ?? "", + headBranch: (raw.head as { ref: string }).ref, + baseBranch: (raw.base as { ref: string }).ref, + user: { login: (raw.user as { login: string }).login }, + state: raw.state as "open" | "closed", + merged: raw.merged as boolean, + }; +} + // ── PR Status ─────────────────────────────────────────────── export async function getPrStatus( @@ -190,3 +223,74 @@ export async function getPrDiff( } return result.stdout; } + +// ── Resolved Threads ────────────────────────────────────── + +/** + * Fetch the set of root comment IDs whose review thread has been + * marked as "Resolved" on GitHub. + * + * Uses the GraphQL API because the REST API does not expose + * thread resolution status. + */ +export async function getResolvedThreadCommentIds( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise> { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(first: 1) { + nodes { databaseId } + } + } + } + } + } + } + `; + + try { + const result = await ghApiPost<{ + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes?: Array<{ + isResolved: boolean; + comments: { + nodes: Array<{ databaseId: number }>; + }; + }>; + }; + }; + }; + }; + }>(repoPath, "graphql", { + query, + variables: { owner, repo, number: prNumber }, + }); + + const threads = + result.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []; + const resolvedIds = new Set(); + for (const thread of threads) { + if (thread.isResolved) { + const rootComment = thread.comments.nodes[0]; + if (rootComment) { + resolvedIds.add(rootComment.databaseId); + } + } + } + return resolvedIds; + } catch (e) { + console.warn(`[github] failed to fetch resolved threads:`, e); + return new Set(); + } +} diff --git a/src/core/services/pr-import.ts b/src/core/services/pr-import.ts new file mode 100644 index 0000000..ad695c9 --- /dev/null +++ b/src/core/services/pr-import.ts @@ -0,0 +1,217 @@ +/** + * PR Import Service + * + * Orchestrates importing an external PR into SUSTN: + * parse URL → match/clone repo → fetch metadata → create task → trigger lifecycle + */ + +import { invoke } from "@tauri-apps/api/core"; +import { parseOwnerRepo, getPrMetadata } from "@core/services/github"; +import { listRepositories, addRepository } from "@core/db/repositories"; +import { createImportedTask, listTasks } from "@core/db/tasks"; +import { getAgentConfig, updateLastScanAt } from "@core/db/agent-config"; +import type { Task } from "@core/types/task"; + +export type ImportProgress = (step: string) => void; +export type ImportCallbacks = { + onProgress?: ImportProgress; + onRepoReady?: (repositoryId: string) => void; +}; + +/** + * Import a GitHub PR into SUSTN. + * Returns the created task, ready for the PR lifecycle poller to pick up. + */ +export async function importPr( + prUrl: string, + callbacks?: ImportCallbacks, +): Promise { + const progress = callbacks?.onProgress ?? (() => {}); + + // 1. Parse the PR URL + const parsed = parseOwnerRepo(prUrl); + if (!parsed) { + throw new Error( + "Invalid PR URL. Expected: https://github.com/owner/repo/pull/123", + ); + } + const { owner, repo, number: prNumber } = parsed; + console.log(`[pr-import] importing ${owner}/${repo}#${prNumber}`); + + // 2. Find or clone the repository + progress(`Looking for ${owner}/${repo}...`); + const repoPath = await resolveRepository(owner, repo, progress); + const repos = await listRepositories(); + const repository = repos.find((r) => r.path === repoPath); + if (!repository) { + throw new Error(`Repository not found after resolution: ${repoPath}`); + } + + // Signal that the repo is ready (so sidebar can refresh immediately) + callbacks?.onRepoReady?.(repository.id); + + // 3. Fetch PR metadata from GitHub + progress(`Fetching PR #${prNumber} details...`); + const metadata = await getPrMetadata(repoPath, owner, repo, prNumber); + if (metadata.merged) { + throw new Error("This PR has already been merged."); + } + if (metadata.state === "closed") { + throw new Error("This PR is closed."); + } + + console.log( + `[pr-import] PR #${prNumber}: "${metadata.title}" (${metadata.headBranch} → ${metadata.baseBranch})`, + ); + + // 4. Fetch the PR branch locally + progress(`Fetching branch ${metadata.headBranch}...`); + await invoke("engine_fetch_branch", { + repoPath, + branchName: metadata.headBranch, + prNumber, + }); + + // 5. Create the task + progress("Creating task..."); + const existingTasks = await listTasks(repository.id); + const maxSortOrder = + existingTasks.length > 0 + ? Math.max(...existingTasks.map((t) => t.sortOrder)) + : 0; + + const task = await createImportedTask( + repository.id, + { + title: `[PR #${prNumber}] ${metadata.title}`, + description: metadata.body || undefined, + baseBranch: metadata.baseBranch, + branchName: metadata.headBranch, + prUrl, + prNumber, + }, + maxSortOrder + 1, + ); + console.log(`[pr-import] task created: ${task.id}`); + + // Prevent auto-scanning — focus on addressing the PR first + try { + await getAgentConfig(repository.id); + await updateLastScanAt(repository.id); + } catch { + // Non-critical + } + + // Trigger PR lifecycle immediately (don't wait for 2-min poll) + try { + const { processTaskPr } = await import("@core/services/pr-lifecycle"); + const { getGlobalSettings, getProjectOverrides } = + await import("@core/db/settings"); + const settings = await getGlobalSettings(); + const overrides = await getProjectOverrides(repository.id); + const autoReply = + overrides.overridePrAutoReply ?? settings.prLifecycleEnabled; + + // Re-fetch the task so it has all fields populated + const { getTask } = await import("@core/db/tasks"); + const freshTask = await getTask(task.id); + if (freshTask) { + void processTaskPr( + freshTask, + repoPath, + settings.maxReviewCycles ?? 5, + autoReply, + ); + } + } catch (e) { + console.warn(`[pr-import] failed to trigger immediate lifecycle:`, e); + } + + return task; +} + +/** + * Find a local repository matching the GitHub owner/repo, + * or clone it if not found. + */ +async function resolveRepository( + owner: string, + repo: string, + progress: ImportProgress, +): Promise { + const repos = await listRepositories(); + const githubSuffix = `${owner}/${repo}`; + + // Check each repo's remote URL for a match + for (const r of repos) { + try { + const remoteUrl = await invoke("engine_get_remote_url", { + repoPath: r.path, + }); + if ( + remoteUrl.includes(githubSuffix) || + remoteUrl.includes(`${githubSuffix}.git`) + ) { + console.log( + `[pr-import] matched existing repo: ${r.name} (${r.path})`, + ); + return r.path; + } + } catch { + // Skip repos where remote URL can't be read + } + } + + // No match — clone the repo + progress(`Cloning ${owner}/${repo} — this may take a minute...`); + console.log(`[pr-import] no matching repo, cloning ${owner}/${repo}`); + const cloneUrl = `https://github.com/${owner}/${repo}.git`; + + const defaultDir = await invoke("get_default_clone_dir"); + const destination = `${defaultDir}/${repo}`; + + // Check if destination already exists (from a previous clone attempt) + let clonedPath: string; + try { + const validateResult = await invoke<{ + valid: boolean; + error: string | null; + }>("validate_git_repo", { path: destination }); + if (validateResult.valid) { + console.log(`[pr-import] reusing existing clone at ${destination}`); + clonedPath = destination; + } else { + throw new Error("not a git repo"); + } + } catch { + // Destination doesn't exist or isn't a git repo — clone it + const result = await invoke<{ + success: boolean; + output: string; + error: string | null; + }>("engine_clone_repo", { + url: cloneUrl, + destination, + }); + + if (!result.success) { + throw new Error( + `Failed to clone ${owner}/${repo}: ${result.error ?? "unknown error"}`, + ); + } + clonedPath = destination; + } + + // Add to SUSTN (may already exist from a previous attempt) + try { + await addRepository(clonedPath, repo); + console.log(`[pr-import] cloned and added: ${clonedPath}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes("already been added")) { + throw e; + } + } + + return clonedPath; +} diff --git a/src/core/services/pr-lifecycle.ts b/src/core/services/pr-lifecycle.ts index 3df24ef..aa119e2 100644 --- a/src/core/services/pr-lifecycle.ts +++ b/src/core/services/pr-lifecycle.ts @@ -88,6 +88,15 @@ export async function processTaskPr( completedAt: new Date().toISOString(), }); await recordPrEvent(task.id, "pr_merged", `PR #${prNumber} merged`); + // Clean up worktree + try { + await invoke("engine_cleanup_worktree", { + repoPath: repoPath, + taskId: task.id, + }); + } catch (e) { + console.warn(`[pr-lifecycle] worktree cleanup failed:`, e); + } return; } @@ -186,8 +195,33 @@ export async function processTaskPr( // 5. Determine which comments need processing const refreshedDbComments = await listComments(task.id); + + // Fetch resolved thread IDs from GitHub to skip already-resolved conversations + let resolvedIds = new Set(); + try { + const { getResolvedThreadCommentIds } = + await import("@core/services/github"); + resolvedIds = await getResolvedThreadCommentIds( + repoPath, + owner, + repo, + prNumber, + ); + if (resolvedIds.size > 0) { + console.log( + `[pr-lifecycle] PR #${prNumber} — ${resolvedIds.size} resolved thread(s), skipping`, + ); + } + } catch (e) { + console.warn(`[pr-lifecycle] failed to fetch resolved threads:`, e); + } + const unprocessedComments = refreshedDbComments.filter( - (c) => !c.ourReply && !c.addressedInCommit && !c.inReplyToId, + (c) => + !c.ourReply && + !c.addressedInCommit && + !c.inReplyToId && + !resolvedIds.has(c.githubCommentId), ); if (task.prState === "opened") { @@ -274,6 +308,21 @@ export async function processTaskPr( ); await updateTask(task.id, { prState: "addressing" as PrState }); + // For imported PRs on the first addressing cycle, fetch the full diff + // so Claude has context about the PR before addressing comments. + let prDiff: string | undefined; + if (task.source === "imported" && !task.sessionId) { + try { + const { getPrDiff } = await import("@core/services/github"); + prDiff = await getPrDiff(repoPath, owner, repo, prNumber); + console.log( + `[pr-lifecycle] fetched PR diff for imported task (${prDiff.length} chars)`, + ); + } catch (e) { + console.warn(`[pr-lifecycle] failed to fetch PR diff:`, e); + } + } + const reviewContext = unprocessedComments .map((c) => { const location = c.path @@ -293,6 +342,7 @@ export async function processTaskPr( reviewComments: reviewContext, prDescription: task.description ?? task.title, resumeSessionId: task.sessionId ?? undefined, + prDiff: prDiff ?? null, }); if (result.success) { @@ -510,6 +560,9 @@ export async function prLifecycleTick(): Promise { const repoMap = new Map(repos.map((r) => [r.id, r])); const maxCycles = settings.maxReviewCycles ?? 5; + // Process all active PRs. Syncing comments is fast (GitHub API), + // but addressing (Claude CLI) is slow and mutex-guarded — so we + // process them sequentially but each PR gets synced every tick. for (const pr of activePrs) { const repo = repoMap.get(pr.repositoryId); if (!repo) { @@ -527,6 +580,15 @@ export async function prLifecycleTick(): Promise { continue; } + // Skip PRs that are currently being addressed (from a previous tick + // or from the immediate trigger on import) + if (task.prState === "addressing") { + console.log( + `[pr-lifecycle] skipping PR #${pr.prNumber} — already being addressed`, + ); + continue; + } + // Resolve per-repo auto-reply setting (override ?? global) const overrides = await getProjectOverrides(pr.repositoryId); const autoReply = diff --git a/src/core/types/task.ts b/src/core/types/task.ts index 25d5358..c9057e0 100644 --- a/src/core/types/task.ts +++ b/src/core/types/task.ts @@ -17,7 +17,7 @@ export type TaskState = | "dismissed" | "failed"; -export type TaskSource = "manual" | "scan" | "linear"; +export type TaskSource = "manual" | "scan" | "linear" | "imported"; export type EstimatedEffort = "low" | "medium" | "high"; export type PrState = @@ -47,6 +47,7 @@ export interface Task { filesInvolved: string[] | undefined; baseBranch: string | undefined; branchName: string | undefined; + worktreePath: string | undefined; commitSha: string | undefined; sessionId: string | undefined; tokensUsed: number; diff --git a/src/ui/components/main/EmptyState.tsx b/src/ui/components/main/EmptyState.tsx index ac04f44..db9649c 100644 --- a/src/ui/components/main/EmptyState.tsx +++ b/src/ui/components/main/EmptyState.tsx @@ -21,11 +21,11 @@ export function EmptyState() {

- Scanning for tasks... -

-

Select a project from the sidebar to get started.

+

+ Or paste a PR link in the sidebar to import one. +

); } diff --git a/src/ui/components/onboarding/AddProjectStep.tsx b/src/ui/components/onboarding/AddProjectStep.tsx index 61e557b..283ab14 100644 --- a/src/ui/components/onboarding/AddProjectStep.tsx +++ b/src/ui/components/onboarding/AddProjectStep.tsx @@ -282,6 +282,17 @@ export function AddProjectStep({ onNext }: AddProjectStepProps) { {error} )} + +
+ +
); } diff --git a/src/ui/components/sidebar/Sidebar.tsx b/src/ui/components/sidebar/Sidebar.tsx index 5616a4f..7905375 100644 --- a/src/ui/components/sidebar/Sidebar.tsx +++ b/src/ui/components/sidebar/Sidebar.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import type { CSSProperties } from "react"; -import { Plus, Search } from "lucide-react"; +import { Plus, Search, Link } from "lucide-react"; import { useAppStore } from "@core/store/app-store"; import { useScanNow } from "@core/api/useEngine"; import { useGlobalSettings } from "@core/api/useSettings"; +import { useImportPr } from "@core/api/useImportPr"; import { ProjectList } from "./ProjectList"; import { SidebarFooter } from "./SidebarFooter"; import { AddProjectDialog } from "./AddProjectDialog"; @@ -17,8 +18,19 @@ export function Sidebar({ style }: SidebarProps) { const setSelectedRepository = useAppStore((s) => s.setSelectedRepository); const scanNow = useScanNow(); const { data: globalSettings } = useGlobalSettings(); + const importPr = useImportPr(); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [search, setSearch] = useState(""); + const [prUrl, setPrUrl] = useState(""); + const prInputRef = useRef(null); + + function handleImportPr() { + const url = prUrl.trim(); + if (!url) return; + // Clear input immediately — progress shows in a toast + setPrUrl(""); + importPr.mutate(url); + } return (