@@ -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" ) ]
555822pub struct EngineStatusResponse {
0 commit comments