1pub(crate) const MAX_REVIEW_AGENT_FAILURES: u64 = 3;
13
14const MAX_PR_CREATE_FAILURES: u64 = 3;
16
17fn outcome_for_agent_error(err: &runner::agents::AgentError) -> &'static str {
22 match err {
23 runner::agents::AgentError::Timeout { .. } => "timeout",
24 runner::agents::AgentError::RateLimit { message } => {
29 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
30 .unwrap_or("rate_limit")
31 }
32 runner::agents::AgentError::Auth { message } => {
33 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
34 .unwrap_or("auth_error")
35 }
36 runner::agents::AgentError::InvalidResponse { .. } => "parse_error",
37 _ => "failed",
38 }
39}
40
41fn format_review_error(agent: &str, err: &runner::agents::AgentError) -> String {
50 match err {
51 runner::agents::AgentError::RateLimit { message } => format!(
52 "{agent} rate limit: {}",
53 runner::fallback::summarize_rate_limit_error(message)
54 ),
55 runner::agents::AgentError::Auth { message } => format!("{agent} auth error: {message}"),
56 runner::agents::AgentError::Timeout { elapsed } => {
57 format!("{agent} timed out after {}s", elapsed.as_secs())
58 }
59 other => format!("{agent} agent error: {other}"),
60 }
61}
62
63pub(crate) fn parse_pr_number_from_url(url: &str) -> Result<u64, String> {
75 let url = url.trim();
76 if url.is_empty() {
77 return Err("URL is empty".to_string());
78 }
79 if !url.starts_with("https://github.com/") {
80 return Err(format!(
81 "URL does not start with 'https://github.com/': {url}"
82 ));
83 }
84 if !url.contains("/pull/") {
85 return Err(format!("URL missing '/pull/' segment: {url}"));
86 }
87 let last_segment = url
88 .trim_end_matches('/')
89 .rsplit('/')
90 .next()
91 .ok_or_else(|| format!("No segment after '/pull/' in URL: {url}"))?;
92 let number_part = last_segment
93 .split(['?', '#'])
94 .next()
95 .filter(|s| !s.is_empty())
96 .ok_or_else(|| format!("PR number segment is empty in URL: {url}"))?;
97 let pr_num: u64 = number_part.parse().map_err(|_| {
98 format!("PR number is not a valid positive integer: '{number_part}' in URL: {url}")
99 })?;
100 if pr_num == 0 {
101 return Err(format!("PR number cannot be zero in URL: {url}"));
102 }
103 Ok(pr_num)
104}
105
106fn review_started_comment(review_agent: &str, review_model: &str) -> String {
107 let footer = attribution_footer("Review started", review_agent, Some(review_model));
108 format!("🔍 Automated review started{}", footer)
109}
110
111fn should_skip_no_code_reroute_increment(last_error: &str) -> bool {
112 crate::engine::runner::git_ops::is_transient_github_api_error(last_error)
113}
114
115use crate::backends::{ExternalBackend, ExternalTask, Status};
116use crate::cmd::CommandErrorContext;
117use crate::config;
118use crate::engine::attribution_footer;
119use crate::engine::auto_merge::{auto_merge_pr, handle_review_changes};
120use crate::engine::runner;
121use crate::engine::runner::worktree;
122use crate::engine::tasks::TaskManager;
123use crate::github::http::GhHttp;
124use crate::store::store_log_activity;
125use crate::store::TaskStore;
126use crate::store::{opt_store_get_task, set_review_session_expected, store_increment};
127use crate::store::{store_set, store_set_result};
128use crate::store::{CompleteRun, RunTokenUsage, StartRun};
129use crate::tmux::TmuxManager;
130use anyhow::Context;
131use std::collections::HashSet;
132use std::sync::Arc;
133use tokio::process::Command;
134use tokio::sync::RwLock;
135
136use super::router::Router;
137
138#[derive(Debug, Clone)]
140pub(crate) enum ReviewDecision {
141 Approve,
143 RequestChanges {
145 notes: String,
146 issues: Vec<crate::engine::runner::response::ReviewIssue>,
147 },
148 Failed(String),
150 Blocked(String),
152 Skipped,
154 Rerouted,
156}
157
158enum EnsurePrResult {
160 Ready(u64),
162 EarlyReturn(ReviewDecision),
164}
165
166enum ReviewPhase<T> {
167 Ready(T),
168 EarlyReturn(ReviewDecision),
169}
170
171struct ReviewContext {
172 store_id: Option<i64>,
173 had_prev_push_failure: bool,
174 review_attempt: u32,
175 worktree_path: std::path::PathBuf,
176 branch_name: String,
177 pr_number: u64,
178 head_sha: Option<String>,
180 default_branch: String,
181 review_agent: String,
182 review_model: Option<String>,
183 review_task_id: String,
184 review_attempt_dir: std::path::PathBuf,
185 output_file: std::path::PathBuf,
186 invocation: runner::agent::AgentInvocation,
187 same_agent_fallback: bool,
188}
189
190impl ReviewContext {
191 fn review_model_str(&self) -> &str {
192 self.review_model.as_deref().unwrap_or("")
193 }
194}
195
196struct ReviewRun {
197 run_id: Option<i64>,
198}
199
200struct ParsedReview {
201 run_id: Option<i64>,
202 exit_code: i32,
203 stderr: String,
204 raw_output: String,
205 text_for_review: String,
206 token_usage: RunTokenUsage,
207 review_notes_for_comment: String,
208 decision: ReviewDecision,
209}
210
211fn approved_sha_key(repo: &str, task_id: &str, pr_number: u64) -> String {
213 format!("review_approved_sha:{repo}#{task_id}#{pr_number}")
214}
215
216async fn worktree_head_sha(dir: &std::path::Path) -> Option<String> {
218 let out = tokio::process::Command::new("git")
219 .args(["rev-parse", "HEAD"])
220 .current_dir(dir)
221 .kill_on_drop(true)
222 .output()
223 .await
224 .ok()?;
225 if !out.status.success() {
226 return None;
227 }
228 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
229 (!sha.is_empty()).then_some(sha)
230}
231
232async fn record_approved_sha(
235 store: &Arc<TaskStore>,
236 repo: &str,
237 task_id: &str,
238 pr_number: u64,
239 dir: &std::path::Path,
240) {
241 let Some(sha) = worktree_head_sha(dir).await else {
242 return;
243 };
244 if let Err(e) = store
245 .kv_set(&approved_sha_key(repo, task_id, pr_number), &sha)
246 .await
247 {
248 tracing::warn!(task_id, err = %e, "failed to record approved commit sha");
249 }
250}
251
252async fn clear_approved_sha(store: &Arc<TaskStore>, repo: &str, task_id: &str, pr_number: u64) {
254 if let Err(e) = store
255 .kv_delete(&approved_sha_key(repo, task_id, pr_number))
256 .await
257 {
258 tracing::warn!(task_id, err = %e, "failed to clear approved commit sha");
259 }
260}
261
262async fn record_carried_approval(
265 store: &Arc<TaskStore>,
266 repo: &str,
267 task_id: &str,
268 pr_number: u64,
269) {
270 store_log_activity(
271 &Some(Arc::clone(store)),
272 repo,
273 task_id,
274 "review_decision",
275 None,
276 None,
277 None::<&str>,
278 None::<&str>,
279 Some(&serde_json::json!({
280 "decision": "approve",
281 "pr_number": pr_number,
282 "reason": "head_already_approved",
283 })),
284 )
285 .await;
286}
287
288async fn pr_head_already_approved(
297 store: &Arc<TaskStore>,
298 repo: &str,
299 task_id: &str,
300 pr_number: u64,
301) -> bool {
302 let approved = match store
303 .kv_get(&approved_sha_key(repo, task_id, pr_number))
304 .await
305 {
306 Ok(sha) => sha,
307 Err(e) => {
308 tracing::warn!(task_id, err = %e, "failed to read approved commit sha");
309 return false;
310 }
311 };
312 if approved.is_none() {
313 return false;
314 }
315 let gh = match crate::github::http::GhHttp::new() {
316 Ok(gh) => gh,
317 Err(e) => {
318 tracing::warn!(task_id, err = %e, "cannot check PR head against recorded approval");
319 return false;
320 }
321 };
322 let pr_head = match gh.get_pr(repo, pr_number).await {
323 Ok(pr) => Some(pr.head.sha),
324 Err(e) => {
325 tracing::warn!(task_id, pr_number, err = %e, "failed to read PR head sha");
326 None
327 }
328 };
329 approval_covers_head(approved.as_deref(), pr_head.as_deref())
330}
331
332fn approval_covers_head(approved: Option<&str>, pr_head: Option<&str>) -> bool {
334 match (approved, pr_head) {
335 (Some(approved), Some(head)) => !approved.is_empty() && approved == head,
336 _ => false,
337 }
338}
339
340fn build_review_disallowed_tools(worktree_path: &std::path::Path) -> Vec<String> {
341 let mut tools = vec![
342 "Bash(rm *)".to_string(),
343 "Bash(rm -*)".to_string(),
344 "Bash(git push*)".to_string(),
345 "Bash(gh pr create*)".to_string(),
347 ];
348 let orch_yml = worktree_path.join(".orch.yml");
349 let orch_yml_str = orch_yml.to_string_lossy();
350 tools.extend([
351 format!("Write({orch_yml_str})"),
352 format!("Edit({orch_yml_str})"),
353 ]);
354 if let Ok(orch_home) = crate::home::orch_home() {
355 for path in [
356 orch_home.join("config.yml"),
357 orch_home.join("config.example.yml"),
358 ] {
359 let path_str = path.to_string_lossy();
360 tools.extend([
361 format!("Read({path_str})"),
362 format!("Write({path_str})"),
363 format!("Edit({path_str})"),
364 ]);
365 }
366 }
367 tools
368}
369
370async fn select_review_agent(
371 task_id: &str,
372 task_agent: &str,
373 store_id: Option<i64>,
374 router: &Arc<RwLock<Router>>,
375 store: &Arc<TaskStore>,
376) -> anyhow::Result<(String, Option<String>, bool)> {
377 let mut exclude_set: HashSet<String> = HashSet::new();
378 if !task_agent.is_empty() {
379 exclude_set.insert(task_agent.to_string());
380 }
381 if let Some(store_id) = store_id {
382 if let Ok(failed) = store.previous_review_agents(store_id).await {
383 for agent in failed {
384 exclude_set.insert(agent);
385 }
386 }
387 }
388
389 let mut r = router.write().await;
390 let mut tried_agents: HashSet<String> = HashSet::new();
391 let mut chosen_agent: Option<String> = None;
392 let mut chosen_model: Option<String> = None;
393 let available_count = r.available_agents.len().max(1);
394
395 loop {
396 let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
397 let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
398 Some(a) => a,
399 None => break,
400 };
401
402 if tried_agents.contains(&agent) {
403 break;
404 }
405 tried_agents.insert(agent.clone());
406
407 let model = r.config.model_for_complexity(&agent, "review", task_id);
408 if model.is_some() {
409 chosen_agent = Some(agent);
410 chosen_model = model;
411 break;
412 }
413
414 exclude_set.insert(agent);
415 if exclude_set.len() >= available_count {
416 break;
417 }
418 }
419
420 if let Some(agent) = chosen_agent {
421 let same = !task_agent.is_empty() && agent == task_agent;
422 Ok((agent, chosen_model, same))
423 } else {
424 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
425 let fallback_agent = r
426 .next_round_robin_agent(&final_exclude_refs, "review")
427 .unwrap_or_else(|| "claude".to_string());
428 let fallback_model = r
429 .config
430 .model_for_complexity(&fallback_agent, "review", task_id);
431 if fallback_model.is_none() {
432 anyhow::bail!(
433 "no review agent with a configured review model is available for task {}",
434 task_id
435 );
436 }
437 let same = !task_agent.is_empty() && fallback_agent == task_agent;
438 Ok((fallback_agent, fallback_model, same))
439 }
440}
441
442#[allow(clippy::too_many_arguments)]
443async fn build_review_context(
444 task: &ExternalTask,
445 repo: &str,
446 router: &Arc<RwLock<Router>>,
447 task_manager: &Arc<TaskManager>,
448 store: &Arc<TaskStore>,
449) -> anyhow::Result<ReviewPhase<ReviewContext>> {
450 let stored_task = store
451 .get_by_external_id(repo, &task.id.0)
452 .await
453 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
454
455 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
456
457 if let Some(pr_number) = stored_task.as_ref().and_then(|t| t.pr_number) {
460 let pr_number = pr_number as u64;
461 if pr_head_already_approved(store, repo, &task.id.0, pr_number).await {
462 tracing::info!(
463 task_id = task.id.0,
464 pr_number,
465 "PR head already approved — skipping review, merge retry continues"
466 );
467 record_carried_approval(store, repo, &task.id.0, pr_number).await;
468 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve));
469 }
470 }
471
472 let had_prev_push_failure = stored_task
473 .as_ref()
474 .is_some_and(|t| t.last_error.contains("push failed"));
475 let review_attempt: u32 = match store_increment(
476 &Some(Arc::clone(store)),
477 repo,
478 &task.id.0,
479 "review_invocations",
480 )
481 .await
482 {
483 Ok(v) => v as u32,
484 Err(e) => {
485 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
486 1
487 }
488 };
489
490 let no_code_review = stored_task.as_ref().is_some_and(|t| {
491 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
492 });
493 if no_code_review {
494 if crate::engine::tasks::is_internal_id(&task.id.0) {
495 tracing::info!(
497 task_id = task.id.0,
498 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
499 );
500 if let Err(e) = task_manager
501 .update_task_status(&task.id, Status::Done)
502 .await
503 {
504 tracing::error!(
505 task_id = task.id.0,
506 err = %e,
507 "update_task_status(Done) failed for no-code review skip"
508 );
509 }
510 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
511 }
512
513 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
516 .ok()
517 .and_then(|s| s.parse().ok())
518 .or_else(|| {
519 config::get("workflow.max_attempts")
520 .ok()
521 .and_then(|s| s.parse().ok())
522 })
523 .unwrap_or(3);
524
525 let reroutes = match store_increment(
526 &Some(Arc::clone(store)),
527 repo,
528 &task.id.0,
529 "no_code_reroutes",
530 )
531 .await
532 {
533 Ok(v) => v,
534 Err(e) => {
535 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
536 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
537 "transient store error: {e}"
538 ))));
539 }
540 };
541
542 if reroutes as u32 >= max_reroutes {
543 tracing::error!(
544 task_id = task.id.0,
545 reroutes,
546 max_reroutes,
547 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
548 );
549 let msg = format!(
550 "no worktree/branch/pr after {}/{} reroute attempts",
551 reroutes, max_reroutes
552 );
553 let fields = [
554 (
555 "block_reason",
556 serde_json::json!(format!(
557 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
558 max_reroutes
559 )),
560 ),
561 ("last_error", serde_json::json!(msg)),
562 ("agent", serde_json::json!(null)),
563 ("model", serde_json::json!(null)),
564 ];
565 if let Err(e) = task_manager
566 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
567 .await
568 {
569 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
570 }
571 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
572 }
573
574 tracing::warn!(
575 task_id = task.id.0,
576 reroutes,
577 max_reroutes,
578 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
579 );
580 if let Err(e) = store_set_result(
581 &Some(Arc::clone(store)),
582 repo,
583 &task.id.0,
584 &[
585 ("agent", serde_json::json!(null)),
586 ("model", serde_json::json!(null)),
587 (
588 "last_error",
589 serde_json::json!("no worktree/branch/pr produced"),
590 ),
591 ],
592 )
593 .await
594 {
595 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
596 } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
597 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
598 }
599 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
600 }
601
602 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
603 Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
604 _ => {
605 tracing::warn!(task_id = task.id.0, "no worktree found for review");
606 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
607 "no worktree found".to_string(),
608 )));
609 }
610 };
611
612 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
613 Some(b) if !b.is_empty() => b.to_string(),
614 _ => {
615 tracing::warn!(task_id = task.id.0, "no branch found for review");
616 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
617 "no branch found".to_string(),
618 )));
619 }
620 };
621
622 let mut missing_worktree_recovered = false;
623 if !worktree_path.exists() {
624 match recover_missing_review_worktree(task, repo, store).await {
625 Ok(recovered) => {
626 branch_name = recovered.branch;
627 worktree_path = recovered.work_dir;
628 missing_worktree_recovered = true;
629 }
630 Err(e) => {
631 let reason = format!("missing review worktree recovery failed: {e}");
632 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
633 store_set(
634 &Some(Arc::clone(store)),
635 repo,
636 &task.id.0,
637 &[("last_error", serde_json::json!(reason.clone()))],
638 )
639 .await;
640 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
641 }
642 }
643 }
644
645 if missing_worktree_recovered {
646 store_set(
647 &Some(Arc::clone(store)),
648 repo,
649 &task.id.0,
650 &[("last_error", serde_json::json!(""))],
651 )
652 .await;
653 }
654
655 let agent_summary = stored_task
656 .as_ref()
657 .map(|t| t.summary.clone())
658 .unwrap_or_default();
659 let stored_pr_number = stored_task
660 .as_ref()
661 .and_then(|t| t.pr_number)
662 .map(|n| n as u64)
663 .filter(|&n| n > 0);
664
665 let pr_number = match ensure_pr_exists(
666 task,
667 &branch_name,
668 &worktree_path,
669 repo,
670 &agent_summary,
671 store,
672 task_manager,
673 stored_pr_number,
674 )
675 .await?
676 {
677 EnsurePrResult::Ready(n) => n,
678 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
679 };
680
681 {
685 let fetch_timeout = std::time::Duration::from_secs(60);
686 let fetch_future = async {
687 let mut cmd = Command::new("git");
688 cmd.args(["fetch", "origin", "--prune"])
689 .current_dir(&worktree_path);
690 cmd.kill_on_drop(true);
692 cmd.output_with_context().await
693 };
694
695 match tokio::time::timeout(fetch_timeout, fetch_future).await {
696 Ok(Ok(o)) if o.status.success() => {
697 }
699 Ok(Ok(o)) => {
700 tracing::warn!(
701 task_id = task.id.0,
702 exit_code = ?o.status.code(),
703 stdout = %String::from_utf8_lossy(&o.stdout),
704 stderr = %String::from_utf8_lossy(&o.stderr),
705 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
706 );
707 }
708 Ok(Err(e)) => {
709 tracing::warn!(
710 task_id = task.id.0,
711 error = %e,
712 "review: git fetch failed — diff/log may use stale remote refs"
713 );
714 }
715 Err(_) => {
716 tracing::warn!(
717 task_id = task.id.0,
718 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
719 );
720 }
721 }
722 }
723
724 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
725 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
726 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
727 let project_dir = crate::config::get_projects_with_paths()
730 .unwrap_or_default()
731 .into_iter()
732 .find(|(candidate, _)| candidate == repo)
733 .map(|(_, dir)| dir);
734 let sandboxed = match &project_dir {
735 Some(dir) => runner::sandbox::enabled(dir),
736 None => runner::sandbox::enabled_globally(),
737 };
738 let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
739 let review_prompt = runner::agent::build_review_prompt(
740 task,
741 &agent_summary,
742 &git_diff,
743 &git_log,
744 &default_branch,
745 pr_number,
746 runner::agent::Shipcheck::detect(&worktree_path)
747 .await
748 .for_prompt(sandboxed),
749 );
750
751 let task_agent = stored_task
752 .as_ref()
753 .and_then(|t| t.agent.clone())
754 .unwrap_or_default();
755 let (review_agent, review_model, same_agent_fallback) =
756 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
757
758 anyhow::ensure!(
759 review_model.is_some(),
760 "review model missing for task {} after agent selection — refusing to dispatch",
761 task.id.0
762 );
763
764 if same_agent_fallback {
765 tracing::warn!(
766 task_id = task.id.0,
767 agent = %review_agent,
768 "no other agent available for review — falling back to the agent that wrote the code"
769 );
770 store_log_activity(
771 &Some(Arc::clone(store)),
772 repo,
773 &task.id.0,
774 "review_agent_fallback",
775 None,
776 None,
777 Some(&review_agent),
778 review_model.as_deref(),
779 Some(&serde_json::json!({
780 "reason": "all available agents excluded (author + previous reviewers)",
781 })),
782 )
783 .await;
784 }
785
786 tracing::info!(
787 task_id = task.id.0,
788 agent = %review_agent,
789 model = ?review_model,
790 "spawning review agent"
791 );
792
793 let review_task_id = format!("{}-review", task.id.0);
794 let review_attempt_dir =
795 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
796 let output_file = review_attempt_dir.join("output.json");
797
798 let git_name =
799 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
800 let git_email = crate::config::get("git.email")
801 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
802 let review_timeout_secs: u64 = crate::config::get("workflow.review_timeout_seconds")
803 .ok()
804 .and_then(|s| s.parse().ok())
805 .unwrap_or_else(|| {
806 crate::config::get("workflow.timeout_seconds")
807 .ok()
808 .and_then(|s| s.parse().ok())
809 .unwrap_or(1800)
810 })
811 .max(1800);
812
813 let invocation = runner::agent::AgentInvocation {
814 agent: review_agent.clone(),
815 model: review_model.clone(),
816 work_dir: worktree_path.clone(),
817 system_prompt: runner::agent::review_system_prompt(),
818 agent_message: review_prompt,
819 task_id: review_task_id.clone(),
820 disallowed_tools: build_review_disallowed_tools(&worktree_path),
821 git_author_name: git_name,
822 git_author_email: git_email,
823 output_file: output_file.clone(),
824 timeout_seconds: review_timeout_secs,
825 repo: repo.to_string(),
826 attempt: review_attempt,
827 sandbox: sandboxed,
828 main_project_dir,
829 branch: branch_name.clone(),
830 };
831
832 let head_sha = match GhHttp::new() {
833 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
834 Err(_) => None,
835 };
836
837 Ok(ReviewPhase::Ready(ReviewContext {
838 store_id,
839 had_prev_push_failure,
840 review_attempt,
841 worktree_path,
842 branch_name,
843 pr_number,
844 head_sha,
845 default_branch,
846 review_agent,
847 review_model,
848 review_task_id,
849 review_attempt_dir,
850 output_file,
851 invocation,
852 same_agent_fallback,
853 }))
854}
855
856#[allow(clippy::too_many_arguments)]
857async fn complete_review_run(
858 store: &Arc<TaskStore>,
859 run_id: Option<i64>,
860 exit_code: Option<i32>,
861 stdout: &str,
862 stderr: &str,
863 parsed: &str,
864 outcome: &str,
865 error: &str,
866 tokens: RunTokenUsage,
867) {
868 if let Some(run_id) = run_id {
869 if let Err(e) = store
870 .complete_run(&CompleteRun {
871 run_id,
872 exit_code,
873 stdout,
874 stderr,
875 parsed,
876 outcome,
877 error,
878 tokens,
879 })
880 .await
881 {
882 tracing::warn!(
883 run_id,
884 error = %e,
885 "failed to record review run completion in audit trail"
886 );
887 }
888 }
889}
890
891async fn invoke_review_agent(
892 task: &ExternalTask,
893 tmux: &Arc<TmuxManager>,
894 ctx: &ReviewContext,
895 repo: &str,
896 store: &Arc<TaskStore>,
897) -> ReviewPhase<ReviewRun> {
898 let run_id = if let Some(sid) = ctx.store_id {
899 match store
900 .start_run(&StartRun {
901 task_id: sid,
902 attempt: ctx.review_attempt as i32,
903 run_type: "review",
904 agent: &ctx.review_agent,
905 model: ctx.review_model.as_deref().unwrap_or(""),
906 command: "",
907 prompt: "",
908 })
909 .await
910 {
911 Ok(run_id) => Some(run_id),
912 Err(e) => {
913 tracing::warn!(
914 task_id = task.id.0,
915 error = %e,
916 "failed to record review run start in audit trail"
917 );
918 None
919 }
920 }
921 } else {
922 None
923 };
924
925 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
926 Ok(session) => session,
927 Err(e) => {
928 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
929 complete_review_run(
930 store,
931 run_id,
932 None,
933 "",
934 &e.to_string(),
935 "",
936 "failed",
937 &format!("spawn failed: {e}"),
938 RunTokenUsage::default(),
939 )
940 .await;
941 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
942 }
943 };
944
945 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
946 match GhHttp::new() {
947 Ok(gh) => {
948 let app = review_app(repo).await;
949 post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
950 if let Err(e) = gh
951 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
952 .await
953 {
954 tracing::debug!(
955 task_id = task.id.0,
956 pr_number = ctx.pr_number,
957 error = %e,
958 "failed to post review-started comment"
959 );
960 }
961 }
962 Err(e) => {
963 tracing::debug!(
964 task_id = task.id.0,
965 pr_number = ctx.pr_number,
966 error = %e,
967 "failed to create GitHub client for review-started comment"
968 );
969 }
970 }
971
972 let poll_interval = std::time::Duration::from_secs(5);
973 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
974 let wait_result = tokio::time::timeout(
975 timeout_duration,
976 tmux.wait_for_completion(&session, poll_interval),
977 )
978 .await;
979
980 match wait_result {
981 Ok(Ok(_)) => {
982 tracing::info!(task_id = task.id.0, "review agent completed");
983 let _ = tmux.kill_session(&session).await;
984 ReviewPhase::Ready(ReviewRun { run_id })
985 }
986 Ok(Err(e)) => {
987 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
988 let _ = tmux.kill_session(&session).await;
989
990 let exit_code: i32 = tokio::task::spawn_blocking({
995 let review_attempt_dir = ctx.review_attempt_dir.clone();
996 move || {
997 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
998 .ok()
999 .and_then(|s| s.trim().parse().ok())
1000 .unwrap_or(-1)
1001 }
1002 })
1003 .await
1004 .unwrap_or(-1);
1005
1006 if exit_code != 0 {
1007 let raw_output =
1012 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1013 .await;
1014 let agent_result =
1015 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1016 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1018 tracing::info!(
1019 task_id = task.id.0,
1020 agent = %ctx.review_agent,
1021 exit_code,
1022 output_found = !raw_output.is_empty(),
1023 "review agent succeeded despite non-zero exit — proceeding to parse output"
1024 );
1025 return ReviewPhase::Ready(ReviewRun { run_id });
1026 }
1027 }
1028
1029 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1030 complete_review_run(
1031 store,
1032 run_id,
1033 Some(exit_code),
1034 "",
1035 &e.to_string(),
1036 "",
1037 "failed",
1038 &format!("agent error: {e}"),
1039 RunTokenUsage::default(),
1040 )
1041 .await;
1042 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1043 }
1044 Err(_) => {
1045 tracing::error!(task_id = task.id.0, "review agent timed out");
1046 let _ = tmux.kill_session(&session).await;
1047 if let Some(model) = ctx.review_model.as_deref() {
1048 crate::engine::cooldown::record_persistent_model_failure(&ctx.review_agent, model)
1053 .await;
1054 }
1055 complete_review_run(
1056 store,
1057 run_id,
1058 Some(-1),
1059 "",
1060 "timeout",
1061 "",
1062 "timeout",
1063 "timeout",
1064 RunTokenUsage::default(),
1065 )
1066 .await;
1067 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1068 }
1069 }
1070}
1071
1072async fn record_review_agent_failure(
1073 task_id: &str,
1074 review_agent: &str,
1075 review_model: Option<&str>,
1076 err: &runner::agents::AgentError,
1077) {
1078 match err {
1079 runner::agents::AgentError::RateLimit { message }
1080 | runner::agents::AgentError::Auth { message } => {
1081 tracing::warn!(
1082 task_id,
1083 agent = %review_agent,
1084 "review agent hit error — adding to cooldown"
1085 );
1086 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1087 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1088 {
1089 if let Some(model) = review_model {
1090 crate::engine::cooldown::record_persistent_model_failure(
1092 review_agent,
1093 model,
1094 )
1095 .await;
1096 } else {
1097 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1098 .await;
1099 }
1100 } else {
1101 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1102 }
1103 } else {
1104 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1105 .await;
1106 }
1107 }
1108 runner::agents::AgentError::InvalidResponse { .. } => {
1109 if let Some(model) = review_model {
1114 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1115 }
1116 }
1117 runner::agents::AgentError::ModelUnavailable { .. } => {
1118 if let Some(model) = review_model {
1123 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1124 }
1125 }
1126 _ => {
1127 if let Some(model) = review_model {
1130 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1131 }
1132 }
1133 }
1134}
1135
1136fn review_decision_from_response(
1137 review_response: runner::response::ReviewResponse,
1138) -> ReviewDecision {
1139 match review_response.decision.as_str() {
1140 "approve" => ReviewDecision::Approve,
1141 "request_changes" => ReviewDecision::RequestChanges {
1142 notes: review_response.notes,
1143 issues: review_response.issues,
1144 },
1145 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1146 }
1147}
1148
1149fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1150 match decision {
1151 ReviewDecision::Approve => "approve",
1152 ReviewDecision::RequestChanges { .. } => "request_changes",
1153 ReviewDecision::Failed(_) => "failed",
1154 ReviewDecision::Blocked(_) => "blocked",
1155 ReviewDecision::Skipped => "skipped",
1156 ReviewDecision::Rerouted => "rerouted",
1157 }
1158}
1159
1160async fn parse_review_output(
1161 task: &ExternalTask,
1162 repo: &str,
1163 ctx: &ReviewContext,
1164 run: ReviewRun,
1165 store: &Arc<TaskStore>,
1166) -> ReviewPhase<ParsedReview> {
1167 let (file_exists, file_size, exit_code, stderr) = {
1168 let output_file_clone = ctx.output_file.clone();
1169 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1170 match tokio::task::spawn_blocking(move || {
1171 let file_exists = output_file_clone.exists();
1172 let file_size = std::fs::metadata(&output_file_clone)
1173 .map(|m| m.len())
1174 .unwrap_or(0);
1175 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1176 .ok()
1177 .and_then(|s| s.trim().parse::<i32>().ok())
1178 .unwrap_or(-1);
1179 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1180 .unwrap_or_default();
1181 (file_exists, file_size, exit_code, stderr)
1182 })
1183 .await
1184 {
1185 Ok(tuple) => tuple,
1186 Err(e) => {
1187 tracing::error!(
1188 task_id = task.id.0,
1189 error = %e,
1190 "spawn_blocking panicked reading review output metadata"
1191 );
1192 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1193 }
1194 }
1195 };
1196
1197 tracing::info!(
1198 task_id = task.id.0,
1199 path = %ctx.output_file.display(),
1200 file_exists,
1201 file_size,
1202 "reading review agent output"
1203 );
1204
1205 let raw_output =
1206 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1207 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1208 let has_result_envelope =
1209 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1210 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1211 let token_usage = RunTokenUsage {
1212 input_tokens: agent_result
1213 .as_ref()
1214 .and_then(|r| r.input_tokens)
1215 .unwrap_or(0),
1216 output_tokens: agent_result
1217 .as_ref()
1218 .and_then(|r| r.output_tokens)
1219 .unwrap_or(0),
1220 total_cost_usd: agent_result
1221 .as_ref()
1222 .and_then(|r| r.cost_usd)
1223 .unwrap_or(0.0),
1224 duration_secs: 0.0,
1225 };
1226
1227 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1228 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1229 && !raw_output.contains("\"is_error\":true");
1230 let is_hard_failure =
1232 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1233
1234 if is_hard_failure {
1235 tracing::warn!(
1236 task_id = task.id.0,
1237 exit_code,
1238 raw_output_len = raw_output.len(),
1239 stderr_len = stderr.len(),
1240 "review agent: entering error path"
1241 );
1242 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1243 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1244 record_review_agent_failure(
1245 &task.id.0,
1246 &ctx.review_agent,
1247 ctx.review_model.as_deref(),
1248 &err,
1249 )
1250 .await;
1251 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1252 let stored_error = format_review_error(&ctx.review_agent, &err);
1253 complete_review_run(
1254 store,
1255 run.run_id,
1256 Some(exit_code),
1257 &raw_output,
1258 &stderr,
1259 "",
1260 outcome_for_agent_error(&err),
1261 &stored_error,
1262 token_usage,
1263 )
1264 .await;
1265 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1266 }
1267
1268 let text_for_review = agent_result
1269 .as_ref()
1270 .map(|r| r.result_text.clone())
1271 .filter(|text| !text.is_empty())
1272 .unwrap_or_else(|| {
1273 tracing::debug!(
1274 task_id = task.id.0,
1275 agent = %ctx.review_agent,
1276 "review agent: per-agent extractor returned no text, using raw output"
1277 );
1278 raw_output.clone()
1279 });
1280
1281 let review_response = match runner::response::parse_review_response(&text_for_review) {
1282 Ok(response) => response,
1283 Err(e) => {
1284 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1285 tracing::warn!(
1286 task_id = task.id.0,
1287 agent = %ctx.review_agent,
1288 "review response parsed via keyword fallback"
1289 );
1290 response
1291 } else {
1292 let ndjson_completed = ndjson_completed_no_error;
1298
1299 if ndjson_completed || !has_result_envelope {
1302 let assistant_text = runner::agents::collect_assistant_messages_text(
1303 &ctx.review_agent,
1304 &raw_output,
1305 );
1306 if !assistant_text.is_empty() {
1307 if let Ok(response) =
1308 runner::response::parse_review_response(&assistant_text)
1309 {
1310 tracing::debug!(
1311 task_id = task.id.0,
1312 agent = %ctx.review_agent,
1313 "found review response JSON in assistant message (terminal_reason:completed)"
1314 );
1315 let review_notes_for_comment = response.notes.clone();
1316 let decision = review_decision_from_response(response);
1317 return ReviewPhase::Ready(ParsedReview {
1318 run_id: run.run_id,
1319 exit_code,
1320 stderr,
1321 raw_output,
1322 text_for_review: assistant_text,
1323 token_usage,
1324 review_notes_for_comment,
1325 decision,
1326 });
1327 }
1328 if let Some(response) =
1329 runner::response::infer_review_response(&assistant_text)
1330 {
1331 tracing::warn!(
1332 task_id = task.id.0,
1333 agent = %ctx.review_agent,
1334 "inferred review response from assistant message (terminal_reason:completed)"
1335 );
1336 let review_notes_for_comment = response.notes.clone();
1337 let decision = review_decision_from_response(response);
1338 return ReviewPhase::Ready(ParsedReview {
1339 run_id: run.run_id,
1340 exit_code,
1341 stderr,
1342 raw_output,
1343 text_for_review: assistant_text,
1344 token_usage,
1345 review_notes_for_comment,
1346 decision,
1347 });
1348 }
1349 }
1350 }
1351
1352 let scan_head = runner::agents::patterns::safe_head(
1359 &text_for_review,
1360 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1361 );
1362 let scan_tail = runner::agents::patterns::safe_tail(
1363 &text_for_review,
1364 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1365 );
1366
1367 if let Some(err) = (!ndjson_completed)
1368 .then(|| {
1369 runner::agents::patterns::detect_rate_limit(scan_head)
1370 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1371 })
1372 .flatten()
1373 {
1374 let runner::agents::AgentError::RateLimit { message } = &err else {
1375 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1376 };
1377 tracing::warn!(
1378 task_id = task.id.0,
1379 agent = %ctx.review_agent,
1380 "review agent hit rate limit — adding to cooldown"
1381 );
1382 let summary = runner::fallback::summarize_rate_limit_error(message);
1389 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1390 record_review_agent_failure(
1391 &task.id.0,
1392 &ctx.review_agent,
1393 ctx.review_model.as_deref(),
1394 &err,
1395 )
1396 .await;
1397 complete_review_run(
1398 store,
1399 run.run_id,
1400 Some(exit_code),
1401 &raw_output,
1402 &stderr,
1403 &text_for_review,
1404 outcome_for_agent_error(&err),
1405 &stored_error,
1406 token_usage,
1407 )
1408 .await;
1409 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1410 }
1411
1412 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1413 let runner::agents::AgentError::Auth { message } = &err else {
1414 unreachable!("detect_auth_error only returns AgentError::Auth")
1415 };
1416 tracing::warn!(
1417 task_id = task.id.0,
1418 agent = %ctx.review_agent,
1419 "review agent hit auth error — adding to cooldown"
1420 );
1421 let stored_error = format!("auth error: {message}");
1422 record_review_agent_failure(
1423 &task.id.0,
1424 &ctx.review_agent,
1425 ctx.review_model.as_deref(),
1426 &err,
1427 )
1428 .await;
1429 complete_review_run(
1430 store,
1431 run.run_id,
1432 Some(exit_code),
1433 &raw_output,
1434 &stderr,
1435 &text_for_review,
1436 outcome_for_agent_error(&err),
1437 &stored_error,
1438 token_usage,
1439 )
1440 .await;
1441 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1442 }
1443
1444 if agent_result
1451 .as_ref()
1452 .map(|r| r.truncated_by_length)
1453 .unwrap_or(false)
1454 {
1455 tracing::warn!(
1456 task_id = task.id.0,
1457 agent = %ctx.review_agent,
1458 model = ?ctx.review_model,
1459 "review agent output truncated: step_finish reason=length \
1460 (token budget exceeded before completion)"
1461 );
1462 if let Some(model) = ctx.review_model.as_deref() {
1463 crate::engine::cooldown::record_persistent_model_failure(
1464 &ctx.review_agent,
1465 model,
1466 )
1467 .await;
1468 }
1469 let stored_error = "truncated: review output was cut off before completion \
1470 (output/reasoning token budget exceeded)"
1471 .to_string();
1472 complete_review_run(
1473 store,
1474 run.run_id,
1475 Some(exit_code),
1476 &raw_output,
1477 &stderr,
1478 &text_for_review,
1479 "truncated",
1480 &stored_error,
1481 token_usage,
1482 )
1483 .await;
1484 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1485 }
1486
1487 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1490 tracing::warn!(
1491 task_id = task.id.0,
1492 agent = %ctx.review_agent,
1493 model = ?ctx.review_model,
1494 "review session exited mid-thought with no result envelope \
1495 — storing as incomplete and retrying without persistent cooldown"
1496 );
1497 let stored_error =
1498 "incomplete: review session exited before producing a decision".to_string();
1499 complete_review_run(
1500 store,
1501 run.run_id,
1502 Some(exit_code),
1503 &raw_output,
1504 &stderr,
1505 &text_for_review,
1506 "incomplete",
1507 &stored_error,
1508 token_usage,
1509 )
1510 .await;
1511 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1512 }
1513
1514 tracing::error!(
1515 task_id = task.id.0,
1516 error = %e,
1517 agent = %ctx.review_agent,
1518 output = %text_for_review.chars().take(300).collect::<String>(),
1519 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1520 "failed to parse review response"
1521 );
1522 if let Some(model) = ctx.review_model.as_deref() {
1523 crate::engine::cooldown::record_persistent_model_failure(
1526 &ctx.review_agent,
1527 model,
1528 )
1529 .await;
1530 }
1531 complete_review_run(
1532 store,
1533 run.run_id,
1534 Some(exit_code),
1535 &raw_output,
1536 &stderr,
1537 &text_for_review,
1538 "parse_error",
1539 &format!("parse error: {e}"),
1540 token_usage,
1541 )
1542 .await;
1543 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1544 "parse error: {e}"
1545 )));
1546 }
1547 }
1548 };
1549
1550 let review_notes_for_comment = review_response.notes.clone();
1551 let decision = review_decision_from_response(review_response);
1552
1553 tracing::info!(
1554 task_id = task.id.0,
1555 pr_number = ctx.pr_number,
1556 decision = ?decision,
1557 "review agent decision received"
1558 );
1559 store_log_activity(
1560 &Some(Arc::clone(store)),
1561 repo,
1562 &task.id.0,
1563 "review_decision",
1564 None,
1565 None,
1566 Some(&ctx.review_agent),
1567 ctx.review_model.as_deref(),
1568 Some(&serde_json::json!({
1569 "decision": review_decision_name(&decision),
1570 "pr_number": ctx.pr_number,
1571 })),
1572 )
1573 .await;
1574
1575 ReviewPhase::Ready(ParsedReview {
1576 run_id: run.run_id,
1577 exit_code,
1578 stderr,
1579 raw_output,
1580 text_for_review,
1581 token_usage,
1582 review_notes_for_comment,
1583 decision,
1584 })
1585}
1586
1587async fn finalize_review_run(
1588 task: &ExternalTask,
1589 ctx: &ReviewContext,
1590 parsed: &ParsedReview,
1591 store: &Arc<TaskStore>,
1592) {
1593 let outcome = match &parsed.decision {
1594 ReviewDecision::Approve
1595 | ReviewDecision::RequestChanges { .. }
1596 | ReviewDecision::Skipped
1597 | ReviewDecision::Rerouted => "success",
1598 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1599 };
1600 let error = match &parsed.decision {
1601 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1602 _ => "",
1603 };
1604
1605 if outcome == "success" {
1606 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1609 .await;
1610 }
1611
1612 complete_review_run(
1613 store,
1614 parsed.run_id,
1615 Some(parsed.exit_code),
1616 &parsed.raw_output,
1617 &parsed.stderr,
1618 &parsed.text_for_review,
1619 outcome,
1620 error,
1621 parsed.token_usage,
1622 )
1623 .await;
1624
1625 if let Some(store_id) = ctx.store_id {
1626 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1627 let model = if ctx.review_model_str().is_empty() {
1628 "unknown"
1629 } else {
1630 ctx.review_model_str()
1631 };
1632 if let Err(e) = store
1633 .store_tokens(
1634 store_id,
1635 parsed.token_usage.input_tokens,
1636 parsed.token_usage.output_tokens,
1637 model,
1638 )
1639 .await
1640 {
1641 tracing::warn!(
1642 task_id = task.id.0,
1643 ?e,
1644 "failed to store review agent token usage"
1645 );
1646 }
1647 }
1648 }
1649}
1650
1651async fn restore_review_config_if_needed(
1652 task_id: &str,
1653 worktree_path: &std::path::Path,
1654 default_branch: &str,
1655) {
1656 let _ = Command::new("git")
1657 .args(["checkout", "HEAD", "--", ".orch.yml"])
1658 .current_dir(worktree_path)
1659 .status()
1660 .await;
1661
1662 let merge_base = Command::new("git")
1663 .args(["merge-base", "HEAD", default_branch])
1664 .current_dir(worktree_path)
1665 .output()
1666 .await
1667 .ok()
1668 .filter(|output| output.status.success())
1669 .and_then(|output| String::from_utf8(output.stdout).ok())
1670 .map(|s| s.trim().to_string())
1671 .unwrap_or_default();
1672
1673 if merge_base.is_empty() {
1674 return;
1675 }
1676
1677 let orch_yml_changed = Command::new("git")
1678 .args([
1679 "diff",
1680 "--name-only",
1681 &merge_base,
1682 "HEAD",
1683 "--",
1684 ".orch.yml",
1685 ])
1686 .current_dir(worktree_path)
1687 .output()
1688 .await
1689 .ok()
1690 .filter(|output| output.status.success())
1691 .map(|output| !output.stdout.is_empty())
1692 .unwrap_or(false);
1693
1694 if !orch_yml_changed {
1695 return;
1696 }
1697
1698 tracing::warn!(
1699 task_id,
1700 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1701 );
1702 let restore_ok = Command::new("git")
1703 .args(["checkout", &merge_base, "--", ".orch.yml"])
1704 .current_dir(worktree_path)
1705 .status()
1706 .await
1707 .map(|status| status.success())
1708 .unwrap_or(false);
1709
1710 if !restore_ok {
1711 return;
1712 }
1713
1714 let staged = Command::new("git")
1715 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1716 .current_dir(worktree_path)
1717 .output()
1718 .await
1719 .ok()
1720 .filter(|output| output.status.success())
1721 .map(|output| !output.stdout.is_empty())
1722 .unwrap_or(false);
1723
1724 if staged {
1725 let _ = Command::new("git")
1726 .args([
1727 "-c",
1728 "commit.gpgsign=false",
1729 "commit",
1730 "-m",
1731 "revert: restore .orch.yml (review agent must not modify project config)",
1732 ])
1733 .current_dir(worktree_path)
1734 .status()
1735 .await;
1736 }
1737}
1738
1739fn is_missing_path_error_message(err: &str) -> bool {
1740 err.to_ascii_lowercase()
1741 .contains("no such file or directory")
1742}
1743
1744async fn push_review_branch(
1745 task: &ExternalTask,
1746 repo: &str,
1747 ctx: &ReviewContext,
1748 store: &Arc<TaskStore>,
1749) -> Result<(), String> {
1750 let mut worktree_path = ctx.worktree_path.clone();
1751 let mut branch_name = ctx.branch_name.clone();
1752 let mut retried_missing_worktree = false;
1753
1754 let push_result = loop {
1755 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1756 {
1757 Ok(ok) => break Ok(ok),
1758 Err(e)
1759 if !retried_missing_worktree
1760 && !worktree_path.exists()
1761 && is_missing_path_error_message(&e.to_string()) =>
1762 {
1763 retried_missing_worktree = true;
1764 tracing::warn!(
1765 task_id = task.id.0,
1766 worktree = %worktree_path.display(),
1767 branch = %branch_name,
1768 "review push failed because worktree disappeared; attempting one-time recovery"
1769 );
1770 match recover_missing_review_worktree(task, repo, store).await {
1771 Ok(recovered) => {
1772 worktree_path = recovered.work_dir;
1773 branch_name = recovered.branch;
1774 continue;
1775 }
1776 Err(recover_err) => {
1777 break Err(anyhow::anyhow!(
1778 "missing review worktree recovery failed after push error: {recover_err}"
1779 ));
1780 }
1781 }
1782 }
1783 Err(e) => break Err(e),
1784 }
1785 };
1786
1787 match push_result {
1788 Ok(_) => {
1789 store_log_activity(
1790 &Some(Arc::clone(store)),
1791 repo,
1792 &task.id.0,
1793 "push",
1794 None,
1795 None,
1796 Some(&ctx.review_agent),
1797 ctx.review_model.as_deref(),
1798 Some(&serde_json::json!({
1799 "status": "ok",
1800 "branch": branch_name.clone(),
1801 "default_branch": ctx.default_branch.clone(),
1802 "phase": "review",
1803 })),
1804 )
1805 .await;
1806 if ctx.had_prev_push_failure {
1807 if let Err(e) = store_set_result(
1808 &Some(Arc::clone(store)),
1809 repo,
1810 &task.id.0,
1811 &[
1812 ("last_error", serde_json::json!("")),
1813 ("push_failures", serde_json::json!(0)),
1814 ],
1815 )
1816 .await
1817 {
1818 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1819 }
1820 }
1821 Ok(())
1822 }
1823 Err(e) => {
1824 store_log_activity(
1825 &Some(Arc::clone(store)),
1826 repo,
1827 &task.id.0,
1828 "push",
1829 None,
1830 None,
1831 Some(&ctx.review_agent),
1832 ctx.review_model.as_deref(),
1833 Some(&serde_json::json!({
1834 "status": "error",
1835 "branch": branch_name.clone(),
1836 "default_branch": ctx.default_branch.clone(),
1837 "phase": "review",
1838 "error": e.to_string(),
1839 })),
1840 )
1841 .await;
1842 tracing::error!(
1843 task_id = task.id.0,
1844 branch = %ctx.branch_name,
1845 error = %e,
1846 "review push failed"
1847 );
1848 store_set(
1849 &Some(Arc::clone(store)),
1850 repo,
1851 &task.id.0,
1852 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1853 )
1854 .await;
1855 Err(e.to_string())
1856 }
1857 }
1858}
1859
1860fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1861 match decision {
1862 ReviewDecision::Approve => {
1863 format!(
1864 "## Automated Review \u{2014} Approve\n\n{}",
1865 review_notes_for_comment
1866 )
1867 }
1868 ReviewDecision::RequestChanges { notes, issues } => {
1869 let mut body = format!(
1870 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1871 notes
1872 );
1873 if !issues.is_empty() {
1874 body.push_str("\n**Issues Found:**\n");
1875 for issue in issues {
1876 body.push_str(&format!(
1877 "- `{}` line {}: {} [{}]\n",
1878 issue.file,
1879 issue
1880 .line
1881 .map(|line| line.to_string())
1882 .unwrap_or_else(|| "?".to_string()),
1883 issue.description,
1884 issue.severity
1885 ));
1886 }
1887 }
1888 body
1889 }
1890 _ => String::new(),
1891 }
1892}
1893
1894const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1895
1896fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1898 match decision {
1899 ReviewDecision::Approve => Some(("success", "Approved by automated review")),
1900 ReviewDecision::RequestChanges { .. } => {
1901 Some(("failure", "Automated review requested changes"))
1902 }
1903 _ => None,
1904 }
1905}
1906
1907async fn post_review_status(
1910 gh: &GhHttp,
1911 repo: &str,
1912 sha: Option<&str>,
1913 state: &str,
1914 description: &str,
1915) {
1916 let Some(sha) = sha else {
1917 return;
1918 };
1919 if let Err(e) = gh
1920 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1921 .await
1922 {
1923 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1924 }
1925}
1926
1927async fn post_review_comment(
1928 task: &ExternalTask,
1929 repo: &str,
1930 ctx: &ReviewContext,
1931 decision: &ReviewDecision,
1932 review_notes_for_comment: &str,
1933) -> anyhow::Result<()> {
1934 let gh = match GhHttp::new() {
1935 Ok(gh) => gh,
1936 Err(e) => {
1937 tracing::warn!(
1938 task_id = task.id.0,
1939 error = %e,
1940 "failed to create GH client for review comment"
1941 );
1942 return Ok(());
1943 }
1944 };
1945 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1946 if ctx.same_agent_fallback && !pr_comment.is_empty() {
1947 pr_comment.push_str(
1948 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1949 );
1950 }
1951
1952 let head_sha = worktree_head_sha(&ctx.worktree_path)
1955 .await
1956 .or_else(|| ctx.head_sha.clone());
1957
1958 let app = review_app(repo).await;
1959 post_review_decision(
1960 app.as_deref(),
1961 &gh,
1962 repo,
1963 head_sha.as_deref(),
1964 decision,
1965 review_notes_for_comment,
1966 )
1967 .await;
1968
1969 if !pr_comment.is_empty() {
1970 let footer =
1971 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
1972 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
1973 if let Err(e) = publish_review(
1974 app.as_deref(),
1975 &gh,
1976 repo,
1977 ctx.pr_number,
1978 head_sha.as_deref(),
1979 decision,
1980 &pr_comment_with_footer,
1981 )
1982 .await
1983 {
1984 tracing::warn!(
1985 task_id = task.id.0,
1986 pr_number = ctx.pr_number,
1987 error = %e,
1988 "failed to post automated review comment on PR"
1989 );
1990 }
1991 }
1992
1993 Ok(())
1994}
1995
1996async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
1998 let mode = crate::review_mode::for_repo(repo);
1999 review_app_for(mode, crate::github::app::shared(), repo).await
2000}
2001
2002async fn review_app_for(
2003 mode: crate::review_mode::ReviewMode,
2004 app: Option<Arc<crate::github::app::AppAuth>>,
2005 repo: &str,
2006) -> Option<Arc<crate::github::app::AppAuth>> {
2007 if mode != crate::review_mode::ReviewMode::App {
2008 return None;
2009 }
2010
2011 let app = app?;
2012 match app.token_for_repo(repo).await {
2013 Ok(Some(_)) => Some(app),
2014 Ok(None) => {
2015 tracing::info!(
2016 repo,
2017 "review.mode is app but the App is not installed here, reviewing with a comment"
2018 );
2019 None
2020 }
2021 Err(e) => {
2022 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2023 None
2024 }
2025 }
2026}
2027
2028async fn post_review_started(
2030 app: Option<&crate::github::app::AppAuth>,
2031 gh: &GhHttp,
2032 repo: &str,
2033 sha: Option<&str>,
2034) {
2035 use crate::github::app::CheckRunState;
2036 match (app, sha) {
2037 (Some(app), Some(sha)) => {
2038 if let Err(e) = app
2039 .post_check_run(repo, sha, CheckRunState::InProgress)
2040 .await
2041 {
2042 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2043 }
2044 }
2045 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2046 }
2047}
2048
2049async fn post_review_decision(
2050 app: Option<&crate::github::app::AppAuth>,
2051 gh: &GhHttp,
2052 repo: &str,
2053 sha: Option<&str>,
2054 decision: &ReviewDecision,
2055 notes: &str,
2056) {
2057 use crate::github::app::CheckRunState;
2058 let Some((state, description)) = review_status(decision) else {
2059 return;
2060 };
2061
2062 match (app, sha) {
2063 (Some(app), Some(sha)) => {
2064 let approved = state == "success";
2065 let completed = CheckRunState::Completed {
2066 approved,
2067 summary: if notes.is_empty() { description } else { notes },
2068 };
2069 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2070 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2071 }
2072 }
2073 _ => post_review_status(gh, repo, sha, state, description).await,
2074 }
2075}
2076
2077async fn publish_review(
2081 app: Option<&crate::github::app::AppAuth>,
2082 gh: &GhHttp,
2083 repo: &str,
2084 pr_number: u64,
2085 head_sha: Option<&str>,
2086 decision: &ReviewDecision,
2087 comment: &str,
2088) -> anyhow::Result<()> {
2089 use crate::github::app::ReviewEvent;
2090 let event = match decision {
2091 ReviewDecision::Approve => Some(ReviewEvent::Approve),
2092 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2093 _ => None,
2094 };
2095 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2096 let inline = inline_comments(app, repo, pr_number, decision).await;
2097 if let Err(e) = app
2098 .submit_review(repo, pr_number, event, comment, sha, &inline)
2099 .await
2100 {
2101 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2102 }
2103 }
2104 gh.add_comment(repo, &pr_number.to_string(), comment).await
2105}
2106
2107async fn inline_comments(
2110 app: &crate::github::app::AppAuth,
2111 repo: &str,
2112 pr_number: u64,
2113 decision: &ReviewDecision,
2114) -> Vec<crate::github::app::InlineComment> {
2115 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2116 return Vec::new();
2117 };
2118
2119 if issues.is_empty() {
2120 return Vec::new();
2121 }
2122
2123 match app.pull_request_hunks(repo, pr_number).await {
2124 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2125 Err(e) => {
2126 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2127 Vec::new()
2128 }
2129 }
2130}
2131
2132#[allow(clippy::too_many_arguments)]
2133async fn apply_review_decision(
2134 task: &ExternalTask,
2135 backend: &Arc<dyn ExternalBackend>,
2136 repo: &str,
2137 task_manager: &Arc<TaskManager>,
2138 store: &Arc<TaskStore>,
2139 ctx: &ReviewContext,
2140 parsed: &ParsedReview,
2141 router_config: &crate::engine::router::RouterConfig,
2142) -> anyhow::Result<ReviewDecision> {
2143 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2144
2145 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2146 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2147 }
2148
2149 post_review_comment(
2150 task,
2151 repo,
2152 ctx,
2153 &parsed.decision,
2154 &parsed.review_notes_for_comment,
2155 )
2156 .await?;
2157
2158 match &parsed.decision {
2159 ReviewDecision::Approve => {
2160 record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
2161 if let Err(e) = auto_merge_pr(
2166 task,
2167 &ctx.branch_name,
2168 backend,
2169 repo,
2170 &ctx.review_agent,
2171 ctx.review_model_str(),
2172 task_manager,
2173 store,
2174 )
2175 .await
2176 {
2177 let error_msg = e.to_string();
2178 if error_msg.contains("not yet computed") {
2179 tracing::warn!(
2180 task_id = task.id.0,
2181 pr_number = ctx.pr_number,
2182 branch = %ctx.branch_name,
2183 error = %e,
2184 "auto-merge deferred — PR mergeability not yet computed"
2185 );
2186 } else {
2187 tracing::error!(
2188 task_id = task.id.0,
2189 pr_number = ctx.pr_number,
2190 branch = %ctx.branch_name,
2191 error = %e,
2192 "auto-merge failed"
2193 );
2194 }
2195 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2196 }
2197 Ok(ReviewDecision::Approve)
2198 }
2199 ReviewDecision::RequestChanges { notes, issues } => {
2200 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2201 handle_review_changes(
2202 task,
2203 notes,
2204 issues,
2205 backend,
2206 repo,
2207 ctx.pr_number,
2208 &ctx.review_agent,
2209 ctx.review_model_str(),
2210 task_manager,
2211 store,
2212 router_config,
2213 )
2214 .await?;
2215 Ok(parsed.decision.clone())
2216 }
2217 _ => Ok(parsed.decision.clone()),
2218 }
2219}
2220
2221async fn recover_missing_review_worktree(
2222 task: &ExternalTask,
2223 repo: &str,
2224 store: &Arc<TaskStore>,
2225) -> anyhow::Result<worktree::WorktreeSetup> {
2226 let project_dir = crate::config::get_projects_with_paths()?
2227 .into_iter()
2228 .find(|(candidate_repo, _)| candidate_repo == repo)
2229 .map(|(_, project_dir)| project_dir)
2230 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2231
2232 tracing::warn!(
2233 task_id = task.id.0,
2234 repo,
2235 project_dir = %project_dir.display(),
2236 "review worktree missing, recreating via normal setup"
2237 );
2238
2239 let store_ref = Some(Arc::clone(store));
2240 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2241 .await
2242 .context("recreating missing review worktree")?;
2243
2244 if !wt.work_dir.exists() {
2245 anyhow::bail!(
2246 "recreated review worktree at {} for task {} is still missing",
2247 wt.work_dir.display(),
2248 task.id.0
2249 );
2250 }
2251
2252 Ok(wt)
2253}
2254
2255async fn count_ahead_commits(
2261 worktree_path: &std::path::Path,
2262 default_branch: &str,
2263) -> Result<u64, String> {
2264 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2265 format!(
2266 "worktree path contains non-UTF-8 characters: {:?}",
2267 worktree_path
2268 )
2269 })?;
2270
2271 let output = tokio::process::Command::new("git")
2272 .args([
2273 "-C",
2274 worktree_str,
2275 "rev-list",
2276 "--count",
2277 &format!("origin/{default_branch}..HEAD"),
2278 ])
2279 .output()
2280 .await
2281 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2282
2283 if !output.status.success() {
2284 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2285 return Err(format!(
2286 "git rev-list exited with {:?}: {}",
2287 output.status.code(),
2288 if stderr.is_empty() {
2289 String::from_utf8_lossy(&output.stdout).trim().to_string()
2290 } else {
2291 stderr
2292 }
2293 ));
2294 }
2295
2296 String::from_utf8_lossy(&output.stdout)
2297 .trim()
2298 .parse::<u64>()
2299 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2300}
2301
2302#[allow(clippy::too_many_arguments)]
2309async fn ensure_pr_exists(
2310 task: &ExternalTask,
2311 branch_name: &str,
2312 worktree_path: &std::path::Path,
2313 repo: &str,
2314 agent_summary: &str,
2315 store: &Arc<TaskStore>,
2316 task_manager: &Arc<TaskManager>,
2317 stored_pr_number: Option<u64>,
2318) -> anyhow::Result<EnsurePrResult> {
2319 let gh_check = GhHttp::new()?;
2320
2321 if let Some(n) = stored_pr_number {
2322 tracing::info!(
2323 task_id = task.id.0,
2324 pr_number = n,
2325 branch = %branch_name,
2326 "open PR found in store, proceeding with review"
2327 );
2328 return Ok(EnsurePrResult::Ready(n));
2329 }
2330
2331 match gh_check.get_pr_number(repo, branch_name).await {
2332 Ok(Some(n)) => {
2333 tracing::info!(
2334 task_id = task.id.0,
2335 pr_number = n,
2336 branch = %branch_name,
2337 "open PR found, proceeding with review"
2338 );
2339 Ok(EnsurePrResult::Ready(n))
2340 }
2341 Ok(None) => {
2342 let default_branch = worktree::detect_default_branch(worktree_path).await;
2344 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2345 Ok(count) => count > 0,
2346 Err(e) => {
2347 tracing::error!(
2348 task_id = task.id.0,
2349 branch = %branch_name,
2350 default_branch = %default_branch,
2351 err = %e,
2352 "could not determine if branch has commits ahead of default"
2353 );
2354 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2355 format!("git ancestry check failed: {e}"),
2356 )));
2357 }
2358 };
2359
2360 if has_commits {
2361 tracing::warn!(
2363 task_id = task.id.0,
2364 branch = %branch_name,
2365 "no open PR but branch has commits — attempting to create PR"
2366 );
2367 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2368 anyhow::anyhow!(
2369 "worktree path contains non-UTF-8 characters: {:?}",
2370 worktree_path
2371 )
2372 })?;
2373 let auth_env = runner::git_ops::build_git_auth_env().await;
2375
2376 let push_timeout = std::time::Duration::from_secs(120);
2381 match tokio::time::timeout(
2382 push_timeout,
2383 tokio::process::Command::new("git")
2384 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2385 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2386 .output(),
2387 )
2388 .await
2389 {
2390 Err(_elapsed) => {
2391 tracing::warn!(
2392 task_id = task.id.0,
2393 branch = %branch_name,
2394 timeout_secs = push_timeout.as_secs(),
2395 "review gate fallback push timed out"
2396 );
2397 }
2398 Ok(Ok(o)) => {
2399 if !o.status.success() {
2400 tracing::warn!(
2401 task_id = task.id.0,
2402 branch = %branch_name,
2403 exit_code = ?o.status.code(),
2404 stdout = %String::from_utf8_lossy(&o.stdout),
2405 stderr = %String::from_utf8_lossy(&o.stderr),
2406 "review gate fallback push failed"
2407 );
2408 } else {
2409 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2410 }
2411 }
2412 Ok(Err(e)) => {
2413 tracing::error!(
2414 task_id = task.id.0,
2415 branch = %branch_name,
2416 error = %e,
2417 "review gate fallback push command failed to run"
2418 );
2419 }
2420 }
2421
2422 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2423 let pr_body = format!(
2424 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2425 );
2426 let gh = GhHttp::new()?;
2427 match gh
2428 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2429 .await
2430 {
2431 Ok(url) => match parse_pr_number_from_url(&url) {
2432 Ok(pr_num) => {
2433 store_set_result(
2434 &Some(Arc::clone(store)),
2435 repo,
2436 &task.id.0,
2437 &[("pr_number", serde_json::json!(pr_num as i64))],
2438 )
2439 .await?;
2440 tracing::info!(
2441 task_id = task.id.0,
2442 branch = %branch_name,
2443 pr_url = %url,
2444 "created missing PR via GhHttp — continuing review"
2445 );
2446 Ok(EnsurePrResult::Ready(pr_num))
2447 }
2448 Err(e) => {
2449 tracing::warn!(
2450 task_id = task.id.0,
2451 branch = %branch_name,
2452 pr_url = %url,
2453 error = %e,
2454 "created missing PR via GhHttp, but failed to parse PR number"
2455 );
2456 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2457 format!("created missing PR, but could not parse PR number: {e}"),
2458 )))
2459 }
2460 },
2461 Err(e) => {
2462 let e_str = format!("{e}");
2463 if e_str.contains("already exists") {
2464 let backoff_delays = [2u64, 4u64];
2468 let mut found_pr: Option<u64> = None;
2469 for delay_secs in backoff_delays {
2470 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2471 .await;
2472 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2473 {
2474 found_pr = Some(n);
2475 break;
2476 }
2477 }
2478 if let Some(n) = found_pr {
2479 tracing::info!(
2480 task_id = task.id.0,
2481 pr_number = n,
2482 branch = %branch_name,
2483 "found existing PR after create_pr 422 — retrying review"
2484 );
2485 store_set_result(
2486 &Some(Arc::clone(store)),
2487 repo,
2488 &task.id.0,
2489 &[("pr_number", serde_json::json!(n as i64))],
2490 )
2491 .await?;
2492 return Ok(EnsurePrResult::Ready(n));
2493 }
2494 }
2495 tracing::warn!(
2496 task_id = task.id.0,
2497 branch = %branch_name,
2498 error = %e,
2499 "create_pr failed via GhHttp, falling back to CLI"
2500 );
2501 let pr_timeout = std::time::Duration::from_secs(120);
2504 let mut gh_cmd = tokio::process::Command::new("gh");
2505 gh_cmd.args([
2506 "pr",
2507 "create",
2508 "--repo",
2509 repo,
2510 "--head",
2511 branch_name,
2512 "--title",
2513 &task.title,
2514 "--body",
2515 &pr_body,
2516 ]);
2517 gh_cmd.current_dir(worktree_path);
2518 gh_cmd.kill_on_drop(true);
2519
2520 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2521 match pr_result {
2522 Ok(Ok(o)) if o.status.success() => {
2523 let stdout = String::from_utf8_lossy(&o.stdout);
2524 match parse_pr_number_from_url(stdout.trim()) {
2525 Ok(pr_num) => {
2526 store_set_result(
2527 &Some(Arc::clone(store)),
2528 repo,
2529 &task.id.0,
2530 &[("pr_number", serde_json::json!(pr_num as i64))],
2531 )
2532 .await?;
2533 tracing::info!(
2534 task_id = task.id.0,
2535 branch = %branch_name,
2536 "created missing PR via CLI — continuing review"
2537 );
2538 Ok(EnsurePrResult::Ready(pr_num))
2539 }
2540 Err(e) => {
2541 tracing::warn!(
2542 task_id = task.id.0,
2543 branch = %branch_name,
2544 stdout = %stdout,
2545 error = %e,
2546 "created missing PR via CLI, but failed to parse PR number"
2547 );
2548 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2549 format!("created missing PR via CLI, but could not parse PR number: {e}")
2550 )))
2551 }
2552 }
2553 }
2554 Ok(Ok(o)) => {
2555 let stderr = String::from_utf8_lossy(&o.stderr);
2556 if stderr.contains("already exists") {
2557 if let Some(pr_url) =
2558 stderr.lines().find(|l| l.trim().starts_with("https://"))
2559 {
2560 match parse_pr_number_from_url(pr_url.trim()) {
2561 Ok(pr_num) => {
2562 if let Err(e) = store_set_result(
2563 &Some(Arc::clone(store)),
2564 repo,
2565 &task.id.0,
2566 &[(
2567 "pr_number",
2568 serde_json::json!(pr_num as i64),
2569 )],
2570 )
2571 .await
2572 {
2573 tracing::warn!(
2574 task_id = task.id.0,
2575 pr_num,
2576 err = %e,
2577 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2578 );
2579 }
2580 tracing::info!(
2581 task_id = task.id.0,
2582 branch = %branch_name,
2583 pr_url = %pr_url,
2584 "PR already exists (from CLI stderr) — retrying review"
2585 );
2586 return Ok(EnsurePrResult::Ready(pr_num));
2587 }
2588 Err(e) => {
2589 tracing::warn!(
2590 task_id = task.id.0,
2591 branch = %branch_name,
2592 pr_url = %pr_url,
2593 error = %e,
2594 "found existing PR URL in CLI stderr, but failed to parse PR number"
2595 );
2596 }
2598 }
2599 }
2600 }
2601 tracing::error!(
2602 task_id = task.id.0,
2603 branch = %branch_name,
2604 stderr = %stderr,
2605 "failed to create missing PR — work may be stuck"
2606 );
2607
2608 if crate::engine::runner::git_ops::is_transient_github_api_error(
2615 &stderr,
2616 ) {
2617 tracing::warn!(
2618 task_id = task.id.0,
2619 branch = %branch_name,
2620 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2621 );
2622 return Ok(EnsurePrResult::EarlyReturn(
2623 ReviewDecision::Failed(format!(
2624 "transient github error creating PR: {stderr}"
2625 )),
2626 ));
2627 }
2628
2629 match store_increment(
2630 &Some(Arc::clone(store)),
2631 repo,
2632 &task.id.0,
2633 "pr_create_failures",
2634 )
2635 .await
2636 {
2637 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2638 return Ok(EnsurePrResult::EarlyReturn(
2639 ReviewDecision::Blocked(format!(
2640 "no PR, create failed {failures} times: {stderr}"
2641 )),
2642 ));
2643 }
2644 Ok(_) => {}
2645 Err(e) => {
2646 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2647 }
2648 }
2649 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2650 format!("no PR, create failed: {stderr}"),
2651 )))
2652 }
2653 Ok(Err(e)) => {
2654 tracing::error!(
2655 task_id = task.id.0,
2656 error = %e,
2657 "failed to run gh pr create"
2658 );
2659 let e_str = format!("{e}");
2660 if crate::engine::runner::git_ops::is_transient_github_api_error(
2661 &e_str,
2662 ) {
2663 tracing::warn!(
2664 task_id = task.id.0,
2665 branch = %branch_name,
2666 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2667 );
2668 return Ok(EnsurePrResult::EarlyReturn(
2669 ReviewDecision::Failed(format!("transient gh error: {e}")),
2670 ));
2671 }
2672
2673 match store_increment(
2674 &Some(Arc::clone(store)),
2675 repo,
2676 &task.id.0,
2677 "pr_create_failures",
2678 )
2679 .await
2680 {
2681 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2682 return Ok(EnsurePrResult::EarlyReturn(
2683 ReviewDecision::Blocked(format!(
2684 "no PR, gh error {failures} times: {e}"
2685 )),
2686 ));
2687 }
2688 Ok(_) => {}
2689 Err(e) => {
2690 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2691 }
2692 }
2693 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2694 format!("no PR, gh error: {e}"),
2695 )))
2696 }
2697 Err(_elapsed) => {
2698 tracing::error!(
2700 task_id = task.id.0,
2701 timeout_secs = pr_timeout.as_secs(),
2702 "gh pr create CLI fallback timed out"
2703 );
2704 let e_str = format!(
2705 "gh pr create timed out after {}s",
2706 pr_timeout.as_secs()
2707 );
2708 if crate::engine::runner::git_ops::is_transient_github_api_error(
2709 &e_str,
2710 ) {
2711 tracing::warn!(
2712 task_id = task.id.0,
2713 branch = %branch_name,
2714 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2715 );
2716 return Ok(EnsurePrResult::EarlyReturn(
2717 ReviewDecision::Failed(format!(
2718 "transient gh error: {e_str}"
2719 )),
2720 ));
2721 }
2722
2723 match store_increment(
2724 &Some(Arc::clone(store)),
2725 repo,
2726 &task.id.0,
2727 "pr_create_failures",
2728 )
2729 .await
2730 {
2731 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2732 return Ok(EnsurePrResult::EarlyReturn(
2733 ReviewDecision::Blocked(format!(
2734 "no PR, gh timeout {failures} times: {e_str}"
2735 )),
2736 ));
2737 }
2738 Ok(_) => {}
2739 Err(e) => {
2740 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2741 }
2742 }
2743 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2744 format!("no PR, gh timeout: {e_str}"),
2745 )))
2746 }
2747 }
2748 }
2749 }
2750 } else {
2751 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2753 Ok(v) => v,
2754 Err(e) => {
2755 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2756 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2757 format!("merge check failed: {e}"),
2758 )));
2759 }
2760 };
2761 if merged {
2762 tracing::info!(
2763 task_id = task.id.0,
2764 branch = %branch_name,
2765 "PR already merged, marking done"
2766 );
2767 if let Err(e) = task_manager
2768 .update_task_status(&task.id, Status::Done)
2769 .await
2770 {
2771 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2772 }
2773 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2774 }
2775
2776 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2777 .await
2778 .map(|t| t.last_error)
2779 .unwrap_or_default();
2780 let reason = if !agent_summary.is_empty() {
2781 agent_summary.to_string()
2782 } else {
2783 last_error.clone()
2784 };
2785
2786 if last_error.contains("exceeded max attempts") {
2787 tracing::warn!(
2788 task_id = task.id.0,
2789 branch = %branch_name,
2790 "no PR and no commits after max attempts — marking blocked to stop loop"
2791 );
2792 let fields = [
2796 (
2797 "block_reason",
2798 serde_json::json!("max attempts exceeded — no PR or commits produced"),
2799 ),
2800 ("last_error", serde_json::json!(last_error)),
2801 ];
2802 if let Err(e) = task_manager
2803 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2804 .await
2805 {
2806 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2807 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2808 format!("failed to write block_reason: {e}"),
2809 )));
2810 }
2811 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2812 }
2813
2814 if last_error.contains("422") && last_error.contains("head") {
2815 tracing::info!(
2816 task_id = task.id.0,
2817 branch = %branch_name,
2818 "no PR — 422/head-invalid means work already merged, marking done"
2819 );
2820 if let Err(e) = task_manager
2821 .update_task_status(&task.id, Status::Done)
2822 .await
2823 {
2824 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2825 }
2826 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2827 }
2828
2829 if last_error.contains("no fallback agents")
2830 || last_error.contains("all agents exhausted")
2831 {
2832 tracing::warn!(
2833 task_id = task.id.0,
2834 branch = %branch_name,
2835 last_error = %last_error,
2836 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2837 );
2838 let fields = [
2842 (
2843 "block_reason",
2844 serde_json::json!("all agents exhausted — no PR or commits produced"),
2845 ),
2846 ("last_error", serde_json::json!(last_error)),
2847 ];
2848 if let Err(e) = task_manager
2849 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2850 .await
2851 {
2852 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2853 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2854 format!("failed to write block_reason: {e}"),
2855 )));
2856 }
2857 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2858 }
2859
2860 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2867 .ok()
2868 .and_then(|s| s.parse().ok())
2869 .or_else(|| {
2870 config::get("workflow.max_attempts")
2871 .ok()
2872 .and_then(|s| s.parse().ok())
2873 })
2874 .unwrap_or(3);
2875
2876 tracing::warn!(
2877 task_id = task.id.0,
2878 branch = %branch_name,
2879 reason = %reason,
2880 "no PR and no commits — re-routing for retry"
2881 );
2882
2883 if should_skip_no_code_reroute_increment(&last_error) {
2887 tracing::warn!(
2888 task_id = task.id.0,
2889 last_error = %last_error,
2890 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2891 );
2892 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2893 format!("transient github error: {last_error}"),
2894 )));
2895 }
2896
2897 let reroutes = match store_increment(
2899 &Some(Arc::clone(store)),
2900 repo,
2901 &task.id.0,
2902 "no_code_reroutes",
2903 )
2904 .await
2905 {
2906 Ok(v) => v,
2907 Err(e) => {
2908 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2909 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2911 format!("transient store error: {e}"),
2912 )));
2913 }
2914 };
2915
2916 if reroutes as u32 >= max_reroutes {
2917 tracing::error!(
2918 task_id = task.id.0,
2919 reroutes,
2920 max_reroutes,
2921 "reached max reroute attempts for no-pr-result — blocking for human review"
2922 );
2923 let msg = format!(
2924 "no PR or code changes after {}/{} reroute attempts",
2925 reroutes, max_reroutes
2926 );
2927 let fields = [
2931 (
2932 "block_reason",
2933 serde_json::json!(format!(
2934 "max reroute attempts ({}) reached — no PR or code changes produced",
2935 max_reroutes
2936 )),
2937 ),
2938 ("last_error", serde_json::json!(msg)),
2939 ("agent", serde_json::json!(null)),
2940 ("model", serde_json::json!(null)),
2941 ];
2942 if let Err(e) = task_manager
2943 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2944 .await
2945 {
2946 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2947 }
2948 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2949 } else if task.id.0.starts_with("internal:") {
2950 tracing::info!(
2957 task_id = task.id.0,
2958 "no PR and no commits for internal task — marking done"
2959 );
2960 if let Err(e) = task_manager
2961 .update_task_status(&task.id, Status::Done)
2962 .await
2963 {
2964 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2965 }
2966 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
2967 } else {
2968 if let Err(e) = store_set_result(
2973 &Some(Arc::clone(store)),
2974 repo,
2975 &task.id.0,
2976 &[
2977 ("agent", serde_json::json!(null)),
2978 ("model", serde_json::json!(null)),
2979 (
2980 "last_error",
2981 serde_json::json!("no PR or code changes produced"),
2982 ),
2983 ],
2984 )
2985 .await
2986 {
2987 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
2988 } else if let Err(e) =
2990 task_manager.update_task_status(&task.id, Status::New).await
2991 {
2992 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
2993 }
2994 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
2995 }
2996 }
2997 }
2998 Err(e) => {
2999 tracing::warn!(
3000 task_id = task.id.0,
3001 branch = %branch_name,
3002 error = %e,
3003 "failed to check PR status"
3004 );
3005 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3006 format!("PR check failed: {e}"),
3007 )))
3008 }
3009 }
3010}
3011
3012pub(crate) async fn review_and_merge(
3018 task: &ExternalTask,
3019 backend: &Arc<dyn ExternalBackend>,
3020 tmux: &Arc<TmuxManager>,
3021 repo: &str,
3022 router: &Arc<RwLock<Router>>,
3023 task_manager: &Arc<TaskManager>,
3024 store: &Arc<TaskStore>,
3025) -> anyhow::Result<ReviewDecision> {
3026 set_review_session_expected(store, repo, &task.id.0, true).await;
3027
3028 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3029 ReviewPhase::Ready(ctx) => ctx,
3030 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3031 };
3032
3033 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3034 ReviewPhase::Ready(run) => run,
3035 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3036 };
3037
3038 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3039 ReviewPhase::Ready(parsed) => parsed,
3040 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3041 };
3042
3043 finalize_review_run(task, &ctx, &parsed, store).await;
3044
3045 let router_config = router.read().await.config.clone();
3046 apply_review_decision(
3047 task,
3048 backend,
3049 repo,
3050 task_manager,
3051 store,
3052 &ctx,
3053 &parsed,
3054 &router_config,
3055 )
3056 .await
3057}
3058
3059#[cfg(test)]
3060mod tests {
3061 use super::*;
3062
3063 use crate::backends::ExternalTask;
3064 use crate::engine::router::RouterConfig;
3065 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3066 use crate::store::{TaskStatus, TaskStore};
3067 use tempfile::TempDir;
3068
3069 #[test]
3072 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3073 let err = runner::agents::AgentError::RateLimit {
3077 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3078 for this billing cycle. Your quota will be refreshed soon."
3079 .to_string(),
3080 };
3081 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3082 }
3083
3084 #[test]
3085 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3086 let err = runner::agents::AgentError::RateLimit {
3087 message: "429 Too Many Requests".to_string(),
3088 };
3089 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3090 }
3091
3092 #[test]
3093 fn outcome_for_agent_error_generic_auth_unaffected() {
3094 let err = runner::agents::AgentError::Auth {
3095 message: "401 Unauthorized".to_string(),
3096 };
3097 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3098 }
3099
3100 #[test]
3103 fn parse_pr_number_standard_url() {
3104 assert_eq!(
3105 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3106 Ok(42)
3107 );
3108 }
3109
3110 #[test]
3111 fn parse_pr_number_trailing_slash() {
3112 assert_eq!(
3113 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3114 Ok(42)
3115 );
3116 }
3117
3118 #[test]
3119 fn parse_pr_number_with_query_string() {
3120 assert_eq!(
3121 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3122 Ok(42)
3123 );
3124 }
3125
3126 #[test]
3127 fn parse_pr_number_with_hash_fragment() {
3128 assert_eq!(
3129 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3130 Ok(42)
3131 );
3132 }
3133
3134 #[test]
3135 fn parse_pr_number_with_query_and_hash() {
3136 assert_eq!(
3137 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3138 Ok(42)
3139 );
3140 }
3141
3142 #[test]
3143 fn parse_pr_number_with_leading_whitespace() {
3144 assert_eq!(
3145 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3146 Ok(42)
3147 );
3148 }
3149
3150 #[test]
3151 fn parse_pr_number_wrong_domain() {
3152 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3153 let result = parse_pr_number_from_url(url);
3154 assert!(result.is_err());
3155 assert!(result
3156 .unwrap_err()
3157 .contains("does not start with 'https://github.com/'"));
3158 }
3159
3160 #[test]
3161 fn parse_pr_number_missing_pull_segment() {
3162 let url = "https://github.com/owner/repo/issues/42";
3164 let result = parse_pr_number_from_url(url);
3165 assert!(result.is_err());
3166 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3167 }
3168
3169 #[test]
3170 fn parse_pr_number_zero_is_rejected() {
3171 let url = "https://github.com/owner/repo/pull/0";
3172 let result = parse_pr_number_from_url(url);
3173 assert!(result.is_err());
3174 assert!(result.unwrap_err().contains("cannot be zero"));
3175 }
3176
3177 #[test]
3178 fn parse_pr_number_non_numeric_segment() {
3179 let url = "https://github.com/owner/repo/pull/abc";
3180 let result = parse_pr_number_from_url(url);
3181 assert!(result.is_err());
3182 assert!(result.unwrap_err().contains("not a valid positive integer"));
3183 }
3184
3185 #[test]
3186 fn parse_pr_number_empty_string() {
3187 let result = parse_pr_number_from_url("");
3188 assert!(result.is_err());
3189 assert!(result.unwrap_err().contains("empty"));
3190 }
3191
3192 #[test]
3193 fn parse_pr_number_http_not_https() {
3194 let url = "http://github.com/owner/repo/pull/42";
3196 let result = parse_pr_number_from_url(url);
3197 assert!(result.is_err());
3198 assert!(result
3199 .unwrap_err()
3200 .contains("does not start with 'https://github.com/'"));
3201 }
3202
3203 #[test]
3211 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3212 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3216 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3217 let err = runner::agents::AgentError::RateLimit {
3218 message: raw_fragment.to_string(),
3219 };
3220
3221 let stored = format_review_error("minimax", &err);
3222
3223 assert!(
3226 !stored.contains("{\"type\""),
3227 "stored error must not contain raw NDJSON envelope, got: {stored}"
3228 );
3229 assert!(
3230 !stored.contains("subtype"),
3231 "stored error must not contain raw api_retry fragment, got: {stored}"
3232 );
3233 assert!(
3234 !stored.contains("session_id"),
3235 "stored error must not contain session_id from provider payload, got: {stored}"
3236 );
3237 assert_eq!(
3238 stored,
3239 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3240 );
3241 }
3242
3243 #[test]
3244 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3245 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3250 let err = runner::agents::AgentError::RateLimit {
3251 message: raw_fragment.to_string(),
3252 };
3253
3254 let stored = format_review_error("glm", &err);
3255
3256 assert!(stored.starts_with("glm rate limit:"));
3257 assert!(
3258 !stored.contains("session_id"),
3259 "stored error must not leak session_id from truncated fragment, got: {stored}"
3260 );
3261 assert!(
3264 stored.len() <= 200,
3265 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3266 stored.len()
3267 );
3268 }
3269
3270 #[test]
3271 fn format_review_error_preserves_non_rate_limit_variants() {
3272 let err = runner::agents::AgentError::Auth {
3273 message: "invalid bearer token".to_string(),
3274 };
3275 assert_eq!(
3276 format_review_error("kimi", &err),
3277 "kimi auth error: invalid bearer token"
3278 );
3279
3280 let err = runner::agents::AgentError::Timeout {
3281 elapsed: std::time::Duration::from_secs(1801),
3282 };
3283 assert_eq!(
3284 format_review_error("kimi", &err),
3285 "kimi timed out after 1801s"
3286 );
3287 }
3288
3289 fn git(dir: &std::path::Path, args: &[&str]) {
3290 let mut cmd = std::process::Command::new("git");
3291 cmd.current_dir(dir);
3292 if args.first() == Some(&"commit") {
3294 cmd.args(["-c", "commit.gpgsign=false"]);
3295 }
3296 let status = cmd.args(args).status().unwrap();
3297 assert!(status.success(), "git {:?} failed", args);
3298 }
3299
3300 #[test]
3301 fn test_pull_request_review_requests_changes() {
3302 let review = PullRequestReview {
3303 review: GitHubReview {
3304 id: 1,
3305 user: GitHubUser {
3306 login: "reviewer".to_string(),
3307 },
3308 body: Some("Please fix".to_string()),
3309 state: "CHANGES_REQUESTED".to_string(),
3310 html_url: None,
3311 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3312 commit_id: None,
3313 },
3314 comments: vec![],
3315 };
3316 assert!(review.requests_changes());
3317 }
3318
3319 #[test]
3320 fn test_pull_request_review_does_not_request_changes() {
3321 let review = PullRequestReview {
3322 review: GitHubReview {
3323 id: 1,
3324 user: GitHubUser {
3325 login: "reviewer".to_string(),
3326 },
3327 body: Some("LGTM".to_string()),
3328 state: "APPROVED".to_string(),
3329 html_url: None,
3330 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3331 commit_id: None,
3332 },
3333 comments: vec![],
3334 };
3335 assert!(!review.requests_changes());
3336 }
3337
3338 #[test]
3339 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3340 let review = PullRequestReview {
3341 review: GitHubReview {
3342 id: 1,
3343 user: GitHubUser {
3344 login: "reviewer".to_string(),
3345 },
3346 body: None,
3347 state: "CHANGES_REQUESTED".to_string(),
3348 html_url: None,
3349 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3350 commit_id: None,
3351 },
3352 comments: vec![
3353 GitHubReviewComment {
3354 id: 1,
3355 user: GitHubUser {
3356 login: "reviewer".to_string(),
3357 },
3358 body: "Fix this issue".to_string(),
3359 path: "src/main.rs".to_string(),
3360 line: Some(10),
3361 original_line: Some(10),
3362 commit_id: "abc".to_string(),
3363 original_commit_id: "abc".to_string(),
3364 html_url: "url".to_string(),
3365 created_at: "2024-01-01T00:00:00Z".to_string(),
3366 updated_at: "2024-01-01T00:00:00Z".to_string(),
3367 in_reply_to_id: None,
3368 diff_hunk: Some(
3369 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3370 ),
3371 },
3372 GitHubReviewComment {
3373 id: 2,
3374 user: GitHubUser {
3375 login: "reviewer".to_string(),
3376 },
3377 body: "".to_string(),
3378 path: "src/lib.rs".to_string(),
3379 line: Some(20),
3380 original_line: Some(20),
3381 commit_id: "abc".to_string(),
3382 original_commit_id: "abc".to_string(),
3383 html_url: "url".to_string(),
3384 created_at: "2024-01-01T00:00:00Z".to_string(),
3385 updated_at: "2024-01-01T00:00:00Z".to_string(),
3386 in_reply_to_id: None,
3387 diff_hunk: None,
3388 },
3389 GitHubReviewComment {
3390 id: 3,
3391 user: GitHubUser {
3392 login: "reviewer".to_string(),
3393 },
3394 body: "Reply to this".to_string(),
3395 path: "src/lib.rs".to_string(),
3396 line: Some(30),
3397 original_line: Some(30),
3398 commit_id: "abc".to_string(),
3399 original_commit_id: "abc".to_string(),
3400 html_url: "url".to_string(),
3401 created_at: "2024-01-01T00:00:00Z".to_string(),
3402 updated_at: "2024-01-01T00:00:00Z".to_string(),
3403 in_reply_to_id: Some(1),
3404 diff_hunk: None,
3405 },
3406 ],
3407 };
3408 let actionable = review.actionable_comments();
3409 assert_eq!(actionable.len(), 1);
3410 assert_eq!(actionable[0].id, 1);
3411 assert_eq!(actionable[0].body, "Fix this issue");
3412 assert_eq!(actionable[0].path, "src/main.rs");
3413 assert_eq!(
3414 actionable[0].diff_hunk.as_ref().unwrap(),
3415 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3416 );
3417 }
3418
3419 #[test]
3420 fn test_router_config_model_for_complexity_returns_none_without_config() {
3421 let cfg = RouterConfig::default();
3424 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3425 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3426 assert!(cfg
3427 .model_for_complexity("unknown_agent_xyz", "simple", "")
3428 .is_none());
3429 }
3430
3431 #[test]
3432 fn review_status_maps_decisions() {
3433 assert_eq!(
3434 review_status(&ReviewDecision::Approve).unwrap().0,
3435 "success"
3436 );
3437 let changes = ReviewDecision::RequestChanges {
3438 notes: String::new(),
3439 issues: vec![],
3440 };
3441 assert_eq!(review_status(&changes).unwrap().0, "failure");
3442 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3443 }
3444
3445 #[test]
3446 fn review_started_comment_includes_agent_and_model() {
3447 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3448 let expected = format!("🔍 Automated review started{}", expected_footer);
3449 assert_eq!(review_started_comment("kimi", "opus"), expected);
3450 }
3451
3452 fn no_code_review_task(id: &str) -> ExternalTask {
3455 ExternalTask {
3456 id: crate::backends::ExternalId(id.to_string()),
3457 title: "opencode discovery timeout".to_string(),
3458 body: "".to_string(),
3459 state: "open".to_string(),
3460 labels: vec![],
3461 author: "tester".to_string(),
3462 created_at: "2026-01-01T00:00:00Z".to_string(),
3463 updated_at: "2026-01-01T00:00:00Z".to_string(),
3464 url: "https://example.com/issues/1".to_string(),
3465 }
3466 }
3467
3468 #[tokio::test]
3472 async fn carried_approval_counts_as_this_rounds_decision() {
3473 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3474 let id = store
3475 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3476 .await
3477 .unwrap();
3478 let task_id = format!("internal:{id}");
3479 store
3480 .update_status(id, crate::store::TaskStatus::InReview)
3481 .await
3482 .unwrap();
3483 assert_eq!(
3484 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3485 Some(false)
3486 );
3487
3488 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3489
3490 assert_eq!(
3491 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3492 Some(true),
3493 "the stale in_review check must see this round as decided"
3494 );
3495 }
3496
3497 #[test]
3498 fn approval_covers_head_only_for_the_exact_commit() {
3499 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3500 assert!(
3501 !approval_covers_head(Some("abc123"), Some("def456")),
3502 "a moved PR head must be reviewed"
3503 );
3504 assert!(
3505 !approval_covers_head(None, Some("abc123")),
3506 "no recorded approval means review"
3507 );
3508 assert!(
3509 !approval_covers_head(Some("abc123"), None),
3510 "an unreadable PR head must not skip the review"
3511 );
3512 assert!(!approval_covers_head(Some(""), Some("")));
3513 }
3514
3515 #[tokio::test]
3520 async fn approved_sha_round_trips_through_the_kv_store() {
3521 let temp = TempDir::new().unwrap();
3522 let wt = temp.path();
3523 git(wt, &["init", "-b", "main"]);
3524 git(wt, &["config", "user.name", "Test"]);
3525 git(wt, &["config", "user.email", "test@test.com"]);
3526 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3527 git(wt, &["add", "."]);
3528 git(wt, &["commit", "-m", "apply the fix"]);
3529
3530 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3531 let repo = "owner/repo";
3532 let task_id = "3783";
3533
3534 record_approved_sha(&store, repo, task_id, 77, wt).await;
3535 let recorded = store
3536 .kv_get(&approved_sha_key(repo, task_id, 77))
3537 .await
3538 .unwrap()
3539 .expect("approval must be recorded");
3540 assert_eq!(
3541 recorded,
3542 worktree_head_sha(wt).await.unwrap(),
3543 "the recorded sha must be the commit that was pushed to the PR"
3544 );
3545 assert!(
3546 store
3547 .kv_get(&approved_sha_key(repo, task_id, 78))
3548 .await
3549 .unwrap()
3550 .is_none(),
3551 "an approval must not leak to another PR"
3552 );
3553
3554 clear_approved_sha(&store, repo, task_id, 77).await;
3555 assert!(
3556 store
3557 .kv_get(&approved_sha_key(repo, task_id, 77))
3558 .await
3559 .unwrap()
3560 .is_none(),
3561 "requesting changes must drop the approval"
3562 );
3563 }
3564
3565 #[tokio::test]
3566 async fn build_review_context_reroutes_external_task_with_no_code() {
3567 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3568 let repo = "owner/repo";
3569 let ext = no_code_review_task("3617");
3570 store.ensure_external_task(repo, &ext).await.unwrap();
3571
3572 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3573 let task_manager = Arc::new(TaskManager::with_store(
3574 backend,
3575 Arc::clone(&store),
3576 repo.to_string(),
3577 ));
3578 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3579
3580 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3581 .await
3582 .unwrap();
3583
3584 assert!(
3585 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3586 "external task with no worktree/branch/pr must reroute, not skip-to-done"
3587 );
3588
3589 let stored = store
3590 .get_by_external_id(repo, &ext.id.0)
3591 .await
3592 .unwrap()
3593 .expect("task must still exist");
3594 assert_eq!(stored.status, TaskStatus::New);
3595 assert!(stored.agent.is_none());
3596 assert!(stored.model.is_none());
3597 }
3598
3599 #[tokio::test]
3600 async fn build_review_context_blocks_external_task_after_max_reroutes() {
3601 let temp_home = TempDir::new().unwrap();
3604 let orch_dir = temp_home.path().join(".orch");
3605 std::fs::create_dir_all(&orch_dir).unwrap();
3606 let old_orch_home = std::env::var_os("ORCH_HOME");
3607 std::env::set_var("ORCH_HOME", &orch_dir);
3608
3609 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3610 let repo = "owner/repo";
3611 let ext = no_code_review_task("3618");
3612 store.ensure_external_task(repo, &ext).await.unwrap();
3613
3614 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3615 let task_manager = Arc::new(TaskManager::with_store(
3616 backend,
3617 Arc::clone(&store),
3618 repo.to_string(),
3619 ));
3620 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3621
3622 for _ in 0..3 {
3624 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3625 .await
3626 .unwrap();
3627 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3628 break;
3629 }
3630 }
3631
3632 let stored = store
3633 .get_by_external_id(repo, &ext.id.0)
3634 .await
3635 .unwrap()
3636 .expect("task must still exist");
3637 assert_eq!(stored.status, TaskStatus::Blocked);
3638
3639 if let Some(old) = old_orch_home {
3640 std::env::set_var("ORCH_HOME", old);
3641 } else {
3642 std::env::remove_var("ORCH_HOME");
3643 }
3644 }
3645
3646 #[tokio::test]
3647 async fn build_review_context_marks_internal_task_done_with_no_code() {
3648 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3649 let repo = "owner/repo";
3650 let internal_id = store
3651 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3652 .await
3653 .unwrap();
3654 let ext_id = format!("internal:{internal_id}");
3655 let ext = no_code_review_task(&ext_id);
3656
3657 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3658 let task_manager = Arc::new(TaskManager::with_store(
3659 backend,
3660 Arc::clone(&store),
3661 repo.to_string(),
3662 ));
3663 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3664
3665 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3666 .await
3667 .unwrap();
3668
3669 assert!(matches!(
3670 phase,
3671 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3672 ));
3673 let stored = store.get(internal_id).await.unwrap();
3674 assert_eq!(stored.status, TaskStatus::Done);
3675 }
3676
3677 #[test]
3678 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3679 let last_error =
3680 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3681 assert!(!should_skip_no_code_reroute_increment(last_error));
3682 }
3683
3684 #[test]
3693 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3694 let padding = "x".repeat(10_000);
3695 let text_for_review = format!(
3696 "{padding}\n+| codex | gpt-5.4 | failed | 1 |\n+| codex | gpt-5.5 | failed / aborted / (empty) | 1 each |\n+| kimi | opus | rate_limit | 1 |\n{padding}"
3697 );
3698
3699 let scan_head = runner::agents::patterns::safe_head(
3700 &text_for_review,
3701 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3702 );
3703 let scan_tail = runner::agents::patterns::safe_tail(
3704 &text_for_review,
3705 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3706 );
3707
3708 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3709 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3710 }
3711
3712 #[test]
3713 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3714 let padding = "x".repeat(10_000);
3715 let text_for_review =
3716 format!("{padding}\nError: rate limit exceeded, please try again later");
3717
3718 let scan_tail = runner::agents::patterns::safe_tail(
3719 &text_for_review,
3720 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3721 );
3722
3723 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3724 }
3725
3726 #[tokio::test]
3727 async fn recover_missing_review_worktree_recreates_worktree() {
3728 let temp_home = TempDir::new().unwrap();
3729 let orch_dir = temp_home.path().join(".orch");
3730 std::fs::create_dir_all(&orch_dir).unwrap();
3731 let old_orch_home = std::env::var_os("ORCH_HOME");
3732 std::env::set_var("ORCH_HOME", &orch_dir);
3733
3734 let project_dir = temp_home.path().join("project");
3735 std::fs::create_dir_all(&project_dir).unwrap();
3736 git(&project_dir, &["init", "-b", "main"]);
3737 git(&project_dir, &["config", "user.name", "Test User"]);
3738 git(&project_dir, &["config", "user.email", "test@example.com"]);
3739 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3740 git(&project_dir, &["add", "README.md"]);
3741 git(&project_dir, &["commit", "-m", "init"]);
3742
3743 std::fs::write(
3744 orch_dir.join("config.yml"),
3745 format!("projects:\n - {}\n", project_dir.display()),
3746 )
3747 .unwrap();
3748 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
3749
3750 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3751 let repo = "owner/repo";
3752 store
3753 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3754 .await
3755 .unwrap();
3756 let ext = ExternalTask {
3757 id: crate::backends::ExternalId("1".to_string()),
3758 title: "Fix review recovery".to_string(),
3759 body: "".to_string(),
3760 state: "open".to_string(),
3761 labels: vec![],
3762 author: "tester".to_string(),
3763 created_at: "2026-01-01T00:00:00Z".to_string(),
3764 updated_at: "2026-01-01T00:00:00Z".to_string(),
3765 url: "https://example.com/issues/1".to_string(),
3766 };
3767 store.ensure_external_task(repo, &ext).await.unwrap();
3768
3769 let recovered = recover_missing_review_worktree(&ext, repo, &store)
3770 .await
3771 .unwrap();
3772
3773 assert!(recovered.work_dir.exists());
3774 assert!(recovered.branch.starts_with("gh-issue-1-"));
3775
3776 if let Some(old) = old_orch_home {
3777 std::env::set_var("ORCH_HOME", old);
3778 } else {
3779 std::env::remove_var("ORCH_HOME");
3780 }
3781 }
3782
3783 #[tokio::test]
3786 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3787 let temp = TempDir::new().unwrap();
3788 let dir = temp.path();
3789
3790 let bare = temp.path().join("bare.git");
3792 std::fs::create_dir_all(&bare).unwrap();
3793 std::process::Command::new("git")
3794 .args(["init", "--bare"])
3795 .current_dir(&bare)
3796 .output()
3797 .unwrap();
3798
3799 git(dir, &["init", "-b", "main"]);
3800 git(dir, &["config", "user.name", "Test"]);
3801 git(dir, &["config", "user.email", "test@test.com"]);
3802 std::fs::write(dir.join("f"), "a").unwrap();
3803 git(dir, &["add", "f"]);
3804 git(dir, &["commit", "-m", "init"]);
3805 std::process::Command::new("git")
3806 .args(["remote", "add", "origin", bare.to_str().unwrap()])
3807 .current_dir(dir)
3808 .output()
3809 .unwrap();
3810 std::process::Command::new("git")
3811 .args(["push", "origin", "main"])
3812 .current_dir(dir)
3813 .output()
3814 .unwrap();
3815
3816 let result = count_ahead_commits(dir, "main").await;
3818 assert!(
3819 result.is_ok(),
3820 "expected Ok when origin/main exists, got {result:?}"
3821 );
3822 assert_eq!(
3823 result.unwrap(),
3824 0,
3825 "expected 0 commits ahead when branch matches origin/main"
3826 );
3827 }
3828
3829 #[tokio::test]
3830 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
3831 let temp = TempDir::new().unwrap();
3832 let dir = temp.path();
3833 git(dir, &["init", "-b", "main"]);
3834 git(dir, &["config", "user.name", "Test"]);
3835 git(dir, &["config", "user.email", "test@test.com"]);
3836 std::fs::write(dir.join("f"), "a").unwrap();
3837 git(dir, &["add", "f"]);
3838 git(dir, &["commit", "-m", "init"]);
3839 let result = count_ahead_commits(dir, "main").await;
3841 assert!(
3842 result.is_err(),
3843 "expected Err when origin/main does not exist, got {result:?}"
3844 );
3845 let err = result.unwrap_err();
3846 assert!(
3847 err.contains("git rev-list exited"),
3848 "error should mention exit failure, got: {err}"
3849 );
3850 }
3851
3852 #[tokio::test]
3853 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
3854 let temp = TempDir::new().unwrap();
3856 let dir = temp.path();
3857 let result = count_ahead_commits(dir, "main").await;
3859 assert!(
3860 result.is_err(),
3861 "expected Err when directory is not a git repo, got {result:?}"
3862 );
3863 }
3864
3865 #[serial_test::serial(cooldown_state)]
3875 #[tokio::test]
3876 async fn review_invalid_response_applies_persistent_model_cooldown() {
3877 crate::engine::cooldown::reset_global_state().await;
3878 let agent = "opencode-3314-review-invalid";
3879 let model = "opencode/north-mini-code-free";
3880 let key = format!("{agent}:{model}");
3881
3882 assert!(
3883 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
3884 "model should not be in cooldown before record_review_agent_failure"
3885 );
3886
3887 let err = runner::agents::AgentError::InvalidResponse {
3888 raw: "empty review output".to_string(),
3889 };
3890 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
3891
3892 let now = chrono::Utc::now().timestamp();
3893 let until = crate::engine::cooldown::cooldown_until(&key)
3894 .expect("review InvalidResponse should set model cooldown");
3895 let remaining = until.saturating_sub(now);
3896
3897 assert!(
3900 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3901 "review InvalidResponse must apply ~4h persistent cooldown (issue #3314), \
3902 got {remaining}s — may have used 5-min standard backoff instead"
3903 );
3904 }
3905
3906 #[serial_test::serial(cooldown_state)]
3914 #[tokio::test]
3915 async fn review_persistent_cooldown_survives_after_first_failure() {
3916 crate::engine::cooldown::reset_global_state().await;
3917 let agent = "opencode-3314-review-parse";
3918 let model = "opencode/north-mini-code-free";
3919 let key = format!("{agent}:{model}");
3920
3921 let err = runner::agents::AgentError::InvalidResponse {
3923 raw: "failed to parse review response".to_string(),
3924 };
3925 record_review_agent_failure("test-3314-b", agent, Some(model), &err).await;
3926
3927 let now = chrono::Utc::now().timestamp();
3928 let until1 = crate::engine::cooldown::cooldown_until(&key)
3929 .expect("first failure should set cooldown");
3930 let remaining1 = until1.saturating_sub(now);
3931
3932 crate::engine::cooldown::record_agent_success(agent, model).await;
3935
3936 assert!(
3942 remaining1 >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3943 "after success reset, first-failure cooldown should still be ~4h, got {remaining1}s"
3944 );
3945 }
3946
3947 #[serial_test::serial(cooldown_state)]
3953 #[tokio::test]
3954 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
3955 crate::engine::cooldown::reset_global_state().await;
3956 let agent = "kimi-3551-review-inline";
3957 let model = "opus";
3958 let key = format!("{agent}:{model}");
3959
3960 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
3962 You've reached your usage limit for this billing cycle. Your quota will be \
3963 refreshed next cycle.";
3964
3965 let scan_tail = runner::agents::patterns::safe_tail(
3966 text_for_review,
3967 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3968 );
3969 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
3970 .expect("billing-cycle message must still be caught by the rate-limit scan");
3971
3972 assert_eq!(
3973 outcome_for_agent_error(&err),
3974 "billing_cycle_exhausted",
3975 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
3976 );
3977
3978 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
3979
3980 let now = chrono::Utc::now().timestamp();
3981 let until = crate::engine::cooldown::cooldown_until(&key)
3982 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
3983 let remaining = until.saturating_sub(now);
3984 assert!(
3989 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3990 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
3991 model cooldown, got {remaining}s — may have used the generic short agent \
3992 cooldown instead"
3993 );
3994 }
3995
3996 fn test_review_task(id: &str) -> ExternalTask {
3999 ExternalTask {
4000 id: crate::backends::ExternalId(id.to_string()),
4001 title: "Test review task".to_string(),
4002 body: "".to_string(),
4003 state: "open".to_string(),
4004 labels: vec![],
4005 author: "tester".to_string(),
4006 created_at: "2026-01-01T00:00:00Z".to_string(),
4007 updated_at: "2026-01-01T00:00:00Z".to_string(),
4008 url: "https://example.com/issues/1".to_string(),
4009 }
4010 }
4011
4012 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4013 ReviewContext {
4014 store_id: None,
4015 had_prev_push_failure: false,
4016 review_attempt: 1,
4017 worktree_path: work_dir.to_path_buf(),
4018 branch_name: "gh-issue-1-test".to_string(),
4019 pr_number: 1,
4020 head_sha: None,
4021 default_branch: "main".to_string(),
4022 review_agent: agent.to_string(),
4023 review_model: Some(model.to_string()),
4024 review_task_id: "review-1".to_string(),
4025 review_attempt_dir: work_dir.to_path_buf(),
4026 output_file: work_dir.join("output.txt"),
4027 invocation: runner::agent::AgentInvocation {
4028 agent: agent.to_string(),
4029 model: Some(model.to_string()),
4030 work_dir: work_dir.to_path_buf(),
4031 system_prompt: String::new(),
4032 agent_message: String::new(),
4033 task_id: "review-1".to_string(),
4034 disallowed_tools: vec![],
4035 git_author_name: "Test".to_string(),
4036 git_author_email: "test@example.com".to_string(),
4037 output_file: work_dir.join("output.txt"),
4038 timeout_seconds: 0,
4039 repo: "owner/repo".to_string(),
4040 attempt: 1,
4041 sandbox: false,
4042 main_project_dir: work_dir.to_path_buf(),
4043 branch: String::new(),
4044 },
4045 same_agent_fallback: false,
4046 }
4047 }
4048
4049 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4050 ParsedReview {
4051 run_id: None,
4052 exit_code: 0,
4053 stderr: String::new(),
4054 raw_output: String::new(),
4055 text_for_review: String::new(),
4056 token_usage: RunTokenUsage::default(),
4057 review_notes_for_comment: String::new(),
4058 decision,
4059 }
4060 }
4061
4062 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4063 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4064 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4065 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4066 }
4067
4068 #[serial_test::serial(cooldown_state)]
4072 #[tokio::test]
4073 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4074 crate::engine::cooldown::reset_global_state().await;
4075 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4076 let work_dir = TempDir::new().unwrap();
4077 let agent = "kimi";
4078 let model = "opus-3734-mid-thought";
4079 let ctx = test_review_context(agent, model, work_dir.path());
4080 let redraw = "^D\u{0008}".repeat(20);
4081 let ndjson = format!(
4082 "{}\n{}\n{}\n{}\n{}",
4083 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4084 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers and the lease logic edge cases:"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4085 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4086 "\x1b[?25h",
4087 redraw,
4088 );
4089 write_review_output(work_dir.path(), &ndjson, 0);
4090
4091 let task = test_review_task("3734");
4092 let phase = parse_review_output(
4093 &task,
4094 "owner/repo",
4095 &ctx,
4096 ReviewRun { run_id: None },
4097 &store,
4098 )
4099 .await;
4100
4101 match phase {
4102 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4103 assert!(
4104 reason.contains("incomplete:"),
4105 "mid-thought review must be stored as incomplete, got {reason}"
4106 );
4107 }
4108 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4109 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4110 }
4111 assert!(
4112 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4113 "mid-thought incomplete must not apply persistent model cooldown"
4114 );
4115 }
4116
4117 #[serial_test::serial(cooldown_state)]
4120 #[tokio::test]
4121 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4122 crate::engine::cooldown::reset_global_state().await;
4123 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4124 let work_dir = TempDir::new().unwrap();
4125 let agent = "kimi";
4126 let model = "opus-3734-tool-use";
4127 let ctx = test_review_context(agent, model, work_dir.path());
4128 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4129 write_review_output(work_dir.path(), ndjson, 0);
4130
4131 let task = test_review_task("3734-tool");
4132 let phase = parse_review_output(
4133 &task,
4134 "owner/repo",
4135 &ctx,
4136 ReviewRun { run_id: None },
4137 &store,
4138 )
4139 .await;
4140
4141 match phase {
4142 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4143 assert!(
4144 reason.contains("incomplete:"),
4145 "tool_use without envelope must be incomplete, got {reason}"
4146 );
4147 }
4148 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4149 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4150 }
4151 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4152 }
4153
4154 #[serial_test::serial(cooldown_state)]
4157 #[tokio::test]
4158 async fn parse_review_output_unparseable_envelope_applies_persistent_cooldown() {
4159 crate::engine::cooldown::reset_global_state().await;
4160 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4161 let work_dir = TempDir::new().unwrap();
4162 let agent = "kimi";
4163 let model = "opus-3734-format";
4164 let ctx = test_review_context(agent, model, work_dir.path());
4165 let ndjson = concat!(
4166 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4167 "\n",
4168 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4169 );
4170 write_review_output(work_dir.path(), ndjson, 0);
4171
4172 let task = test_review_task("3734-format");
4173 let phase = parse_review_output(
4174 &task,
4175 "owner/repo",
4176 &ctx,
4177 ReviewRun { run_id: None },
4178 &store,
4179 )
4180 .await;
4181
4182 match phase {
4183 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4184 assert!(
4185 reason.contains("parse error"),
4186 "finished unparseable envelope must stay a parse error, got {reason}"
4187 );
4188 }
4189 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4190 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4191 }
4192 assert!(
4193 crate::engine::cooldown::is_model_in_cooldown(agent, model),
4194 "finished format failure must apply persistent model cooldown"
4195 );
4196 }
4197
4198 #[serial_test::serial(cooldown_state)]
4201 #[tokio::test]
4202 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4203 crate::engine::cooldown::reset_global_state().await;
4204 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4205 let work_dir = TempDir::new().unwrap();
4206 let agent = "kimi";
4207 let model = "opus-3734-assistant";
4208 let ctx = test_review_context(agent, model, work_dir.path());
4209 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4210 write_review_output(work_dir.path(), ndjson, 0);
4211
4212 let task = test_review_task("3734-asst");
4213 let phase = parse_review_output(
4214 &task,
4215 "owner/repo",
4216 &ctx,
4217 ReviewRun { run_id: None },
4218 &store,
4219 )
4220 .await;
4221
4222 match phase {
4223 ReviewPhase::Ready(parsed) => {
4224 assert!(matches!(parsed.decision, ReviewDecision::Approve));
4225 }
4226 ReviewPhase::EarlyReturn(_) => {
4227 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4228 }
4229 }
4230 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4231 }
4232
4233 #[serial_test::serial(cooldown_state)]
4236 #[tokio::test]
4237 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4238 crate::engine::cooldown::reset_global_state().await;
4239 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4240 let work_dir = TempDir::new().unwrap();
4241 let agent = "kimi";
4242 let model = "opus-3734-done";
4243 let ctx = test_review_context(agent, model, work_dir.path());
4244 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4245 write_review_output(work_dir.path(), ndjson, 0);
4246
4247 let task = test_review_task("3734-done");
4248 let phase = parse_review_output(
4249 &task,
4250 "owner/repo",
4251 &ctx,
4252 ReviewRun { run_id: None },
4253 &store,
4254 )
4255 .await;
4256
4257 match phase {
4258 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4259 assert!(
4260 reason.contains("parse error"),
4261 "unfinished prose without a decision must not approve, got {reason}"
4262 );
4263 }
4264 ReviewPhase::Ready(_) => {
4265 panic!("task-path status=done synthesis must not become a review approval")
4266 }
4267 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4268 }
4269 }
4270
4271 #[serial_test::serial(cooldown_state)]
4277 #[tokio::test]
4278 async fn finalize_review_run_success_resets_generic_model_failure_count() {
4279 crate::engine::cooldown::reset_global_state().await;
4280 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4281 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4282
4283 let agent = "test-3478-agent";
4284 let model = "test-3478-model";
4285 let failure_key = format!("failure_count:{agent}:{model}");
4286
4287 let err = runner::agents::AgentError::AgentFailed {
4291 message: "agent reported a generic failure".to_string(),
4292 };
4293 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4294 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4295 assert_eq!(
4296 store.kv_get(&failure_key).await.unwrap().as_deref(),
4297 Some("1"),
4298 "sanity check: failure should have incremented the persisted count"
4299 );
4300
4301 let work_dir = TempDir::new().unwrap();
4302 let ctx = test_review_context(agent, model, work_dir.path());
4303 let parsed = test_parsed_review(ReviewDecision::Approve);
4304 let task = test_review_task("1");
4305
4306 finalize_review_run(&task, &ctx, &parsed, &store).await;
4307 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4308
4309 assert_eq!(
4310 store.kv_get(&failure_key).await.unwrap().as_deref(),
4311 Some("0"),
4312 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4313 );
4314 }
4315
4316 #[serial_test::serial(cooldown_state)]
4322 #[tokio::test]
4323 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4324 crate::engine::cooldown::reset_global_state().await;
4325 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4326 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4327
4328 let agent = "test-3478-interleave-agent";
4329 let model = "test-3478-interleave-model";
4330 let cooldown_key = format!("{agent}:{model}");
4331
4332 let work_dir = TempDir::new().unwrap();
4333 let ctx = test_review_context(agent, model, work_dir.path());
4334 let task = test_review_task("2");
4335
4336 for i in 0..4 {
4337 let err = runner::agents::AgentError::AgentFailed {
4338 message: format!("failure {i}"),
4339 };
4340 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4341
4342 let now = chrono::Utc::now().timestamp();
4343 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4344 .expect("failure should set cooldown");
4345 let remaining = until.saturating_sub(now);
4346
4347 assert!(
4348 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4349 "failure #{} must stay at base backoff since the prior success reset the \
4350 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4351 here means review successes are not resetting failure_count",
4352 i + 1,
4353 crate::engine::cooldown::BACKOFF_BASE_SECS
4354 );
4355
4356 let parsed = test_parsed_review(ReviewDecision::Approve);
4360 finalize_review_run(&task, &ctx, &parsed, &store).await;
4361 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4362 }
4363 }
4364
4365 #[serial_test::serial(cooldown_state)]
4371 #[tokio::test]
4372 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4373 crate::engine::cooldown::reset_global_state().await;
4374 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4375 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4376
4377 let agent = "test-3571-agent";
4378 let model = "test-3571-model";
4379 let cooldown_key = format!("{agent}:{model}");
4380
4381 let work_dir = TempDir::new().unwrap();
4382 let ctx = test_review_context(agent, model, work_dir.path());
4383 let task = test_review_task("3");
4384
4385 let err = runner::agents::AgentError::InvalidResponse {
4387 raw: "empty review output".to_string(),
4388 };
4389 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4390 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4391
4392 let parsed = test_parsed_review(ReviewDecision::Approve);
4395 finalize_review_run(&task, &ctx, &parsed, &store).await;
4396 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4397
4398 let err2 = runner::agents::AgentError::InvalidResponse {
4401 raw: "empty review output again".to_string(),
4402 };
4403 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4404 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4405
4406 let now = chrono::Utc::now().timestamp();
4407 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4408 .expect("second failure should set cooldown");
4409 let remaining = until.saturating_sub(now);
4410
4411 assert!(
4412 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4413 "second persistent model failure should escalate past the 4h base despite an \
4414 intervening success (issue #3571); got {remaining}s, base is {}s",
4415 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4416 );
4417 }
4418
4419 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
4420 Arc::new(crate::github::app::AppAuth::new(
4421 "12345".to_string(),
4422 crate::github::app::test_pem().to_string(),
4423 server.uri(),
4424 ))
4425 }
4426
4427 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
4428 use wiremock::matchers::{method, path};
4429 use wiremock::{Mock, ResponseTemplate};
4430 Mock::given(method("GET"))
4431 .and(path(format!("/repos/{repo}/installation")))
4432 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4433 .mount(server)
4434 .await;
4435 Mock::given(method("POST"))
4436 .and(path("/app/installations/42/access_tokens"))
4437 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4438 "token": "inst-1",
4439 "expires_at": "2099-01-01T00:00:00Z",
4440 })))
4441 .mount(server)
4442 .await;
4443 }
4444
4445 #[tokio::test]
4446 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
4447 use crate::review_mode::ReviewMode;
4448 use wiremock::matchers::{method, path};
4449 use wiremock::{Mock, MockServer, ResponseTemplate};
4450 let server = MockServer::start().await;
4451 Mock::given(method("GET"))
4452 .and(path("/repos/org/b/installation"))
4453 .respond_with(ResponseTemplate::new(404))
4454 .expect(1)
4455 .mount(&server)
4456 .await;
4457 let app = app_for_test(&server);
4458
4459 assert!(
4460 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
4461 .await
4462 .is_none()
4463 );
4464 assert!(
4466 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
4467 .await
4468 .is_none()
4469 );
4470 assert!(review_app_for(ReviewMode::App, None, "org/b")
4471 .await
4472 .is_none());
4473 }
4474
4475 #[tokio::test]
4476 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
4477 use wiremock::matchers::{body_partial_json, method, path};
4478 use wiremock::{Mock, MockServer, ResponseTemplate};
4479 let server = MockServer::start().await;
4480 mount_installed(&server, "o/r").await;
4481 Mock::given(method("POST"))
4482 .and(path("/repos/o/r/check-runs"))
4483 .and(body_partial_json(serde_json::json!({
4484 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
4485 })))
4486 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4487 .expect(1)
4488 .mount(&server)
4489 .await;
4490 Mock::given(method("POST"))
4491 .and(path("/repos/o/r/check-runs"))
4492 .and(body_partial_json(serde_json::json!({
4493 "name": "orch/review", "head_sha": "abc123", "status": "completed",
4494 "conclusion": "failure", "output": {"summary": "fix it"},
4495 })))
4496 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4497 .expect(1)
4498 .mount(&server)
4499 .await;
4500 let app = app_for_test(&server);
4501 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4502 let decision = ReviewDecision::RequestChanges {
4503 notes: "fix it".to_string(),
4504 issues: vec![],
4505 };
4506
4507 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
4508 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
4509 let requests = server.received_requests().await.expect("requests");
4510 assert!(requests
4511 .iter()
4512 .all(|req| !req.url.path().contains("/statuses/")));
4513 }
4514
4515 #[tokio::test]
4516 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
4517 use crate::engine::runner::response::ReviewIssue;
4518 use wiremock::matchers::{method, path, query_param};
4519 use wiremock::{Mock, MockServer, ResponseTemplate};
4520 let server = MockServer::start().await;
4521 mount_installed(&server, "o/r").await;
4522 Mock::given(method("GET"))
4523 .and(path("/repos/o/r/pulls/9/files"))
4524 .and(query_param("page", "1"))
4525 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
4526 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
4527 ])))
4528 .expect(1)
4529 .mount(&server)
4530 .await;
4531 Mock::given(method("POST"))
4532 .and(path("/repos/o/r/pulls/9/reviews"))
4533 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4534 .expect(1)
4535 .mount(&server)
4536 .await;
4537 Mock::given(method("POST"))
4538 .and(path("/repos/o/r/issues/9/comments"))
4539 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4540 .mount(&server)
4541 .await;
4542 let issue = |file: &str, line: Option<u32>| ReviewIssue {
4543 file: file.to_string(),
4544 line,
4545 severity: "error".to_string(),
4546 description: "wrong".to_string(),
4547 };
4548 let decision = ReviewDecision::RequestChanges {
4549 notes: "fix".to_string(),
4550 issues: vec![
4551 issue("src/a.rs", Some(11)),
4552 issue("src/a.rs", Some(50)),
4553 issue("src/b.rs", Some(3)),
4554 issue("src/a.rs", None),
4555 ],
4556 };
4557 let app = app_for_test(&server);
4558 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4559
4560 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
4561 .await
4562 .expect("comment posted");
4563 let requests = server.received_requests().await.expect("requests");
4564 let review = requests
4565 .iter()
4566 .find(|req| req.url.path().ends_with("/reviews"))
4567 .expect("review submitted");
4568 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
4569 assert_eq!(
4570 body["comments"],
4571 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
4572 );
4573 }
4574
4575 #[allow(clippy::await_holding_lock)]
4576 #[tokio::test]
4577 async fn without_an_app_the_review_path_posts_only_the_comment() {
4578 use wiremock::matchers::{method, path};
4579 use wiremock::{Mock, MockServer, ResponseTemplate};
4580 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
4581 let prev = std::env::var_os("GH_TOKEN");
4582 std::env::set_var("GH_TOKEN", "owner-token");
4583 let server = MockServer::start().await;
4584 Mock::given(method("POST"))
4585 .and(path("/repos/o/r/issues/9/comments"))
4586 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4587 .expect(1)
4588 .mount(&server)
4589 .await;
4590 let gh = GhHttp::for_test(server.uri(), None);
4591 let result = publish_review(
4592 None,
4593 &gh,
4594 "o/r",
4595 9,
4596 Some("abc123"),
4597 &ReviewDecision::Approve,
4598 "## Automated Review \u{2014} Approve\n\nok",
4599 )
4600 .await;
4601 match prev {
4602 Some(value) => std::env::set_var("GH_TOKEN", value),
4603 None => std::env::remove_var("GH_TOKEN"),
4604 }
4605 result.expect("comment posted");
4606 let requests = server.received_requests().await.expect("requests");
4607 assert!(requests
4608 .iter()
4609 .all(|req| !req.url.path().contains("/reviews")));
4610 }
4611
4612 #[tokio::test]
4613 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
4614 use wiremock::matchers::{body_partial_json, method, path};
4615 use wiremock::{Mock, MockServer, ResponseTemplate};
4616 let server = MockServer::start().await;
4617 Mock::given(method("POST"))
4618 .and(path("/app/installations/42/access_tokens"))
4619 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4620 "token": "inst-1",
4621 "expires_at": "2099-01-01T00:00:00Z",
4622 })))
4623 .mount(&server)
4624 .await;
4625 Mock::given(method("POST"))
4626 .and(path("/repos/o/r/pulls/9/reviews"))
4627 .and(body_partial_json(serde_json::json!({
4628 "event": "REQUEST_CHANGES",
4629 "commit_id": "abc123",
4630 })))
4631 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4632 .expect(1)
4633 .mount(&server)
4634 .await;
4635 Mock::given(method("POST"))
4636 .and(path("/repos/o/r/issues/9/comments"))
4637 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4638 .expect(1)
4639 .mount(&server)
4640 .await;
4641 Mock::given(method("GET"))
4642 .and(path("/repos/o/r/installation"))
4643 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4644 .mount(&server)
4645 .await;
4646 let app = Arc::new(crate::github::app::AppAuth::new(
4647 "12345".to_string(),
4648 crate::github::app::test_pem().to_string(),
4649 server.uri(),
4650 ));
4651 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4652 let decision = ReviewDecision::RequestChanges {
4653 notes: "fix it".to_string(),
4654 issues: vec![],
4655 };
4656 publish_review(
4657 Some(&app),
4658 &gh,
4659 "o/r",
4660 9,
4661 Some("abc123"),
4662 &decision,
4663 "## Automated Review \u{2014} Changes Requested\n\nfix it",
4664 )
4665 .await
4666 .expect("comment posted");
4667 }
4668}