Skip to content

Commit c267ed1

Browse files
authored
Merge pull request #13 from Ghvstcode/pr-lifecycle
Add Github Integration & Syncing
2 parents 3a1248d + 680e8a5 commit c267ed1

25 files changed

Lines changed: 2566 additions & 63 deletions

src-tauri/src/engine_commands.rs

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,273 @@ pub struct AugmentTaskResult {
550550
pub category: String,
551551
}
552552

553+
/// Run a `gh api` GET request and return the raw JSON output.
554+
#[tauri::command]
555+
pub async fn run_gh_api(
556+
repo_path: String,
557+
endpoint: String,
558+
accept: Option<String>,
559+
) -> Result<GhApiResult, String> {
560+
let gh = crate::preflight::resolve_gh_binary_pub();
561+
562+
let mut cmd = std::process::Command::new(&gh);
563+
cmd.args(["api", &endpoint]);
564+
if let Some(accept_header) = accept {
565+
cmd.args(["-H", &format!("Accept: {accept_header}")]);
566+
}
567+
cmd.current_dir(&repo_path);
568+
569+
let output = cmd
570+
.output()
571+
.map_err(|e| format!("Failed to execute gh: {e}"))?;
572+
573+
Ok(GhApiResult {
574+
success: output.status.success(),
575+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
576+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
577+
})
578+
}
579+
580+
/// Run a `gh api` POST request with a JSON body.
581+
#[tauri::command]
582+
pub async fn run_gh_api_post(
583+
repo_path: String,
584+
endpoint: String,
585+
body: String,
586+
) -> Result<GhApiResult, String> {
587+
let gh = crate::preflight::resolve_gh_binary_pub();
588+
589+
let output = std::process::Command::new(&gh)
590+
.args(["api", &endpoint, "--method", "POST", "--input", "-"])
591+
.current_dir(&repo_path)
592+
.stdin(std::process::Stdio::piped())
593+
.stdout(std::process::Stdio::piped())
594+
.stderr(std::process::Stdio::piped())
595+
.spawn()
596+
.and_then(|mut child| {
597+
use std::io::Write;
598+
if let Some(ref mut stdin) = child.stdin {
599+
stdin.write_all(body.as_bytes())?;
600+
}
601+
child.wait_with_output()
602+
})
603+
.map_err(|e| format!("Failed to execute gh: {e}"))?;
604+
605+
Ok(GhApiResult {
606+
success: output.status.success(),
607+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
608+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
609+
})
610+
}
611+
612+
#[derive(Debug, Serialize, Deserialize)]
613+
#[serde(rename_all = "camelCase")]
614+
pub struct GhApiResult {
615+
pub success: bool,
616+
pub stdout: String,
617+
pub stderr: String,
618+
}
619+
620+
/// Address PR review comments by invoking Claude CLI with the review context.
621+
/// Resumes the original session when available so Claude has full reasoning context.
622+
#[tauri::command]
623+
pub async fn engine_address_review(
624+
app: AppHandle,
625+
state: State<'_, Arc<EngineState>>,
626+
task_id: String,
627+
repository_id: String,
628+
repo_path: String,
629+
branch_name: String,
630+
base_branch: String,
631+
review_comments: String,
632+
pr_description: String,
633+
resume_session_id: Option<String>,
634+
) -> Result<worker::WorkResult, String> {
635+
println!(
636+
"[engine_address_review] task_id={task_id}, branch={branch_name}, comments_len={}, resume={:?}",
637+
review_comments.len(),
638+
resume_session_id,
639+
);
640+
641+
// Wait for deep scan
642+
{
643+
loop {
644+
let is_scanning = state.deep_scanning_repos.lock().await.contains(&repository_id);
645+
if !is_scanning { break; }
646+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
647+
}
648+
}
649+
650+
if let Some(issue) = engine::git::preflight_check(&repo_path) {
651+
return Err(format!("Environment issue: {}", issue.error));
652+
}
653+
654+
// Budget check
655+
{
656+
let app_data_dir = app.path().app_data_dir()
657+
.map_err(|e| format!("Failed to get app data dir: {e}"))?;
658+
let mut config = db::read_budget_config(&app_data_dir);
659+
let effective_ceiling = db::read_effective_ceiling_percent(&app_data_dir, &repository_id);
660+
if effective_ceiling < config.max_usage_percent {
661+
config.max_usage_percent = effective_ceiling;
662+
}
663+
let status = budget::calculate_budget_status(&config);
664+
if status.budget_exhausted {
665+
return Err("Budget exhausted — cannot address review".to_string());
666+
}
667+
}
668+
669+
{
670+
let current = state.current_task.lock().await;
671+
if current.is_some() {
672+
return Err("Another task is already in progress".to_string());
673+
}
674+
}
675+
676+
{
677+
let mut current = state.current_task.lock().await;
678+
*current = Some(CurrentTask {
679+
task_id: task_id.clone(),
680+
repository_id: repository_id.clone(),
681+
phase: TaskPhase::Implementing,
682+
started_at: chrono::Local::now().to_rfc3339(),
683+
});
684+
}
685+
686+
let _ = app.emit("agent:task-started", serde_json::json!({
687+
"taskId": task_id,
688+
"repositoryId": repository_id,
689+
}));
690+
691+
let (agent_prefs, _) = {
692+
let app_data_dir = app.path().app_data_dir()
693+
.map_err(|e| format!("Failed to get app data dir: {e}"))?;
694+
db::read_project_preferences(&app_data_dir, &repository_id)
695+
};
696+
697+
let prefs_section = match agent_prefs.as_deref() {
698+
Some(prefs) if !prefs.trim().is_empty() => format!("\n\n## Project-Specific Instructions\n{prefs}"),
699+
_ => String::new(),
700+
};
701+
702+
let prompt = format!(
703+
r#"IMPORTANT: You are running as an automated background agent in non-interactive mode. Commit your changes directly — do NOT ask for permission.
704+
705+
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.
706+
707+
## PR Description
708+
{pr_description}
709+
710+
## Review Comments
711+
Each comment below has a COMMENT_ID number that you MUST include in your response.
712+
713+
{review_comments}
714+
{prefs_section}
715+
716+
## Instructions
717+
For EACH review comment above:
718+
719+
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.
720+
721+
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.
722+
723+
3. **If it's praise or acknowledgment** (looks good, nice, etc.): draft a brief thanks.
724+
725+
CRITICAL: You MUST return a reply for EVERY comment. Use the exact COMMENT_ID number from each comment header above.
726+
727+
After making any code changes and committing, output ONLY this JSON (no markdown):
728+
{{
729+
"replies": [
730+
{{
731+
"comment_id": 1234567890,
732+
"reply": "Your response to this specific comment",
733+
"made_code_changes": true
734+
}}
735+
],
736+
"summary": "Brief description of what was changed",
737+
"files_modified": ["list", "of", "files"]
738+
}}
739+
740+
The comment_id MUST be the numeric ID from the [COMMENT_ID: <number>] tag in each comment above. Do NOT use null."#
741+
);
742+
743+
// Ensure we're on the right branch
744+
if engine::git::branch_exists(&repo_path, &branch_name) {
745+
engine::git::checkout_branch(&repo_path, &branch_name);
746+
} else {
747+
engine::git::create_branch_from(&repo_path, &branch_name, &base_branch);
748+
}
749+
750+
// Call Claude CLI directly with our exact prompt (not through worker,
751+
// which overrides the prompt with its own resume template)
752+
let cli_result = engine::invoke_claude_cli(
753+
&repo_path,
754+
&prompt,
755+
1800, // 30 min timeout
756+
None,
757+
None,
758+
resume_session_id.as_deref(),
759+
)
760+
.await;
761+
762+
// Get commit SHA after Claude ran
763+
let sha_result = engine::git::latest_commit_sha(&repo_path);
764+
let commit_sha = if sha_result.success { Some(sha_result.output) } else { None };
765+
let session_id = cli_result.as_ref().ok().and_then(|r| r.session_id.clone());
766+
767+
// Build result
768+
let (success, summary, error) = match &cli_result {
769+
Ok(r) if r.success => {
770+
// Extract the result text from Claude's JSON wrapper
771+
let summary = if let Ok(v) = serde_json::from_str::<serde_json::Value>(&r.stdout) {
772+
v.get("result")
773+
.and_then(|r| r.as_str())
774+
.map(|s| s.to_string())
775+
.unwrap_or_else(|| r.stdout.clone())
776+
} else {
777+
r.stdout.clone()
778+
};
779+
(true, Some(summary), None)
780+
}
781+
Ok(r) => (false, None, Some(format!("Claude CLI returned error: {}", r.stderr))),
782+
Err(e) => (false, None, Some(e.clone())),
783+
};
784+
785+
let result = worker::WorkResult {
786+
success,
787+
phase_reached: engine::TaskPhase::Implementing,
788+
branch_name: Some(branch_name.clone()),
789+
commit_sha: commit_sha.clone(),
790+
files_modified: vec![],
791+
summary: summary.clone(),
792+
review_warnings: None,
793+
error: error.clone(),
794+
session_id: session_id.clone(),
795+
};
796+
797+
{
798+
let mut current = state.current_task.lock().await;
799+
*current = None;
800+
}
801+
802+
if success {
803+
let _ = app.emit("agent:review-addressed", serde_json::json!({
804+
"taskId": task_id,
805+
"repositoryId": repository_id,
806+
"branchName": branch_name,
807+
"commitSha": commit_sha,
808+
}));
809+
} else {
810+
let _ = app.emit("agent:review-address-failed", serde_json::json!({
811+
"taskId": task_id,
812+
"repositoryId": repository_id,
813+
"error": error,
814+
}));
815+
}
816+
817+
Ok(result)
818+
}
819+
553820
#[derive(Debug, Serialize, Deserialize)]
554821
#[serde(rename_all = "camelCase")]
555822
pub struct EngineStatusResponse {

src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub fn run() {
9797
engine_commands::engine_get_diff_stat,
9898
engine_commands::engine_create_pr,
9999
engine_commands::engine_augment_tasks,
100+
engine_commands::engine_address_review,
101+
engine_commands::run_gh_api,
102+
engine_commands::run_gh_api_post,
100103
engine_commands::run_terminal_command,
101104
command::set_dock_badge,
102105
])

src-tauri/src/migrations.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,5 +302,67 @@ pub fn migrations() -> Vec<Migration> {
302302
"#,
303303
kind: MigrationKind::Up,
304304
},
305+
Migration {
306+
version: 16,
307+
description: "add PR lifecycle management tables and columns",
308+
sql: r#"
309+
ALTER TABLE tasks ADD COLUMN pr_state TEXT;
310+
ALTER TABLE tasks ADD COLUMN pr_number INTEGER;
311+
ALTER TABLE tasks ADD COLUMN pr_review_cycles INTEGER DEFAULT 0;
312+
313+
CREATE TABLE IF NOT EXISTS pr_reviews (
314+
id TEXT PRIMARY KEY NOT NULL,
315+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
316+
github_review_id INTEGER NOT NULL,
317+
reviewer TEXT NOT NULL,
318+
state TEXT NOT NULL,
319+
body TEXT,
320+
submitted_at DATETIME NOT NULL,
321+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
322+
);
323+
324+
CREATE INDEX IF NOT EXISTS idx_pr_reviews_task
325+
ON pr_reviews(task_id);
326+
327+
CREATE TABLE IF NOT EXISTS pr_comments (
328+
id TEXT PRIMARY KEY NOT NULL,
329+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
330+
github_comment_id INTEGER NOT NULL,
331+
in_reply_to_id INTEGER,
332+
reviewer TEXT NOT NULL,
333+
body TEXT NOT NULL,
334+
path TEXT,
335+
line INTEGER,
336+
side TEXT,
337+
commit_id TEXT,
338+
classification TEXT,
339+
our_reply TEXT,
340+
addressed_in_commit TEXT,
341+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
342+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
343+
);
344+
345+
CREATE INDEX IF NOT EXISTS idx_pr_comments_task
346+
ON pr_comments(task_id);
347+
CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_comments_github_id
348+
ON pr_comments(github_comment_id);
349+
CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_reviews_github_id
350+
ON pr_reviews(github_review_id);
351+
352+
INSERT OR IGNORE INTO global_settings (key, value) VALUES
353+
('pr_lifecycle_enabled', 'true'),
354+
('max_review_cycles', '5');
355+
"#,
356+
kind: MigrationKind::Up,
357+
},
358+
// Migration 17: per-repo PR auto-reply override
359+
Migration {
360+
version: 17,
361+
description: "add per-repo pr auto-reply override",
362+
sql: r#"
363+
ALTER TABLE agent_config ADD COLUMN override_pr_auto_reply INTEGER;
364+
"#,
365+
kind: MigrationKind::Up,
366+
},
305367
]
306368
}

src-tauri/src/preflight.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ fn resolve_gh_binary() -> String {
5151
resolve_binary("gh", &[])
5252
}
5353

54+
/// Public accessor for use in engine_commands
55+
pub fn resolve_gh_binary_pub() -> String {
56+
resolve_gh_binary()
57+
}
58+
5459
fn resolve_git_binary() -> String {
5560
resolve_binary("git", &[PathBuf::from("/usr/bin/git")])
5661
}

src/core/api/useEngine.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "@core/db/tasks";
1919
import { listRepositories } from "@core/db/repositories";
2020
import { addComment as addLinearComment } from "@core/services/linear";
21+
import { parseOwnerRepo } from "@core/services/github";
2122
import type {
2223
BudgetConfig,
2324
BudgetStatus,
@@ -471,14 +472,22 @@ async function handleTaskResult(
471472
}
472473
}
473474

475+
const prMeta = prUrl ? parseOwnerRepo(prUrl) : undefined;
476+
474477
await dbUpdateTaskWithRetry(variables.taskId, {
475-
state: prUrl ? ("done" as const) : ("review" as const),
478+
state: "review" as const,
476479
baseBranch: variables.baseBranch,
477480
branchName: result.branchName,
478481
commitSha: result.commitSha,
479482
sessionId: result.sessionId,
480483
completedAt: new Date().toISOString(),
481484
...(prUrl ? { prUrl } : {}),
485+
...(prMeta
486+
? {
487+
prState: "opened" as const,
488+
prNumber: prMeta.number,
489+
}
490+
: {}),
482491
});
483492

484493
if (notify && settings.notificationsEnabled) {

0 commit comments

Comments
 (0)