diff --git a/src-tauri/src/engine_commands.rs b/src-tauri/src/engine_commands.rs index 5d6b07d..153d1bf 100644 --- a/src-tauri/src/engine_commands.rs +++ b/src-tauri/src/engine_commands.rs @@ -550,6 +550,273 @@ pub struct AugmentTaskResult { pub category: String, } +/// Run a `gh api` GET request and return the raw JSON output. +#[tauri::command] +pub async fn run_gh_api( + repo_path: String, + endpoint: String, + accept: Option, +) -> Result { + let gh = crate::preflight::resolve_gh_binary_pub(); + + let mut cmd = std::process::Command::new(&gh); + cmd.args(["api", &endpoint]); + if let Some(accept_header) = accept { + cmd.args(["-H", &format!("Accept: {accept_header}")]); + } + cmd.current_dir(&repo_path); + + let output = cmd + .output() + .map_err(|e| format!("Failed to execute gh: {e}"))?; + + Ok(GhApiResult { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +/// Run a `gh api` POST request with a JSON body. +#[tauri::command] +pub async fn run_gh_api_post( + repo_path: String, + endpoint: String, + body: String, +) -> Result { + let gh = crate::preflight::resolve_gh_binary_pub(); + + let output = std::process::Command::new(&gh) + .args(["api", &endpoint, "--method", "POST", "--input", "-"]) + .current_dir(&repo_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(body.as_bytes())?; + } + child.wait_with_output() + }) + .map_err(|e| format!("Failed to execute gh: {e}"))?; + + Ok(GhApiResult { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GhApiResult { + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +/// Address PR review comments by invoking Claude CLI with the review context. +/// Resumes the original session when available so Claude has full reasoning context. +#[tauri::command] +pub async fn engine_address_review( + app: AppHandle, + state: State<'_, Arc>, + task_id: String, + repository_id: String, + repo_path: String, + branch_name: String, + base_branch: String, + review_comments: String, + pr_description: String, + resume_session_id: Option, +) -> Result { + println!( + "[engine_address_review] task_id={task_id}, branch={branch_name}, comments_len={}, resume={:?}", + review_comments.len(), + resume_session_id, + ); + + // Wait for deep scan + { + loop { + let is_scanning = state.deep_scanning_repos.lock().await.contains(&repository_id); + if !is_scanning { break; } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + + if let Some(issue) = engine::git::preflight_check(&repo_path) { + return Err(format!("Environment issue: {}", issue.error)); + } + + // Budget check + { + let app_data_dir = app.path().app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {e}"))?; + let mut config = db::read_budget_config(&app_data_dir); + let effective_ceiling = db::read_effective_ceiling_percent(&app_data_dir, &repository_id); + if effective_ceiling < config.max_usage_percent { + config.max_usage_percent = effective_ceiling; + } + let status = budget::calculate_budget_status(&config); + if status.budget_exhausted { + return Err("Budget exhausted — cannot address review".to_string()); + } + } + + { + let current = state.current_task.lock().await; + if current.is_some() { + return Err("Another task is already in progress".to_string()); + } + } + + { + let mut current = state.current_task.lock().await; + *current = Some(CurrentTask { + task_id: task_id.clone(), + repository_id: repository_id.clone(), + phase: TaskPhase::Implementing, + started_at: chrono::Local::now().to_rfc3339(), + }); + } + + let _ = app.emit("agent:task-started", serde_json::json!({ + "taskId": task_id, + "repositoryId": repository_id, + })); + + let (agent_prefs, _) = { + let app_data_dir = app.path().app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {e}"))?; + db::read_project_preferences(&app_data_dir, &repository_id) + }; + + let prefs_section = match agent_prefs.as_deref() { + Some(prefs) if !prefs.trim().is_empty() => format!("\n\n## Project-Specific Instructions\n{prefs}"), + _ => 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. + +## PR Description +{pr_description} + +## Review Comments +Each comment below has a COMMENT_ID number that you MUST include in your response. + +{review_comments} +{prefs_section} + +## Instructions +For EACH review comment above: + +1. **If it requires code changes** (bug fix, refactor, improvement, the reviewer is questioning an approach and they're right): make the changes, commit with trailer SUSTN-Task: {task_id}, and draft a reply explaining what you changed. + +2. **If it's a question about your reasoning** (why did you do X?): explain your reasoning clearly — you have context from when you wrote this code. + +3. **If it's praise or acknowledgment** (looks good, nice, etc.): draft a brief thanks. + +CRITICAL: You MUST return a reply for EVERY comment. Use the exact COMMENT_ID number from each comment header above. + +After making any code changes and committing, output ONLY this JSON (no markdown): +{{ + "replies": [ + {{ + "comment_id": 1234567890, + "reply": "Your response to this specific comment", + "made_code_changes": true + }} + ], + "summary": "Brief description of what was changed", + "files_modified": ["list", "of", "files"] +}} + +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); + } + + // 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, + &prompt, + 1800, // 30 min timeout + None, + None, + resume_session_id.as_deref(), + ) + .await; + + // Get commit SHA after Claude ran + let sha_result = engine::git::latest_commit_sha(&repo_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()); + + // Build result + let (success, summary, error) = match &cli_result { + Ok(r) if r.success => { + // Extract the result text from Claude's JSON wrapper + let summary = if let Ok(v) = serde_json::from_str::(&r.stdout) { + v.get("result") + .and_then(|r| r.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| r.stdout.clone()) + } else { + r.stdout.clone() + }; + (true, Some(summary), None) + } + Ok(r) => (false, None, Some(format!("Claude CLI returned error: {}", r.stderr))), + Err(e) => (false, None, Some(e.clone())), + }; + + let result = worker::WorkResult { + success, + phase_reached: engine::TaskPhase::Implementing, + branch_name: Some(branch_name.clone()), + commit_sha: commit_sha.clone(), + files_modified: vec![], + summary: summary.clone(), + review_warnings: None, + error: error.clone(), + session_id: session_id.clone(), + }; + + { + let mut current = state.current_task.lock().await; + *current = None; + } + + if success { + let _ = app.emit("agent:review-addressed", serde_json::json!({ + "taskId": task_id, + "repositoryId": repository_id, + "branchName": branch_name, + "commitSha": commit_sha, + })); + } else { + let _ = app.emit("agent:review-address-failed", serde_json::json!({ + "taskId": task_id, + "repositoryId": repository_id, + "error": error, + })); + } + + Ok(result) +} + #[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 24eb8c8..bbdaf8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,9 @@ pub fn run() { engine_commands::engine_get_diff_stat, engine_commands::engine_create_pr, engine_commands::engine_augment_tasks, + engine_commands::engine_address_review, + engine_commands::run_gh_api, + engine_commands::run_gh_api_post, engine_commands::run_terminal_command, command::set_dock_badge, ]) diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 4bde743..88de07b 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -302,5 +302,58 @@ pub fn migrations() -> Vec { "#, kind: MigrationKind::Up, }, + Migration { + version: 16, + description: "add PR lifecycle management tables and columns", + sql: r#" + ALTER TABLE tasks ADD COLUMN pr_state TEXT; + ALTER TABLE tasks ADD COLUMN pr_number INTEGER; + ALTER TABLE tasks ADD COLUMN pr_review_cycles INTEGER DEFAULT 0; + + CREATE TABLE IF NOT EXISTS pr_reviews ( + id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + github_review_id INTEGER NOT NULL, + reviewer TEXT NOT NULL, + state TEXT NOT NULL, + body TEXT, + submitted_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_pr_reviews_task + ON pr_reviews(task_id); + + CREATE TABLE IF NOT EXISTS pr_comments ( + id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + github_comment_id INTEGER NOT NULL, + in_reply_to_id INTEGER, + reviewer TEXT NOT NULL, + body TEXT NOT NULL, + path TEXT, + line INTEGER, + side TEXT, + commit_id TEXT, + classification TEXT, + our_reply TEXT, + addressed_in_commit TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_pr_comments_task + ON pr_comments(task_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_comments_github_id + ON pr_comments(github_comment_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_reviews_github_id + ON pr_reviews(github_review_id); + + INSERT OR IGNORE INTO global_settings (key, value) VALUES + ('pr_lifecycle_enabled', 'true'), + ('max_review_cycles', '5'); + "#, + kind: MigrationKind::Up, + }, ] } diff --git a/src-tauri/src/preflight.rs b/src-tauri/src/preflight.rs index 3bdfd68..d2b39eb 100644 --- a/src-tauri/src/preflight.rs +++ b/src-tauri/src/preflight.rs @@ -51,6 +51,11 @@ fn resolve_gh_binary() -> String { resolve_binary("gh", &[]) } +/// Public accessor for use in engine_commands +pub fn resolve_gh_binary_pub() -> String { + resolve_gh_binary() +} + fn resolve_git_binary() -> String { resolve_binary("git", &[PathBuf::from("/usr/bin/git")]) } diff --git a/src/core/api/useEngine.ts b/src/core/api/useEngine.ts index 9bc21b3..836149c 100644 --- a/src/core/api/useEngine.ts +++ b/src/core/api/useEngine.ts @@ -18,6 +18,7 @@ import { } from "@core/db/tasks"; import { listRepositories } from "@core/db/repositories"; import { addComment as addLinearComment } from "@core/services/linear"; +import { parseOwnerRepo } from "@core/services/github"; import type { BudgetConfig, BudgetStatus, @@ -471,14 +472,22 @@ async function handleTaskResult( } } + const prMeta = prUrl ? parseOwnerRepo(prUrl) : undefined; + await dbUpdateTaskWithRetry(variables.taskId, { - state: prUrl ? ("done" as const) : ("review" as const), + state: "review" as const, baseBranch: variables.baseBranch, branchName: result.branchName, commitSha: result.commitSha, sessionId: result.sessionId, completedAt: new Date().toISOString(), ...(prUrl ? { prUrl } : {}), + ...(prMeta + ? { + prState: "opened" as const, + prNumber: prMeta.number, + } + : {}), }); if (notify && settings.notificationsEnabled) { diff --git a/src/core/api/usePrLifecycle.ts b/src/core/api/usePrLifecycle.ts new file mode 100644 index 0000000..dd33769 --- /dev/null +++ b/src/core/api/usePrLifecycle.ts @@ -0,0 +1,167 @@ +/** + * PR Lifecycle polling hook + related queries. + * Mount once in AppShell to enable automatic PR review monitoring. + */ + +import { useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { listen } from "@tauri-apps/api/event"; +import { prLifecycleTick } from "@core/services/pr-lifecycle"; +import { + listReviews, + listComments, + getTasksWithActivePr, +} from "@core/db/pr-lifecycle"; +import { getGlobalSettings } from "@core/db/settings"; +import { + sendNotification, + playSound, + incrementBadge, +} from "@core/services/notifications"; + +const POLL_INTERVAL_MS = 120_000; // 2 minutes +const STARTUP_DELAY_MS = 15_000; // 15 seconds after app start + +/** + * Background poller for PR lifecycle events. + * Mount once in AppShell. + */ +export function usePrLifecyclePoller() { + const queryClient = useQueryClient(); + const busyRef = useRef(false); + + useEffect(() => { + let timer: ReturnType; + let mounted = true; + + async function tick() { + if (!mounted || busyRef.current) return; + busyRef.current = true; + + try { + await prLifecycleTick(); + // Invalidate relevant queries after each tick + void queryClient.invalidateQueries({ queryKey: ["tasks"] }); + void queryClient.invalidateQueries({ + queryKey: ["pr-reviews"], + }); + void queryClient.invalidateQueries({ + queryKey: ["pr-comments"], + }); + } catch (e) { + console.error("[pr-lifecycle] tick error:", e); + } finally { + busyRef.current = false; + } + } + + const startupTimer = setTimeout(() => { + if (!mounted) return; + void tick(); + timer = setInterval(() => void tick(), POLL_INTERVAL_MS); + }, STARTUP_DELAY_MS); + + return () => { + mounted = false; + clearTimeout(startupTimer); + clearInterval(timer); + }; + }, [queryClient]); + + // Listen for review-addressed events to refresh immediately + useEffect(() => { + const unlisteners: Promise<() => void>[] = []; + + unlisteners.push( + listen<{ taskId: string; repositoryId: string }>( + "agent:review-addressed", + (event) => { + console.log( + "[pr-lifecycle] review addressed:", + event.payload, + ); + void queryClient.invalidateQueries({ + queryKey: ["task", event.payload.taskId], + }); + void queryClient.invalidateQueries({ + queryKey: ["tasks", event.payload.repositoryId], + }); + void queryClient.invalidateQueries({ + queryKey: ["pr-comments", event.payload.taskId], + }); + + void getGlobalSettings().then((settings) => { + if (settings.notificationsEnabled) { + void sendNotification( + "Review comments addressed", + "Agent pushed changes and re-requested review.", + ); + void incrementBadge(); + } + if (settings.soundEnabled) { + void playSound(settings.soundPreset); + } + }); + }, + ), + ); + + unlisteners.push( + listen<{ + taskId: string; + repositoryId: string; + error: string | null; + }>("agent:review-address-failed", (event) => { + console.error("[pr-lifecycle] address failed:", event.payload); + void queryClient.invalidateQueries({ + queryKey: ["task", event.payload.taskId], + }); + + void getGlobalSettings().then((settings) => { + if (settings.notificationsEnabled) { + void sendNotification( + "Failed to address review", + event.payload.error ?? "Unknown error", + ); + void incrementBadge(); + } + }); + }), + ); + + return () => { + for (const p of unlisteners) { + void p.then((fn) => fn()); + } + }; + }, [queryClient]); +} + +// ── Queries ───────────────────────────────────────────────── + +export function usePrReviews(taskId: string | undefined) { + return useQuery({ + queryKey: ["pr-reviews", taskId], + queryFn: () => listReviews(taskId!), + enabled: !!taskId, + }); +} + +export function usePrComments(taskId: string | undefined) { + return useQuery({ + queryKey: ["pr-comments", taskId], + queryFn: () => listComments(taskId!), + enabled: !!taskId, + }); +} + +export function useActivePrCount() { + return useQuery({ + queryKey: ["active-pr-count"], + queryFn: async () => { + const prs = await getTasksWithActivePr(); + return prs.length; + }, + refetchInterval: 60_000, + }); +} diff --git a/src/core/api/useScheduler.ts b/src/core/api/useScheduler.ts index 2930fad..06f20f4 100644 --- a/src/core/api/useScheduler.ts +++ b/src/core/api/useScheduler.ts @@ -17,6 +17,7 @@ import { import { shouldWorkNow, isScanDue } from "@core/services/scheduler"; import { generateBranchName, effectiveBaseBranch } from "@core/utils/branch"; import { getProjectOverrides } from "@core/db/settings"; +import { parseOwnerRepo } from "@core/services/github"; import { sendNotification, playSound, @@ -255,13 +256,21 @@ async function runSchedulerTick( } } + const prMeta = prUrl ? parseOwnerRepo(prUrl) : undefined; + await dbUpdateTask(nextTask.id, { - state: prUrl ? "done" : "review", + state: "review", baseBranch, branchName: result.branchName, commitSha: result.commitSha, completedAt: new Date().toISOString(), ...(prUrl ? { prUrl } : {}), + ...(prMeta + ? { + prState: "opened" as const, + prNumber: prMeta.number, + } + : {}), }); if (settings.notificationsEnabled) { diff --git a/src/core/db/pr-lifecycle.ts b/src/core/db/pr-lifecycle.ts new file mode 100644 index 0000000..6ee835d --- /dev/null +++ b/src/core/db/pr-lifecycle.ts @@ -0,0 +1,306 @@ +/** + * Database module for PR lifecycle management. + * Handles pr_reviews and pr_comments tables. + */ + +import Database from "@tauri-apps/plugin-sql"; +import { invoke } from "@tauri-apps/api/core"; +import { config } from "@core/config"; +import type { PrReview, PrComment } from "@core/types/task"; + +async function getDb() { + return await Database.load(config.dbUrl); +} + +function ensureUtc(ts: string): string { + if (ts.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(ts)) return ts; + return ts.replace(" ", "T") + "Z"; +} + +// ── PR Reviews ────────────────────────────────────────────── + +interface PrReviewRow { + id: string; + task_id: string; + github_review_id: number; + reviewer: string; + state: string; + body: string | null; + submitted_at: string; + created_at: string; +} + +function rowToReview(row: PrReviewRow): PrReview { + return { + id: row.id, + taskId: row.task_id, + githubReviewId: row.github_review_id, + reviewer: row.reviewer, + state: row.state as PrReview["state"], + body: row.body ?? undefined, + submittedAt: ensureUtc(row.submitted_at), + createdAt: ensureUtc(row.created_at), + }; +} + +export async function upsertReview( + taskId: string, + review: { + githubReviewId: number; + reviewer: string; + state: string; + body?: string; + submittedAt: string; + }, +): Promise { + const db = await getDb(); + + // Check if already exists + const existing = await db.select( + "SELECT * FROM pr_reviews WHERE github_review_id = $1", + [review.githubReviewId], + ); + + if (existing.length > 0) { + return rowToReview(existing[0]); + } + + const id = await invoke("generate_task_id"); + await db.execute( + `INSERT INTO pr_reviews (id, task_id, github_review_id, reviewer, state, body, submitted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + taskId, + review.githubReviewId, + review.reviewer, + review.state.toLowerCase(), + review.body ?? null, + review.submittedAt, + ], + ); + + const rows = await db.select( + "SELECT * FROM pr_reviews WHERE id = $1", + [id], + ); + return rowToReview(rows[0]); +} + +export async function listReviews(taskId: string): Promise { + const db = await getDb(); + const rows = await db.select( + "SELECT * FROM pr_reviews WHERE task_id = $1 ORDER BY submitted_at ASC", + [taskId], + ); + return rows.map(rowToReview); +} + +/** Get the latest review that requested changes */ +export async function getLatestChangesRequested( + taskId: string, +): Promise { + const db = await getDb(); + const rows = await db.select( + "SELECT * FROM pr_reviews WHERE task_id = $1 AND state = 'changes_requested' ORDER BY submitted_at DESC LIMIT 1", + [taskId], + ); + return rows[0] ? rowToReview(rows[0]) : undefined; +} + +// ── PR Comments ───────────────────────────────────────────── + +interface PrCommentRow { + id: string; + task_id: string; + github_comment_id: number; + in_reply_to_id: number | null; + reviewer: string; + body: string; + path: string | null; + line: number | null; + side: string | null; + commit_id: string | null; + classification: string | null; + our_reply: string | null; + addressed_in_commit: string | null; + created_at: string; + updated_at: string; +} + +function rowToComment(row: PrCommentRow): PrComment { + return { + id: row.id, + taskId: row.task_id, + githubCommentId: row.github_comment_id, + inReplyToId: row.in_reply_to_id ?? undefined, + reviewer: row.reviewer, + body: row.body, + path: row.path ?? undefined, + line: row.line ?? undefined, + side: (row.side as PrComment["side"]) ?? undefined, + commitId: row.commit_id ?? undefined, + classification: + (row.classification as PrComment["classification"]) ?? undefined, + ourReply: row.our_reply ?? undefined, + addressedInCommit: row.addressed_in_commit ?? undefined, + createdAt: ensureUtc(row.created_at), + updatedAt: ensureUtc(row.updated_at), + }; +} + +export async function upsertComment( + taskId: string, + comment: { + githubCommentId: number; + inReplyToId?: number; + reviewer: string; + body: string; + path?: string; + line?: number; + side?: string; + commitId?: string; + }, +): Promise { + const db = await getDb(); + + // Check if already exists — update body if so + const existing = await db.select( + "SELECT * FROM pr_comments WHERE github_comment_id = $1", + [comment.githubCommentId], + ); + + if (existing.length > 0) { + // Update body in case it changed + if (existing[0].body !== comment.body) { + await db.execute( + "UPDATE pr_comments SET body = $1, updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2", + [comment.body, comment.githubCommentId], + ); + } + const rows = await db.select( + "SELECT * FROM pr_comments WHERE github_comment_id = $1", + [comment.githubCommentId], + ); + return rowToComment(rows[0]); + } + + const id = await invoke("generate_task_id"); + await db.execute( + `INSERT INTO pr_comments (id, task_id, github_comment_id, in_reply_to_id, reviewer, body, path, line, side, commit_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + id, + taskId, + comment.githubCommentId, + comment.inReplyToId ?? null, + comment.reviewer, + comment.body, + comment.path ?? null, + comment.line ?? null, + comment.side ?? null, + comment.commitId ?? null, + ], + ); + + const rows = await db.select( + "SELECT * FROM pr_comments WHERE id = $1", + [id], + ); + return rowToComment(rows[0]); +} + +export async function listComments(taskId: string): Promise { + const db = await getDb(); + const rows = await db.select( + "SELECT * FROM pr_comments WHERE task_id = $1 ORDER BY created_at ASC", + [taskId], + ); + return rows.map(rowToComment); +} + +/** Get unaddressed actionable comments */ +export async function getUnaddressedComments( + taskId: string, +): Promise { + const db = await getDb(); + const rows = await db.select( + `SELECT * FROM pr_comments + WHERE task_id = $1 AND classification = 'actionable' AND addressed_in_commit IS NULL + ORDER BY created_at ASC`, + [taskId], + ); + return rows.map(rowToComment); +} + +export async function updateCommentClassification( + githubCommentId: number, + classification: "actionable" | "conversational" | "resolved", +): Promise { + const db = await getDb(); + await db.execute( + "UPDATE pr_comments SET classification = $1, updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2", + [classification, githubCommentId], + ); +} + +export async function markCommentAddressed( + githubCommentId: number, + commitSha: string, +): Promise { + const db = await getDb(); + await db.execute( + "UPDATE pr_comments SET addressed_in_commit = $1, classification = 'resolved', updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2", + [commitSha, githubCommentId], + ); +} + +export async function setCommentReply( + githubCommentId: number, + reply: string, +): Promise { + const db = await getDb(); + await db.execute( + "UPDATE pr_comments SET our_reply = $1, updated_at = CURRENT_TIMESTAMP WHERE github_comment_id = $2", + [reply, githubCommentId], + ); +} + +/** Get all tasks that have an active PR lifecycle (for polling) */ +export async function getTasksWithActivePr(): Promise< + { + id: string; + repositoryId: string; + prUrl: string; + prNumber: number; + prState: string; + prReviewCycles: number; + }[] +> { + const db = await getDb(); + const rows = await db.select< + { + id: string; + repository_id: string; + pr_url: string; + pr_number: number; + pr_state: string; + pr_review_cycles: number; + }[] + >( + `SELECT id, repository_id, pr_url, pr_number, pr_state, pr_review_cycles + FROM tasks + WHERE pr_state IS NOT NULL + AND pr_state NOT IN ('merged', 'needs_human_attention') + AND pr_url IS NOT NULL + AND pr_number IS NOT NULL`, + ); + return rows.map((r) => ({ + id: r.id, + repositoryId: r.repository_id, + prUrl: r.pr_url, + prNumber: r.pr_number, + prState: r.pr_state, + prReviewCycles: r.pr_review_cycles, + })); +} diff --git a/src/core/db/settings.ts b/src/core/db/settings.ts index ab82ce6..ccb8ed7 100644 --- a/src/core/db/settings.ts +++ b/src/core/db/settings.ts @@ -50,6 +50,8 @@ const KEY_MAP: Record = { show_budget_in_sidebar: "showBudgetInSidebar", linear_api_key: "linearApiKey", linear_enabled: "linearEnabled", + pr_lifecycle_enabled: "prLifecycleEnabled", + max_review_cycles: "maxReviewCycles", }; const REVERSE_KEY_MAP: Record = Object.fromEntries( @@ -63,6 +65,7 @@ const BOOLEAN_KEYS = new Set([ "deleteBranchOnDismiss", "showBudgetInSidebar", "linearEnabled", + "prLifecycleEnabled", ]); function parseValue(camelKey: string, raw: string): unknown { @@ -70,6 +73,7 @@ function parseValue(camelKey: string, raw: string): unknown { if (camelKey === "scheduleDays") return raw ? (raw.split(",") as ScheduleDay[]) : []; if (camelKey === "budgetCeilingPercent") return parseInt(raw, 10); + if (camelKey === "maxReviewCycles") return parseInt(raw, 10); return raw; } @@ -102,6 +106,8 @@ const DEFAULTS: GlobalSettings = { showBudgetInSidebar: true, linearApiKey: "", linearEnabled: false, + prLifecycleEnabled: true, + maxReviewCycles: 5, }; export async function getGlobalSettings(): Promise { diff --git a/src/core/db/tasks.ts b/src/core/db/tasks.ts index 36531b2..775b5f6 100644 --- a/src/core/db/tasks.ts +++ b/src/core/db/tasks.ts @@ -7,6 +7,7 @@ import type { TaskState, TaskSource, EstimatedEffort, + PrState, TaskEvent, TaskMessage, MessageRole, @@ -34,6 +35,9 @@ interface TaskRow { linear_issue_id: string | null; linear_identifier: string | null; linear_url: string | null; + pr_state: string | null; + pr_number: number | null; + pr_review_cycles: number | null; tokens_used: number | null; retry_count: number | null; last_error: string | null; @@ -110,6 +114,9 @@ function rowToTask(row: TaskRow): Task { linearIssueId: row.linear_issue_id ?? undefined, linearIdentifier: row.linear_identifier ?? undefined, linearUrl: row.linear_url ?? undefined, + prState: (row.pr_state as PrState) ?? undefined, + prNumber: row.pr_number ?? undefined, + prReviewCycles: row.pr_review_cycles ?? 0, tokensUsed: row.tokens_used ?? 0, retryCount: row.retry_count ?? 0, lastError: row.last_error ?? undefined, @@ -233,6 +240,7 @@ const fieldEventTypes: Record = { notes: "notes_change", prUrl: "pr_url_change", category: "category_change", + prState: "pr_state_change", }; // Field name to DB column mapping @@ -243,6 +251,7 @@ const fieldToColumn: Record = { notes: "notes", prUrl: "pr_url", category: "category", + prState: "pr_state", }; export async function updateTask( @@ -265,6 +274,9 @@ export async function updateTask( | "startedAt" | "completedAt" | "filesInvolved" + | "prState" + | "prNumber" + | "prReviewCycles" > >, ): Promise { @@ -341,6 +353,18 @@ export async function updateTask( setClauses.push(`files_involved = $${paramIndex++}`); values.push(JSON.stringify(fields.filesInvolved)); } + if (fields.prState !== undefined) { + setClauses.push(`pr_state = $${paramIndex++}`); + values.push(fields.prState); + } + if (fields.prNumber !== undefined) { + setClauses.push(`pr_number = $${paramIndex++}`); + values.push(fields.prNumber); + } + if (fields.prReviewCycles !== undefined) { + setClauses.push(`pr_review_cycles = $${paramIndex++}`); + values.push(fields.prReviewCycles); + } setClauses.push(`updated_at = CURRENT_TIMESTAMP`); diff --git a/src/core/services/github.ts b/src/core/services/github.ts new file mode 100644 index 0000000..366320e --- /dev/null +++ b/src/core/services/github.ts @@ -0,0 +1,192 @@ +/** + * GitHub API helpers for PR lifecycle management. + * Uses the `gh` CLI via Tauri's invoke to interact with GitHub, + * which inherits the user's auth — no separate token needed. + */ + +import { invoke } from "@tauri-apps/api/core"; + +// ── Types ─────────────────────────────────────────────────── + +export interface GhPrReview { + id: number; + user: { login: string }; + state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED"; + body: string; + submitted_at: string; +} + +export interface GhPrComment { + id: number; + user: { login: string }; + body: string; + path: string | null; + line: number | null; + side: "LEFT" | "RIGHT" | null; + original_line: number | null; + commit_id: string | null; + in_reply_to_id: number | null; + created_at: string; + updated_at: string; +} + +export interface GhPrStatus { + state: "open" | "closed" | "merged"; + merged: boolean; + mergeable: string | null; + reviewDecision: string | null; +} + +// ── Helpers ───────────────────────────────────────────────── + +/** Parse owner/repo from a GitHub PR URL */ +export function parseOwnerRepo( + prUrl: string, +): { owner: string; repo: string; number: number } | undefined { + const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + if (!match) return undefined; + return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }; +} + +/** Run a gh api command and return parsed JSON */ +async function ghApi(repoPath: string, endpoint: string): Promise { + const result = await invoke<{ + success: boolean; + stdout: string; + stderr: string; + }>("run_gh_api", { repoPath, endpoint }); + if (!result.success) { + throw new Error(`gh api failed: ${result.stderr}`); + } + return JSON.parse(result.stdout) as T; +} + +/** Run a gh api command with POST body */ +async function ghApiPost( + repoPath: string, + endpoint: string, + body: Record, +): Promise { + const result = await invoke<{ + success: boolean; + stdout: string; + stderr: string; + }>("run_gh_api_post", { repoPath, endpoint, body: JSON.stringify(body) }); + if (!result.success) { + throw new Error(`gh api POST failed: ${result.stderr}`); + } + return JSON.parse(result.stdout) as T; +} + +// ── PR Status ─────────────────────────────────────────────── + +export async function getPrStatus( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + return ghApi( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}`, + ); +} + +// ── Reviews ───────────────────────────────────────────────── + +export async function listPrReviews( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + return ghApi( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}/reviews`, + ); +} + +// ── Review Comments ───────────────────────────────────────── + +export async function listPrComments( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + return ghApi( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}/comments`, + ); +} + +// ── Post Reply ────────────────────────────────────────────── + +export async function replyToComment( + repoPath: string, + owner: string, + repo: string, + prNumber: number, + commentId: number, + body: string, +): Promise { + return ghApiPost( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}/comments/${commentId}/replies`, + { body }, + ); +} + +/** Post a top-level issue comment on the PR */ +export async function postPrComment( + repoPath: string, + owner: string, + repo: string, + prNumber: number, + body: string, +): Promise<{ id: number }> { + return ghApiPost<{ id: number }>( + repoPath, + `repos/${owner}/${repo}/issues/${prNumber}/comments`, + { body }, + ); +} + +// ── Re-request Review ─────────────────────────────────────── + +export async function requestReview( + repoPath: string, + owner: string, + repo: string, + prNumber: number, + reviewers: string[], +): Promise { + await ghApiPost( + repoPath, + `repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, + { reviewers }, + ); +} + +// ── Get diff of the PR ────────────────────────────────────── + +export async function getPrDiff( + repoPath: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + const result = await invoke<{ + success: boolean; + stdout: string; + stderr: string; + }>("run_gh_api", { + repoPath, + endpoint: `repos/${owner}/${repo}/pulls/${prNumber}`, + accept: "application/vnd.github.v3.diff", + }); + if (!result.success) { + throw new Error(`Failed to get PR diff: ${result.stderr}`); + } + return result.stdout; +} diff --git a/src/core/services/pr-lifecycle.ts b/src/core/services/pr-lifecycle.ts new file mode 100644 index 0000000..042f36d --- /dev/null +++ b/src/core/services/pr-lifecycle.ts @@ -0,0 +1,566 @@ +/** + * PR Lifecycle Management Service + * + * Orchestrates the full lifecycle of PRs opened by SUSTN: + * opened → in_review → changes_requested → addressing → re_review_requested → approved → merged + * + * When new review comments arrive, sends ALL of them to Claude in a single + * resumed session (preserving the original reasoning context). Claude handles + * classification, code changes, and reply drafting in one pass. + */ + +import { invoke } from "@tauri-apps/api/core"; +import { updateTask, getTask } from "@core/db/tasks"; +import { + getTasksWithActivePr, + upsertReview, + upsertComment, + listComments, + markCommentAddressed, + setCommentReply, +} from "@core/db/pr-lifecycle"; +import { + parseOwnerRepo, + listPrReviews, + listPrComments, + getPrStatus, + replyToComment, + requestReview, +} from "@core/services/github"; +import { listRepositories } from "@core/db/repositories"; +import { getGlobalSettings } from "@core/db/settings"; +import type { WorkResult } from "@core/types/agent"; +import type { PrState, Task } from "@core/types/task"; +import type { GhPrComment, GhPrReview } from "@core/services/github"; + +// ── Claude Response Parsing ───────────────────────────────── + +interface ClaudeReviewReply { + comment_id: number; + reply: string; + made_code_changes: boolean; +} + +// ── Core Lifecycle ────────────────────────────────────────── + +/** + * Process a single task's PR lifecycle. + * Called by the polling loop for each task with an active PR. + */ +export async function processTaskPr( + task: Task, + repoPath: string, + maxReviewCycles: number, +): Promise { + if (!task.prUrl || !task.prNumber) return; + + const parsed = parseOwnerRepo(task.prUrl); + if (!parsed) return; + const { owner, repo, number: prNumber } = parsed; + + console.log( + `[pr-lifecycle] processing ${task.prUrl} — current state: ${task.prState}`, + ); + + // 1. Check PR status (merged? closed?) + console.log( + `[pr-lifecycle] fetching PR status: ${owner}/${repo}#${prNumber}`, + ); + let prStatus; + try { + prStatus = await getPrStatus(repoPath, owner, repo, prNumber); + console.log(`[pr-lifecycle] PR status:`, { + state: prStatus.state, + merged: prStatus.merged, + reviewDecision: prStatus.reviewDecision, + }); + } catch (e) { + console.error(`[pr-lifecycle] failed to get PR status:`, e); + return; + } + + if (prStatus.merged) { + console.log(`[pr-lifecycle] PR #${prNumber} merged!`); + await updateTask(task.id, { + prState: "merged" as PrState, + state: "done", + completedAt: new Date().toISOString(), + }); + await recordPrEvent(task.id, "pr_merged", `PR #${prNumber} merged`); + return; + } + + if (prStatus.state === "closed") { + console.log(`[pr-lifecycle] PR #${prNumber} closed without merge`); + await updateTask(task.id, { + prState: "merged" as PrState, + state: "done", + completedAt: new Date().toISOString(), + }); + return; + } + + // 2. Fetch reviews + console.log(`[pr-lifecycle] fetching reviews for PR #${prNumber}`); + let reviews: GhPrReview[]; + try { + reviews = await listPrReviews(repoPath, owner, repo, prNumber); + console.log( + `[pr-lifecycle] found ${reviews.length} review(s):`, + reviews.map((r) => ({ + reviewer: r.user.login, + state: r.state, + submitted: r.submitted_at, + })), + ); + } catch (e) { + console.error(`[pr-lifecycle] failed to fetch reviews:`, e); + return; + } + + // 3. Sync reviews to DB + for (const review of reviews) { + await upsertReview(task.id, { + githubReviewId: review.id, + reviewer: review.user.login, + state: review.state, + body: review.body || undefined, + submittedAt: review.submitted_at, + }); + } + + const latestReview = reviews + .filter((r) => r.state !== "COMMENTED") + .sort( + (a, b) => + new Date(b.submitted_at).getTime() - + new Date(a.submitted_at).getTime(), + )[0]; + + // 4. Always sync comments regardless of review state + console.log(`[pr-lifecycle] fetching comments for PR #${prNumber}`); + let ghComments: GhPrComment[]; + try { + ghComments = await listPrComments(repoPath, owner, repo, prNumber); + } catch (e) { + console.error(`[pr-lifecycle] failed to fetch comments:`, e); + ghComments = []; + } + + // Filter out our own replies + const existingDbComments = await listComments(task.id); + const ourReplyBodies = new Set( + existingDbComments + .filter((c) => c.ourReply) + .map((c) => c.ourReply!.trim()), + ); + + const externalComments = ghComments.filter((c) => { + if (c.in_reply_to_id && ourReplyBodies.has(c.body.trim())) { + console.log( + `[pr-lifecycle] skipping our own reply (comment ${c.id})`, + ); + return false; + } + return true; + }); + + console.log( + `[pr-lifecycle] PR #${prNumber} — ${ghComments.length} total comment(s), ${externalComments.length} external`, + ); + + // Sync external comments to DB + for (const comment of externalComments) { + await upsertComment(task.id, { + githubCommentId: comment.id, + inReplyToId: comment.in_reply_to_id ?? undefined, + reviewer: comment.user.login, + body: comment.body, + path: comment.path ?? undefined, + line: comment.line ?? comment.original_line ?? undefined, + side: comment.side ?? undefined, + commitId: comment.commit_id ?? undefined, + }); + } + + // 5. Determine which comments need processing + const refreshedDbComments = await listComments(task.id); + const unprocessedComments = refreshedDbComments.filter( + (c) => !c.ourReply && !c.addressedInCommit && !c.inReplyToId, + ); + + if (task.prState === "opened") { + await updateTask(task.id, { prState: "in_review" as PrState }); + } + + // 6. Handle approved + if (latestReview?.state === "APPROVED") { + console.log( + `[pr-lifecycle] PR #${prNumber} approved by @${latestReview.user.login}`, + ); + await updateTask(task.id, { + prState: "approved" as PrState, + state: "done", + completedAt: new Date().toISOString(), + }); + await recordPrEvent( + task.id, + "pr_approved", + `@${latestReview.user.login} approved the PR`, + ); + return; + } + + // 7. Handle changes requested — update cycle count + if (latestReview?.state === "CHANGES_REQUESTED") { + if ( + task.prState === "addressing" || + task.prState === "re_review_requested" + ) { + const latestDbTime = + refreshedDbComments.length > 0 + ? Math.max( + ...refreshedDbComments.map((c) => + new Date(c.createdAt).getTime(), + ), + ) + : 0; + const reviewTime = new Date(latestReview.submitted_at).getTime(); + if (reviewTime <= latestDbTime) return; + } + + if (task.prReviewCycles >= maxReviewCycles) { + console.log( + `[pr-lifecycle] PR #${prNumber} — max review cycles (${maxReviewCycles}) reached`, + ); + await updateTask(task.id, { + prState: "needs_human_attention" as PrState, + lastError: `Reviewer requested changes ${task.prReviewCycles} times`, + }); + return; + } + + await updateTask(task.id, { + prState: "changes_requested" as PrState, + prReviewCycles: task.prReviewCycles + 1, + }); + await recordPrEvent( + task.id, + "pr_review_received", + `@${latestReview.user.login} requested changes (cycle ${task.prReviewCycles + 1})`, + ); + } + + // 8. If no unprocessed comments, nothing for the agent to do + if (unprocessedComments.length === 0) { + console.log(`[pr-lifecycle] PR #${prNumber} — no unprocessed comments`); + return; + } + + // 9. Send ALL unprocessed comments to Claude in one session. + // Resume the original session so Claude has its reasoning context. + // Claude decides for each comment: fix code, explain reasoning, or thank. + console.log( + `[pr-lifecycle] PR #${prNumber} — ${unprocessedComments.length} comment(s) → sending to Claude${task.sessionId ? " (resuming session)" : " (fresh session)"}`, + ); + await updateTask(task.id, { prState: "addressing" as PrState }); + + const reviewContext = unprocessedComments + .map((c) => { + const location = c.path + ? `File: ${c.path}${c.line ? `:${c.line}` : ""}` + : "General"; + return `[COMMENT_ID: ${c.githubCommentId}] [${location}] @${c.reviewer}:\n${c.body}`; + }) + .join("\n\n---\n\n"); + + try { + const result = await invoke("engine_address_review", { + taskId: task.id, + repositoryId: task.repositoryId, + repoPath, + branchName: task.branchName, + baseBranch: task.baseBranch ?? "main", + reviewComments: reviewContext, + prDescription: task.description ?? task.title, + resumeSessionId: task.sessionId ?? undefined, + }); + + if (result.success) { + console.log( + `[pr-lifecycle] engine_address_review succeeded — summary length=${result.summary?.length ?? 0}, commitSha=${result.commitSha}, sessionId=${result.sessionId}`, + ); + + // Push any code changes + const pushResult = await invoke<{ + success: boolean; + error?: string; + }>("engine_push_branch", { + repoPath, + branchName: task.branchName, + }); + + console.log( + `[pr-lifecycle] push result: success=${pushResult.success}, error=${pushResult.error}`, + ); + + // Parse Claude's structured output for per-comment replies + let replies: ClaudeReviewReply[] = []; + try { + const jsonMatch = result.summary?.match( + /\{[\s\S]*"replies"[\s\S]*\}/, + ); + console.log( + `[pr-lifecycle] JSON match found: ${!!jsonMatch}, length=${jsonMatch?.[0]?.length ?? 0}`, + ); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) as { + replies?: ClaudeReviewReply[]; + }; + replies = parsed.replies ?? []; + } + } catch (parseErr) { + console.error( + `[pr-lifecycle] could not parse structured replies:`, + parseErr, + `summary preview: ${result.summary?.slice(0, 200)}`, + ); + } + + // Fix null comment_ids — if counts match, zip them + for (let i = 0; i < replies.length; i++) { + if (!replies[i].comment_id && i < unprocessedComments.length) { + replies[i].comment_id = + unprocessedComments[i].githubCommentId; + console.log( + `[pr-lifecycle] fixed null comment_id → ${replies[i].comment_id} (positional match)`, + ); + } + } + + console.log( + `[pr-lifecycle] Claude returned ${replies.length} reply(ies), push=${pushResult.success}`, + replies.map((r) => ({ + id: r.comment_id, + code: r.made_code_changes, + reply: r.reply?.slice(0, 60), + })), + ); + + // Post replies to GitHub and update DB + for (const r of replies) { + if (!r.reply || !r.comment_id) continue; + try { + await replyToComment( + repoPath, + owner, + repo, + prNumber, + r.comment_id, + r.reply, + ); + await setCommentReply(r.comment_id, r.reply); + + if (r.made_code_changes && result.commitSha) { + await markCommentAddressed( + r.comment_id, + result.commitSha, + ); + } + + console.log( + `[pr-lifecycle] replied to comment ${r.comment_id} (code_changes=${r.made_code_changes})`, + ); + } catch (e) { + console.error( + `[pr-lifecycle] failed to post reply for comment ${r.comment_id}:`, + e, + ); + } + } + + // For any comments Claude didn't return a reply for, mark as addressed if code changed + if (pushResult.success && result.commitSha) { + for (const c of unprocessedComments) { + const hasReply = replies.some( + (r) => r.comment_id === c.githubCommentId, + ); + if (!hasReply) { + await markCommentAddressed( + c.githubCommentId, + result.commitSha, + ); + } + } + } + + // Per-comment replies already explain what was done — no need + // for a separate summary comment on the PR thread. + + // Re-request review + const reviewer = latestReview?.user.login; + if (reviewer) { + try { + await requestReview(repoPath, owner, repo, prNumber, [ + reviewer, + ]); + } catch { + // Not critical + } + } + + console.log( + `[pr-lifecycle] all replies posted, updating state to re_review_requested`, + ); + await updateTask(task.id, { + prState: "re_review_requested" as PrState, + commitSha: result.commitSha ?? task.commitSha, + sessionId: result.sessionId ?? task.sessionId, + }); + await recordPrEvent( + task.id, + "pr_comment_addressed", + `Agent processed ${unprocessedComments.length} comment(s)${result.commitSha ? ` — commit ${result.commitSha.slice(0, 7)}` : ""}`, + ); + } else { + console.error( + `[pr-lifecycle] address review failed:`, + result.error, + ); + await updateTask(task.id, { + prState: "in_review" as PrState, + lastError: result.error ?? "Failed to address review", + }); + } + } catch (e) { + console.error(`[pr-lifecycle] address review error:`, e); + // Always transition out of "addressing" even on error + try { + await updateTask(task.id, { + prState: "in_review" as PrState, + lastError: e instanceof Error ? e.message : String(e), + }); + } catch (updateErr) { + console.error( + `[pr-lifecycle] CRITICAL: failed to update task state after error:`, + updateErr, + ); + } + } +} + +// ── PR Event Recording ────────────────────────────────────── + +async function recordPrEvent( + taskId: string, + _eventType: string, + comment: string, +): Promise { + try { + const { addComment } = await import("@core/db/tasks"); + await addComment(taskId, `[PR] ${comment}`); + } catch (e) { + console.error(`[pr-lifecycle] failed to record event:`, e); + } +} + +// ── Polling Loop ──────────────────────────────────────────── + +/** + * Single tick of the PR lifecycle poller. + * Called from usePrLifecyclePoller hook. + */ +export async function prLifecycleTick(): Promise { + const settings = await getGlobalSettings(); + if (!settings.prLifecycleEnabled) { + console.log("[pr-lifecycle] tick — disabled in settings, skipping"); + return; + } + + console.log("[pr-lifecycle] tick — checking for active PRs..."); + + // Backfill tasks with pr_url but no pr_state + await backfillPrState(); + + const activePrs = await getTasksWithActivePr(); + console.log( + `[pr-lifecycle] tick — found ${activePrs.length} active PR(s)`, + activePrs.map((p) => ({ + id: p.id.slice(0, 8), + prState: p.prState, + prNumber: p.prNumber, + })), + ); + if (activePrs.length === 0) return; + + const repos = await listRepositories(); + const repoMap = new Map(repos.map((r) => [r.id, r])); + const maxCycles = settings.maxReviewCycles ?? 5; + + for (const pr of activePrs) { + const repo = repoMap.get(pr.repositoryId); + if (!repo) { + console.log( + `[pr-lifecycle] skipping PR ${pr.prNumber} — repo not found`, + ); + continue; + } + + const task = await getTask(pr.id); + if (!task) { + console.log( + `[pr-lifecycle] skipping PR ${pr.prNumber} — task not found`, + ); + continue; + } + + console.log( + `[pr-lifecycle] processing PR #${pr.prNumber} (${task.title}) — prState=${task.prState}, repo=${repo.name}`, + ); + + try { + await processTaskPr(task, repo.path, maxCycles); + } catch (e) { + console.error(`[pr-lifecycle] error processing ${pr.prUrl}:`, e); + } + } + + console.log("[pr-lifecycle] tick — done"); +} + +/** + * Backfill pr_state and pr_number for tasks that have a pr_url + * but were created before the lifecycle feature was wired in. + */ +async function backfillPrState(): Promise { + try { + const { default: Database } = await import("@tauri-apps/plugin-sql"); + const { config } = await import("@core/config"); + const db = await Database.load(config.dbUrl); + const rows = await db.select<{ id: string; pr_url: string }[]>( + `SELECT id, pr_url FROM tasks + WHERE pr_url IS NOT NULL + AND pr_url != '' + AND pr_state IS NULL`, + ); + if (rows.length === 0) return; + + console.log( + `[pr-lifecycle] backfilling ${rows.length} task(s) with pr_url but no pr_state`, + ); + + for (const row of rows) { + const parsed = parseOwnerRepo(row.pr_url); + if (!parsed) continue; + await updateTask(row.id, { + prState: "opened" as PrState, + prNumber: parsed.number, + }); + console.log( + `[pr-lifecycle] backfilled task ${row.id.slice(0, 8)} — PR #${parsed.number}`, + ); + } + } catch (e) { + console.error("[pr-lifecycle] backfill failed:", e); + } +} diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 427993e..309bbc9 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -34,6 +34,10 @@ export interface GlobalSettings { // Integrations linearApiKey: string; linearEnabled: boolean; + + // PR Lifecycle + prLifecycleEnabled: boolean; + maxReviewCycles: number; } export interface ProjectOverrides { diff --git a/src/core/types/task.ts b/src/core/types/task.ts index 2ef6b08..25d5358 100644 --- a/src/core/types/task.ts +++ b/src/core/types/task.ts @@ -20,6 +20,16 @@ export type TaskState = export type TaskSource = "manual" | "scan" | "linear"; export type EstimatedEffort = "low" | "medium" | "high"; +export type PrState = + | "opened" + | "in_review" + | "changes_requested" + | "addressing" + | "re_review_requested" + | "approved" + | "merged" + | "needs_human_attention"; + export interface Task { id: string; repositoryId: string; @@ -47,6 +57,38 @@ export interface Task { linearIssueId: string | undefined; linearIdentifier: string | undefined; linearUrl: string | undefined; + prState: PrState | undefined; + prNumber: number | undefined; + prReviewCycles: number; + createdAt: string; + updatedAt: string; +} + +export interface PrReview { + id: string; + taskId: string; + githubReviewId: number; + reviewer: string; + state: "approved" | "changes_requested" | "commented" | "dismissed"; + body: string | undefined; + submittedAt: string; + createdAt: string; +} + +export interface PrComment { + id: string; + taskId: string; + githubCommentId: number; + inReplyToId: number | undefined; + reviewer: string; + body: string; + path: string | undefined; + line: number | undefined; + side: "LEFT" | "RIGHT" | undefined; + commitId: string | undefined; + classification: "actionable" | "conversational" | "resolved" | undefined; + ourReply: string | undefined; + addressedInCommit: string | undefined; createdAt: string; updatedAt: string; } diff --git a/src/ui/components/ErrorBoundary.tsx b/src/ui/components/ErrorBoundary.tsx new file mode 100644 index 0000000..c506297 --- /dev/null +++ b/src/ui/components/ErrorBoundary.tsx @@ -0,0 +1,144 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { Button } from "@ui/components/ui/button"; + +// ── Types ─────────────────────────────────────────────────── + +interface ErrorBoundaryProps { + children: ReactNode; + /** Fallback UI level — controls how much chrome is shown */ + level?: "root" | "route" | "widget"; + /** Override the heading shown in the fallback */ + heading?: string; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | undefined; +} + +// ── Component ─────────────────────────────────────────────── + +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: undefined }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error( + "[ErrorBoundary] Uncaught error:", + error, + info.componentStack, + ); + } + + private handleReset = () => { + this.setState({ hasError: false, error: undefined }); + }; + + private handleReload = () => { + window.location.reload(); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + const level = this.props.level ?? "widget"; + + if (level === "root") { + return ( +
+
+
+ +
+

+ {this.props.heading ?? "Something went wrong"} +

+

+ An unexpected error crashed the application. Click + below to reload. +

+ {this.state.error && ( +
+                                {this.state.error.message}
+                            
+ )} + +
+
+ ); + } + + if (level === "route") { + return ( +
+
+
+ +
+

+ {this.props.heading ?? "This view crashed"} +

+

+ An error occurred while rendering this section. +

+ {this.state.error && ( +
+                                {this.state.error.message}
+                            
+ )} + +
+
+ ); + } + + // Widget level — compact inline fallback + return ( +
+ +
+

+ {this.props.heading ?? "Failed to render"} +

+ {this.state.error && ( +

+ {this.state.error.message} +

+ )} +
+ +
+ ); + } +} diff --git a/src/ui/components/layout/AppShell.tsx b/src/ui/components/layout/AppShell.tsx index 4bf60ea..38513f9 100644 --- a/src/ui/components/layout/AppShell.tsx +++ b/src/ui/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { Sidebar } from "@ui/components/sidebar/Sidebar"; import { MainContent } from "@ui/components/main/MainContent"; +import { ErrorBoundary } from "@ui/components/ErrorBoundary"; import { useStartupRecovery, useStartupScan, @@ -10,6 +11,7 @@ import { import { useAuth } from "@core/api/useAuth"; import { useScheduler } from "@core/api/useScheduler"; import { useLinearAutoSync } from "@core/api/useLinear"; +import { usePrLifecyclePoller } from "@core/api/usePrLifecycle"; import { startSessionTracking } from "@core/services/session-tracker"; import { initNotificationPermission } from "@core/services/notifications"; @@ -23,6 +25,7 @@ export function AppShell() { useStartupScan(); useScheduler(); useLinearAutoSync(); + usePrLifecyclePoller(); useQueueProcessor(); useGlobalTaskNotifications(); @@ -70,15 +73,17 @@ export function AppShell() { }, []); return ( -
- -
-
- -
-
+ +
+ +
+
+ +
+
+ ); } diff --git a/src/ui/components/main/MainContent.tsx b/src/ui/components/main/MainContent.tsx index a5972fb..81b8773 100644 --- a/src/ui/components/main/MainContent.tsx +++ b/src/ui/components/main/MainContent.tsx @@ -2,6 +2,7 @@ import { useAppStore } from "@core/store/app-store"; import { EmptyState } from "./EmptyState"; import { TaskListView } from "@ui/components/tasks/TaskListView"; import { TaskDetailView } from "@ui/components/tasks/TaskDetailView"; +import { ErrorBoundary } from "@ui/components/ErrorBoundary"; export function MainContent() { const selectedRepositoryId = useAppStore((s) => s.selectedRepositoryId); @@ -12,8 +13,24 @@ export function MainContent() { } if (selectedTaskId) { - return ; + return ( + + + + ); } - return ; + return ( + + + + ); } diff --git a/src/ui/components/settings/sections/IntegrationsSection.tsx b/src/ui/components/settings/sections/IntegrationsSection.tsx index e3a056f..e4acee1 100644 --- a/src/ui/components/settings/sections/IntegrationsSection.tsx +++ b/src/ui/components/settings/sections/IntegrationsSection.tsx @@ -468,6 +468,88 @@ export function IntegrationsSection() {
)}
+ + {/* PR Lifecycle Management */} +
+
+
+ + + + + + +

+ PR Lifecycle +

+
+

+ Automatically monitor PRs opened by SUSTN, address + reviewer feedback, and re-request reviews. +

+
+ +
+ + + updateSetting({ + key: "prLifecycleEnabled", + value: checked, + }) + } + /> + + + +
+ { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= 20) { + updateSetting({ + key: "maxReviewCycles", + value: val, + }); + } + }} + /> + + cycles + +
+
+
+
); } diff --git a/src/ui/components/tasks/TaskDetailHeader.tsx b/src/ui/components/tasks/TaskDetailHeader.tsx index 77df27b..c4a7736 100644 --- a/src/ui/components/tasks/TaskDetailHeader.tsx +++ b/src/ui/components/tasks/TaskDetailHeader.tsx @@ -29,7 +29,50 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@ui/components/ui/dropdown-menu"; -import type { Task } from "@core/types/task"; +import { GitPullRequest } from "lucide-react"; +import type { Task, PrState } from "@core/types/task"; + +function prStateDisplayLabel(state: PrState): string { + switch (state) { + case "opened": + return "PR Opened"; + case "in_review": + return "In Review"; + case "changes_requested": + return "Changes Requested"; + case "addressing": + return "Addressing Feedback"; + case "re_review_requested": + return "Re-review Requested"; + case "approved": + return "Approved"; + case "merged": + return "Merged"; + case "needs_human_attention": + return "Needs Your Attention"; + default: + return state; + } +} + +function prStateChipStyle(state: PrState): string { + switch (state) { + case "opened": + case "in_review": + case "re_review_requested": + return "border-blue-400/40 bg-blue-500/10 text-blue-500"; + case "changes_requested": + return "border-amber-400/40 bg-amber-500/10 text-amber-500"; + case "addressing": + return "border-violet-400/40 bg-violet-500/10 text-violet-500"; + case "approved": + return "border-green-400/40 bg-green-500/10 text-green-500"; + case "needs_human_attention": + return "border-red-400/40 bg-red-500/10 text-red-500"; + default: + return "border-border text-muted-foreground"; + } +} interface TaskDetailHeaderProps { task: Task; @@ -214,6 +257,21 @@ export function TaskDetailHeader({ )} + {/* PR lifecycle state */} + {task.prState && task.prState !== "merged" && ( +
+ + {prStateDisplayLabel(task.prState)} + {task.prReviewCycles > 0 && ( + + ({task.prReviewCycles}) + + )} +
+ )} + {/* Primary action */} {primaryAction} diff --git a/src/ui/components/tasks/TaskDetailView.tsx b/src/ui/components/tasks/TaskDetailView.tsx index 4ca4e9b..8f28f0d 100644 --- a/src/ui/components/tasks/TaskDetailView.tsx +++ b/src/ui/components/tasks/TaskDetailView.tsx @@ -1,4 +1,5 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowUp, Play, @@ -31,6 +32,7 @@ import { } from "@core/api/useEngine"; import { useQueueStore } from "@core/store/queue-store"; import { useGlobalSettings, useProjectOverrides } from "@core/api/useSettings"; +import { usePrComments } from "@core/api/usePrLifecycle"; import { generateBranchName, effectiveBaseBranch } from "@core/utils/branch"; import { useAppStore } from "@core/store/app-store"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -47,6 +49,7 @@ import { TaskFilesInvolved } from "./TaskFilesInvolved"; import { TaskStatusBanner } from "./TaskStatusBanner"; import { queuedToast } from "@ui/lib/toast"; import { FileContentViewer } from "./FileContentViewer"; +import { ErrorBoundary } from "@ui/components/ErrorBoundary"; // ── Constants ─────────────────────────────────────────────── @@ -267,6 +270,7 @@ interface TaskDetailViewProps { export function TaskDetailView({ taskId }: TaskDetailViewProps) { const { data: task, isLoading } = useTask(taskId); const { data: repositories } = useRepositories(); + const queryClient = useQueryClient(); const updateTask = useUpdateTask(); const deleteTask = useDeleteTask(); const startTask = useStartTask(); @@ -348,6 +352,63 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { showDiff && hasBranch ? task?.branchName : undefined, ); + // PR comments from GitHub + const { data: prComments } = usePrComments( + task?.prState ? taskId : undefined, + ); + + const ghCommentsForDiff = useMemo( + () => + (prComments ?? []).map((c) => ({ + id: c.id, + githubCommentId: c.githubCommentId, + reviewer: c.reviewer, + body: c.body, + path: c.path, + line: c.line, + side: c.side, + classification: c.classification, + ourReply: c.ourReply, + addressedInCommit: c.addressedInCommit, + createdAt: c.createdAt, + })), + [prComments], + ); + + const handleReplyToGhComment = useCallback( + async (commentId: number, body: string) => { + if (!task?.prUrl || !repoPath) return; + try { + const { replyToComment, parseOwnerRepo } = + await import("@core/services/github"); + const parsed = parseOwnerRepo(task.prUrl); + if (!parsed) return; + await replyToComment( + repoPath, + parsed.owner, + parsed.repo, + parsed.number, + commentId, + body, + ); + // Update local DB + const { setCommentReply } = + await import("@core/db/pr-lifecycle"); + await setCommentReply(commentId, body); + // Invalidate to refresh + void queryClient.invalidateQueries({ + queryKey: ["pr-comments", taskId], + }); + } catch (e) { + console.error( + "[TaskDetailView] reply to GH comment failed:", + e, + ); + } + }, + [task?.prUrl, repoPath, taskId, queryClient], + ); + const isReadOnly = task?.state === "done" || task?.state === "dismissed"; const hasChanges = !!(showDiff && diffStat && diffStat.length > 0); const showSidebar = @@ -560,9 +621,20 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { }, { onSuccess: (pr) => { + const prMatch = + pr.url.match(/\/pull\/(\d+)/); + const prNumber = prMatch + ? parseInt(prMatch[1], 10) + : undefined; updateTask.mutate({ id: taskId, prUrl: pr.url, + ...(prNumber + ? { + prState: "opened" as const, + prNumber, + } + : {}), }); sendMessage.mutate({ taskId, @@ -589,7 +661,7 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { ), ); } - handleUpdateState("done"); + // Task stays in "review" — PR lifecycle drives transition to "done" }, onError: (err) => { sendMessage.mutate({ @@ -597,7 +669,7 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { role: "system", content: `Branch ${task.branchName} pushed to remote. PR creation failed: ${err instanceof Error ? err.message : String(err)}. Create it manually.`, }); - handleUpdateState("done"); + // Task stays in "review" even on PR failure }, }, ); @@ -732,18 +804,21 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { let sidebarActions: React.ReactNode = null; if (state === "review") { + const hasPr = !!task.prUrl; sidebarActions = ( <> - - {task.branchName && !task.prUrl ? ( + {!hasPr && ( + + )} + {task.branchName && !hasPr ? ( + - {task.prUrl && ( - - )} + ) : ( + )} ); @@ -874,17 +957,26 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { {isShowingDiff ? ( diffText ? (
- + + +
) : (
diff --git a/src/ui/components/tasks/TaskDiffViewer.tsx b/src/ui/components/tasks/TaskDiffViewer.tsx index 16f7b30..860cc43 100644 --- a/src/ui/components/tasks/TaskDiffViewer.tsx +++ b/src/ui/components/tasks/TaskDiffViewer.tsx @@ -2,7 +2,15 @@ import { useState, useMemo, useCallback, useRef, useEffect } from "react"; import { PatchDiff } from "@pierre/diffs/react"; import type { DiffLineAnnotation, AnnotationSide } from "@pierre/diffs/react"; import type { SelectedLineRange } from "@pierre/diffs"; -import { Columns2, Rows3, MessageSquare, X, Send } from "lucide-react"; +import { + Columns2, + Rows3, + MessageSquare, + X, + Send, + Reply, + CheckCircle, +} from "lucide-react"; import { Button } from "@ui/components/ui/button"; // ── Types ─────────────────────────────────────────────────── @@ -15,13 +23,29 @@ export interface InlineComment { fileName?: string; } +export interface GitHubPrComment { + id: string; + githubCommentId: number; + reviewer: string; + body: string; + path?: string; + line?: number; + side?: "LEFT" | "RIGHT"; + classification?: "actionable" | "conversational" | "resolved"; + ourReply?: string; + addressedInCommit?: string; + createdAt: string; +} + interface TaskDiffViewerProps { diffText: string; activeFile?: string; singleFile?: string; comments?: InlineComment[]; + ghComments?: GitHubPrComment[]; onAddComment?: (comment: Omit) => void; onRemoveComment?: (id: string) => void; + onReplyToGhComment?: (commentId: number, body: string) => void; } // ── Annotation metadata type ──────────────────────────────── @@ -29,6 +53,12 @@ interface TaskDiffViewerProps { interface CommentAnnotation { commentId: string; text: string; + isGitHub?: boolean; + reviewer?: string; + classification?: string; + ourReply?: string; + githubCommentId?: number; + addressedInCommit?: string; } // ── Inline comment form ───────────────────────────────────── @@ -132,14 +162,163 @@ function CommentBubble({ ); } +// ── GitHub PR comment ──────────────────────────────────────── + +function GitHubCommentBubble({ + reviewer, + text, + ourReply, + addressedInCommit, + classification, + onReply, +}: { + reviewer: string; + text: string; + ourReply?: string; + addressedInCommit?: string; + classification?: string; + onReply?: (body: string) => void; +}) { + const [showReply, setShowReply] = useState(false); + const [replyText, setReplyText] = useState(""); + const replyRef = useRef(null); + + useEffect(() => { + if (showReply) replyRef.current?.focus(); + }, [showReply]); + + const isResolved = !!addressedInCommit || classification === "resolved"; + + return ( +
+ {/* Reviewer header */} +
+ + {reviewer} + + {isResolved && ( + + + Resolved + + )} +
+ + {/* Comment body */} +
+

+ {text} +

+
+ + {/* Reply (inline, not a separate card) */} + {ourReply && ( +
+
+ + You + + + via SUSTN + +
+
+

+ {ourReply} +

+
+
+ )} + + {/* Reply button */} + {onReply && !ourReply && !showReply && ( +
+ +
+ )} + + {/* Reply form */} + {showReply && ( +
+