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::Interrupted { .. } => "interrupted",
25 runner::agents::AgentError::RateLimit { message } => {
30 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
31 .unwrap_or("rate_limit")
32 }
33 runner::agents::AgentError::Auth { message } => {
34 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
35 .unwrap_or("auth_error")
36 }
37 runner::agents::AgentError::InvalidResponse { .. } => "parse_error",
38 _ => "failed",
39 }
40}
41
42fn format_review_error(agent: &str, err: &runner::agents::AgentError) -> String {
51 match err {
52 runner::agents::AgentError::RateLimit { message } => format!(
53 "{agent} rate limit: {}",
54 runner::fallback::summarize_rate_limit_error(message)
55 ),
56 runner::agents::AgentError::Auth { message } => format!("{agent} auth error: {message}"),
57 runner::agents::AgentError::Timeout { elapsed } => {
58 format!("{agent} timed out after {}s", elapsed.as_secs())
59 }
60 other => format!("{agent} agent error: {other}"),
61 }
62}
63
64pub(crate) fn parse_pr_number_from_url(url: &str) -> Result<u64, String> {
76 let url = url.trim();
77 if url.is_empty() {
78 return Err("URL is empty".to_string());
79 }
80 if !url.starts_with("https://github.com/") {
81 return Err(format!(
82 "URL does not start with 'https://github.com/': {url}"
83 ));
84 }
85 if !url.contains("/pull/") {
86 return Err(format!("URL missing '/pull/' segment: {url}"));
87 }
88 let last_segment = url
89 .trim_end_matches('/')
90 .rsplit('/')
91 .next()
92 .ok_or_else(|| format!("No segment after '/pull/' in URL: {url}"))?;
93 let number_part = last_segment
94 .split(['?', '#'])
95 .next()
96 .filter(|s| !s.is_empty())
97 .ok_or_else(|| format!("PR number segment is empty in URL: {url}"))?;
98 let pr_num: u64 = number_part.parse().map_err(|_| {
99 format!("PR number is not a valid positive integer: '{number_part}' in URL: {url}")
100 })?;
101 if pr_num == 0 {
102 return Err(format!("PR number cannot be zero in URL: {url}"));
103 }
104 Ok(pr_num)
105}
106
107fn review_started_comment(review_agent: &str, review_model: &str) -> String {
108 let footer = attribution_footer("Review started", review_agent, Some(review_model));
109 format!("🔍 Automated review started{}", footer)
110}
111
112fn should_skip_no_code_reroute_increment(last_error: &str) -> bool {
113 crate::engine::runner::git_ops::is_transient_github_api_error(last_error)
114}
115
116use crate::backends::{ExternalBackend, ExternalTask, Status};
117use crate::cmd::CommandErrorContext;
118use crate::config;
119use crate::engine::attribution_footer;
120use crate::engine::auto_merge::{auto_merge_pr, handle_review_changes};
121use crate::engine::runner;
122use crate::engine::runner::worktree;
123use crate::engine::tasks::TaskManager;
124use crate::github::http::GhHttp;
125use crate::store::store_log_activity;
126use crate::store::TaskStore;
127use crate::store::{opt_store_get_task, store_increment};
128use crate::store::{store_set, store_set_result};
129use crate::store::{CompleteRun, RunTokenUsage, StartRun};
130use crate::tmux::TmuxManager;
131use anyhow::Context;
132use std::collections::HashSet;
133use std::sync::Arc;
134use tokio::process::Command;
135use tokio::sync::RwLock;
136
137use super::router::Router;
138
139#[derive(Debug, Clone)]
141pub(crate) enum ReviewDecision {
142 Approve {
144 issues: Vec<crate::engine::runner::response::ReviewIssue>,
145 },
146 RequestChanges {
148 notes: String,
149 issues: Vec<crate::engine::runner::response::ReviewIssue>,
150 },
151 Failed(String),
153 Blocked(String),
155 Skipped,
157 Rerouted,
159}
160
161enum EnsurePrResult {
163 Ready(u64),
165 EarlyReturn(ReviewDecision),
167}
168
169enum ReviewPhase<T> {
170 Ready(T),
171 EarlyReturn(ReviewDecision),
172}
173
174struct ReviewContext {
175 store_id: Option<i64>,
176 had_prev_push_failure: bool,
177 review_attempt: u32,
178 worktree_path: std::path::PathBuf,
179 branch_name: String,
180 pr_number: u64,
181 head_sha: Option<String>,
183 default_branch: String,
184 review_agent: String,
185 review_model: Option<String>,
186 review_task_id: String,
187 review_attempt_dir: std::path::PathBuf,
188 output_file: std::path::PathBuf,
189 invocation: runner::agent::AgentInvocation,
190 same_agent_fallback: bool,
191}
192
193impl ReviewContext {
194 fn review_model_str(&self) -> &str {
195 self.review_model.as_deref().unwrap_or("")
196 }
197}
198
199struct ReviewRun {
200 run_id: Option<i64>,
201}
202
203struct ParsedReview {
204 run_id: Option<i64>,
205 exit_code: i32,
206 stderr: String,
207 raw_output: String,
208 text_for_review: String,
209 token_usage: RunTokenUsage,
210 review_notes_for_comment: String,
211 decision: ReviewDecision,
212}
213
214fn approved_sha_key(repo: &str, task_id: &str, pr_number: u64) -> String {
216 format!("review_approved_sha:{repo}#{task_id}#{pr_number}")
217}
218
219const REVIEW_TIMEOUT_DEFAULT_SECS: u64 = 900;
220const REVIEW_TIMEOUT_MIN_SECS: u64 = 300;
221
222fn review_timeout_secs(configured: Option<u64>) -> u64 {
224 configured
225 .unwrap_or(REVIEW_TIMEOUT_DEFAULT_SECS)
226 .max(REVIEW_TIMEOUT_MIN_SECS)
227}
228
229pub(crate) async fn worktree_head_sha(dir: &std::path::Path) -> Option<String> {
231 let out = tokio::process::Command::new("git")
232 .args(["rev-parse", "HEAD"])
233 .current_dir(dir)
234 .kill_on_drop(true)
235 .output()
236 .await
237 .ok()?;
238 if !out.status.success() {
239 return None;
240 }
241 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
242 (!sha.is_empty()).then_some(sha)
243}
244
245#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
248pub(crate) struct ApprovalEvent {
249 sha: String,
250 repo: String,
251 branch: String,
252 base: String,
253}
254
255impl ApprovalEvent {
256 fn parse(raw: &str) -> Option<Self> {
258 let raw = raw.trim();
259 if raw.is_empty() {
260 return None;
261 }
262 serde_json::from_str(raw).ok().or_else(|| {
263 Some(Self {
264 sha: raw.to_string(),
265 repo: String::new(),
266 branch: String::new(),
267 base: String::new(),
268 })
269 })
270 }
271}
272
273async fn read_approval(
274 store: &Arc<TaskStore>,
275 repo: &str,
276 task_id: &str,
277 pr_number: u64,
278) -> anyhow::Result<Option<ApprovalEvent>> {
279 Ok(store
280 .kv_get(&approved_sha_key(repo, task_id, pr_number))
281 .await?
282 .as_deref()
283 .and_then(ApprovalEvent::parse))
284}
285
286async fn write_approval(
287 store: &Arc<TaskStore>,
288 task_id: &str,
289 pr_number: u64,
290 event: &ApprovalEvent,
291) {
292 let key = approved_sha_key(&event.repo, task_id, pr_number);
293 let value = serde_json::to_string(event).unwrap_or_default();
294 if let Err(e) = store.kv_set(&key, &value).await {
295 tracing::warn!(task_id, err = %e, "failed to record the approval");
296 }
297}
298
299async fn record_approved_sha(
302 store: &Arc<TaskStore>,
303 repo: &str,
304 task_id: &str,
305 pr_number: u64,
306 dir: &std::path::Path,
307 branch: &str,
308 base: &str,
309) {
310 let Some(sha) = worktree_head_sha(dir).await else {
311 return;
312 };
313 let event = ApprovalEvent {
314 sha,
315 repo: repo.to_string(),
316 branch: branch.to_string(),
317 base: base.to_string(),
318 };
319 write_approval(store, task_id, pr_number, &event).await;
320}
321
322async fn clear_approved_sha(store: &Arc<TaskStore>, repo: &str, task_id: &str, pr_number: u64) {
324 if let Err(e) = store
325 .kv_delete(&approved_sha_key(repo, task_id, pr_number))
326 .await
327 {
328 tracing::warn!(task_id, err = %e, "failed to clear approved commit sha");
329 }
330}
331
332async fn record_carried_approval(
335 store: &Arc<TaskStore>,
336 repo: &str,
337 task_id: &str,
338 pr_number: u64,
339) {
340 store_log_activity(
341 &Some(Arc::clone(store)),
342 repo,
343 task_id,
344 "review_decision",
345 None,
346 None,
347 None::<&str>,
348 None::<&str>,
349 Some(&serde_json::json!({
350 "decision": "approve",
351 "pr_number": pr_number,
352 "reason": "head_already_approved",
353 })),
354 )
355 .await;
356}
357
358async fn pr_head_already_approved(
367 store: &Arc<TaskStore>,
368 repo: &str,
369 task_id: &str,
370 pr_number: u64,
371) -> bool {
372 let approved = match read_approval(store, repo, task_id, pr_number).await {
373 Ok(event) => event.map(|e| e.sha),
374 Err(e) => {
375 tracing::warn!(task_id, err = %e, "failed to read approved commit sha");
376 return false;
377 }
378 };
379 if approved.is_none() {
380 return false;
381 }
382 let gh = match crate::github::http::GhHttp::new() {
383 Ok(gh) => gh,
384 Err(e) => {
385 tracing::warn!(task_id, err = %e, "cannot check PR head against recorded approval");
386 return false;
387 }
388 };
389 let pr_head = match gh.get_pr(repo, pr_number).await {
390 Ok(pr) => Some(pr.head.sha),
391 Err(e) => {
392 tracing::warn!(task_id, pr_number, err = %e, "failed to read PR head sha");
393 None
394 }
395 };
396 approval_covers_head(approved.as_deref(), pr_head.as_deref())
397}
398
399struct CarryContext {
401 approval: ApprovalEvent,
402 worktree: std::path::PathBuf,
403}
404
405async fn carry_context(
412 store: &Arc<TaskStore>,
413 repo: &str,
414 task_id: &str,
415 pr: &crate::github::types::GitHubPullRequest,
416 pr_number: u64,
417) -> Option<CarryContext> {
418 let approval = read_approval(store, repo, task_id, pr_number)
419 .await
420 .ok()
421 .flatten()?;
422 let task = opt_store_get_task(&Some(Arc::clone(store)), repo, task_id).await?;
423 if !pr_matches_approval(pr, &approval, &task.branch) || task.worktree.is_empty() {
424 tracing::warn!(
425 task_id,
426 pr_number,
427 "the PR does not match the approved repository, branch and base, approval not carried"
428 );
429 return None;
430 }
431 let worktree = std::path::PathBuf::from(&task.worktree);
432 if runner::git_ops::get_current_branch(&worktree).await != approval.branch
433 || crate::engine::runner::worktree::detect_default_branch(&worktree).await != approval.base
434 {
435 tracing::warn!(
436 task_id,
437 pr_number,
438 "the task worktree does not track the approved branch and base, approval not carried"
439 );
440 return None;
441 }
442 Some(CarryContext { approval, worktree })
443}
444
445fn pr_matches_approval(
448 pr: &crate::github::types::GitHubPullRequest,
449 approval: &ApprovalEvent,
450 task_branch: &str,
451) -> bool {
452 let same_repo = !approval.repo.is_empty()
453 && pr
454 .head
455 .repo
456 .as_ref()
457 .is_some_and(|r| r.full_name.eq_ignore_ascii_case(&approval.repo));
458 same_repo
459 && !approval.branch.is_empty()
460 && pr.head.ref_ == approval.branch
461 && approval.branch == task_branch
462 && !approval.base.is_empty()
463 && pr.base.ref_ == approval.base
464}
465
466pub(crate) async fn carry_approval_across_base_update(
474 store: &Arc<TaskStore>,
475 gh: &GhHttp,
476 repo: &str,
477 task_id: &str,
478 pr: &crate::github::types::GitHubPullRequest,
479 pr_number: u64,
480) {
481 let head_sha = pr.head.sha.as_str();
482 let Ok(Some(recorded)) = read_approval(store, repo, task_id, pr_number).await else {
483 return;
484 };
485 if recorded.sha == head_sha {
486 return;
487 }
488 let Some(ctx) = carry_context(store, repo, task_id, pr, pr_number).await else {
489 return;
490 };
491 let meta = match gh.commit_meta(repo, head_sha).await {
492 Ok(meta) => meta,
493 Err(e) => {
494 tracing::warn!(task_id, err = %e, "cannot read the PR head commit to carry the approval");
495 return;
496 }
497 };
498 let Some(base_parent) = base_merge_parent(&ctx.approval.sha, &meta.parents) else {
499 return;
500 };
501 if meta.committer_login.as_deref() != Some("web-flow") {
502 tracing::warn!(
503 task_id,
504 pr_number,
505 head_sha,
506 "the base merge was not made by GitHub, approval not carried"
507 );
508 return;
509 }
510 let Some(author) = meta.author_login.as_deref() else {
511 return;
512 };
513 if !matches!(gh.is_collaborator(repo, author).await, Ok(true)) {
514 tracing::warn!(
515 task_id,
516 pr_number,
517 author,
518 "the base merge author is not a collaborator, approval not carried"
519 );
520 return;
521 }
522 if !matches!(
523 gh.branch_contains(repo, &pr.base.ref_, base_parent).await,
524 Ok(true)
525 ) {
526 return;
527 }
528 git_output(&ctx.worktree, &["fetch", "origin"]).await;
529 if !is_clean_merge(&ctx.worktree, &ctx.approval.sha, base_parent, head_sha).await {
530 tracing::warn!(
531 task_id,
532 pr_number,
533 head_sha,
534 "the PR head is not a clean merge of the base, approval not carried"
535 );
536 return;
537 }
538
539 publish_carried_approval(store, gh, repo, task_id, pr_number, &ctx.approval, head_sha).await;
540}
541
542pub(crate) async fn carry_approval_after_rebase(
549 store: &Arc<TaskStore>,
550 gh: &GhHttp,
551 repo: &str,
552 task_id: &str,
553 pr: &crate::github::types::GitHubPullRequest,
554 pr_number: u64,
555) {
556 let Some(ctx) = carry_context(store, repo, task_id, pr, pr_number).await else {
557 return;
558 };
559 let rebased_from = pr.head.sha.as_str();
560 if ctx.approval.sha != rebased_from {
561 return;
562 }
563 let Some(new_head) = worktree_head_sha(&ctx.worktree).await else {
564 return;
565 };
566 let base = format!("origin/{}", pr.base.ref_);
567 if !same_commits_rebased(&ctx.worktree, rebased_from, &base, &new_head).await {
568 tracing::warn!(
569 task_id,
570 pr_number,
571 "the rebased commits differ from the approved ones, approval not carried"
572 );
573 return;
574 }
575 let mut pushed = false;
576 for _ in 0..3 {
577 if gh
578 .get_pr(repo, pr_number)
579 .await
580 .is_ok_and(|p| p.head.sha == new_head)
581 {
582 pushed = true;
583 break;
584 }
585 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
586 }
587 if !pushed {
588 tracing::warn!(
589 task_id,
590 pr_number,
591 "GitHub does not show the rebased head yet, approval not carried"
592 );
593 return;
594 }
595
596 publish_carried_approval(
597 store,
598 gh,
599 repo,
600 task_id,
601 pr_number,
602 &ctx.approval,
603 &new_head,
604 )
605 .await;
606}
607
608async fn same_commits_rebased(
611 dir: &std::path::Path,
612 old_head: &str,
613 base: &str,
614 new_head: &str,
615) -> bool {
616 let Some(fork_point) = git_output(dir, &["merge-base", old_head, base]).await else {
617 return false;
618 };
619 let on_base = git_output(dir, &["merge-base", "--is-ancestor", base, new_head])
620 .await
621 .is_some();
622 let old = commit_fingerprints(dir, &format!("{fork_point}..{old_head}")).await;
623 let new = commit_fingerprints(dir, &format!("{base}..{new_head}")).await;
624 on_base && old.is_some() && !old.as_ref().is_some_and(Vec::is_empty) && old == new
625}
626
627async fn commit_fingerprints(dir: &std::path::Path, range: &str) -> Option<Vec<(String, String)>> {
630 let log = git_output(
631 dir,
632 &["log", "--reverse", "--format=%H %P%x00%an <%ae>", range],
633 )
634 .await?;
635 let mut prints = Vec::new();
636 for line in log.lines().filter(|l| !l.is_empty()) {
637 let (shas, author) = line.split_once('\0')?;
638 let mut shas = shas.split(' ');
639 let sha = shas.next()?;
640 if shas.count() != 1 {
641 return None;
642 }
643 prints.push((patch_id(dir, sha).await?, author.to_string()));
644 }
645 Some(prints)
646}
647
648async fn patch_id(dir: &std::path::Path, sha: &str) -> Option<String> {
650 use tokio::io::AsyncWriteExt;
651 let diff = git_output(dir, &["show", "--format=", sha]).await?;
652 let mut child = tokio::process::Command::new("git")
653 .args(["patch-id", "--stable"])
654 .current_dir(dir)
655 .stdin(std::process::Stdio::piped())
656 .stdout(std::process::Stdio::piped())
657 .kill_on_drop(true)
658 .spawn()
659 .ok()?;
660 let mut stdin = child.stdin.take()?;
661 stdin.write_all(diff.as_bytes()).await.ok()?;
662 stdin.write_all(b"\n").await.ok()?;
663 drop(stdin);
664 let out = child.wait_with_output().await.ok()?;
665 String::from_utf8_lossy(&out.stdout)
666 .split_whitespace()
667 .next()
668 .map(String::from)
669}
670
671async fn publish_carried_approval(
674 store: &Arc<TaskStore>,
675 gh: &GhHttp,
676 repo: &str,
677 task_id: &str,
678 pr_number: u64,
679 approval: &ApprovalEvent,
680 head_sha: &str,
681) {
682 let carried = ApprovalEvent {
683 sha: head_sha.to_string(),
684 ..approval.clone()
685 };
686 write_approval(store, task_id, pr_number, &carried).await;
687 let note = "Approval carried across a base branch update";
688 let app = review_app(repo).await;
689 if let Some(app) = app.as_deref() {
691 if let Err(e) = app
692 .submit_review(
693 repo,
694 pr_number,
695 crate::github::app::ReviewEvent::Approve,
696 note,
697 head_sha,
698 &[],
699 )
700 .await
701 {
702 tracing::warn!(task_id, err = %e, "failed to carry the App approval to the new head");
703 }
704 }
705 post_review_decision(
706 app.as_deref(),
707 gh,
708 repo,
709 Some(head_sha),
710 &ReviewDecision::Approve { issues: vec![] },
711 note,
712 )
713 .await;
714 tracing::info!(
715 task_id,
716 pr_number,
717 head_sha,
718 "approval carried across a base branch update"
719 );
720}
721
722async fn git_output(dir: &std::path::Path, args: &[&str]) -> Option<String> {
724 let out = tokio::process::Command::new("git")
725 .args(args)
726 .current_dir(dir)
727 .kill_on_drop(true)
728 .output()
729 .await
730 .ok()?;
731 out.status
732 .success()
733 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
734}
735
736async fn is_clean_merge(
738 dir: &std::path::Path,
739 approved: &str,
740 base_parent: &str,
741 head: &str,
742) -> bool {
743 let merged = git_output(dir, &["merge-tree", "--write-tree", approved, base_parent]).await;
744 let head_tree = git_output(dir, &["rev-parse", &format!("{head}^{{tree}}")]).await;
745 match (merged, head_tree) {
746 (Some(merged), Some(head_tree)) => merged.lines().next() == Some(head_tree.as_str()),
747 _ => false,
748 }
749}
750
751fn base_merge_parent<'a>(approved: &str, parents: &'a [String]) -> Option<&'a str> {
753 match parents {
754 [first, second] if first == approved => Some(second.as_str()),
755 _ => None,
756 }
757}
758
759fn approval_covers_head(approved: Option<&str>, pr_head: Option<&str>) -> bool {
761 match (approved, pr_head) {
762 (Some(approved), Some(head)) => !approved.is_empty() && approved == head,
763 _ => false,
764 }
765}
766
767fn build_review_disallowed_tools(worktree_path: &std::path::Path) -> Vec<String> {
768 let mut tools = vec![
769 "Bash(rm *)".to_string(),
770 "Bash(rm -*)".to_string(),
771 "Bash(git push*)".to_string(),
772 "Bash(gh pr create*)".to_string(),
774 ];
775 let orch_yml = worktree_path.join(".orch.yml");
776 let orch_yml_str = orch_yml.to_string_lossy();
777 tools.extend([
778 format!("Write({orch_yml_str})"),
779 format!("Edit({orch_yml_str})"),
780 ]);
781 if let Ok(orch_home) = crate::home::orch_home() {
782 for path in [
783 orch_home.join("config.yml"),
784 orch_home.join("config.example.yml"),
785 ] {
786 let path_str = path.to_string_lossy();
787 tools.extend([
788 format!("Read({path_str})"),
789 format!("Write({path_str})"),
790 format!("Edit({path_str})"),
791 ]);
792 }
793 }
794 tools
795}
796
797async fn select_review_agent(
798 task_id: &str,
799 task_agent: &str,
800 store_id: Option<i64>,
801 router: &Arc<RwLock<Router>>,
802 store: &Arc<TaskStore>,
803) -> anyhow::Result<(String, Option<String>, bool)> {
804 let mut exclude_set: HashSet<String> = HashSet::new();
805 if !task_agent.is_empty() {
806 exclude_set.insert(task_agent.to_string());
807 }
808 if let Some(store_id) = store_id {
809 if let Ok(failed) = store.previous_review_agents(store_id).await {
810 for agent in failed {
811 exclude_set.insert(agent);
812 }
813 }
814 }
815
816 let mut r = router.write().await;
817 let mut tried_agents: HashSet<String> = HashSet::new();
818 let mut chosen_agent: Option<String> = None;
819 let mut chosen_model: Option<String> = None;
820 let available_count = r.available_agents.len().max(1);
821
822 loop {
823 let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
824 let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
825 Some(a) => a,
826 None => break,
827 };
828
829 if tried_agents.contains(&agent) {
830 break;
831 }
832 tried_agents.insert(agent.clone());
833
834 let model = r.config.model_for_complexity(&agent, "review", task_id);
835 if model.is_some() {
836 chosen_agent = Some(agent);
837 chosen_model = model;
838 break;
839 }
840
841 exclude_set.insert(agent);
842 if exclude_set.len() >= available_count {
843 break;
844 }
845 }
846
847 if let Some(agent) = chosen_agent {
848 let same = !task_agent.is_empty() && agent == task_agent;
849 Ok((agent, chosen_model, same))
850 } else {
851 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
852 let fallback_agent = r
853 .next_round_robin_agent(&final_exclude_refs, "review")
854 .unwrap_or_else(|| "claude".to_string());
855 let fallback_model = r
856 .config
857 .model_for_complexity(&fallback_agent, "review", task_id);
858 if fallback_model.is_none() {
859 anyhow::bail!(
860 "no review agent with a configured review model is available for task {}",
861 task_id
862 );
863 }
864 let same = !task_agent.is_empty() && fallback_agent == task_agent;
865 Ok((fallback_agent, fallback_model, same))
866 }
867}
868
869#[allow(clippy::too_many_arguments)]
870async fn build_review_context(
871 task: &ExternalTask,
872 repo: &str,
873 router: &Arc<RwLock<Router>>,
874 task_manager: &Arc<TaskManager>,
875 store: &Arc<TaskStore>,
876) -> anyhow::Result<ReviewPhase<ReviewContext>> {
877 let stored_task = store
878 .get_by_external_id(repo, &task.id.0)
879 .await
880 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
881
882 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
883
884 if let Some(pr_number) = stored_task.as_ref().and_then(|t| t.pr_number) {
887 let pr_number = pr_number as u64;
888 if pr_head_already_approved(store, repo, &task.id.0, pr_number).await {
889 tracing::info!(
890 task_id = task.id.0,
891 pr_number,
892 "PR head already approved — skipping review, merge retry continues"
893 );
894 record_carried_approval(store, repo, &task.id.0, pr_number).await;
895 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve {
896 issues: Vec::new(),
897 }));
898 }
899 }
900
901 let had_prev_push_failure = stored_task
902 .as_ref()
903 .is_some_and(|t| t.last_error.contains("push failed"));
904 let review_attempt: u32 = match store_increment(
905 &Some(Arc::clone(store)),
906 repo,
907 &task.id.0,
908 "review_invocations",
909 )
910 .await
911 {
912 Ok(v) => v as u32,
913 Err(e) => {
914 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
915 1
916 }
917 };
918
919 let no_code_review = stored_task.as_ref().is_some_and(|t| {
920 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
921 });
922 if no_code_review {
923 if crate::engine::tasks::is_internal_id(&task.id.0) {
924 tracing::info!(
926 task_id = task.id.0,
927 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
928 );
929 if let Err(e) = task_manager
930 .update_task_status(&task.id, Status::Done)
931 .await
932 {
933 tracing::error!(
934 task_id = task.id.0,
935 err = %e,
936 "update_task_status(Done) failed for no-code review skip"
937 );
938 }
939 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
940 }
941
942 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
945 .ok()
946 .and_then(|s| s.parse().ok())
947 .or_else(|| {
948 config::get("workflow.max_attempts")
949 .ok()
950 .and_then(|s| s.parse().ok())
951 })
952 .unwrap_or(3);
953
954 let reroutes = match store_increment(
955 &Some(Arc::clone(store)),
956 repo,
957 &task.id.0,
958 "no_code_reroutes",
959 )
960 .await
961 {
962 Ok(v) => v,
963 Err(e) => {
964 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
965 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
966 "transient store error: {e}"
967 ))));
968 }
969 };
970
971 if reroutes as u32 >= max_reroutes {
972 tracing::error!(
973 task_id = task.id.0,
974 reroutes,
975 max_reroutes,
976 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
977 );
978 let msg = format!(
979 "no worktree/branch/pr after {}/{} reroute attempts",
980 reroutes, max_reroutes
981 );
982 let fields = [
983 (
984 "block_reason",
985 serde_json::json!(format!(
986 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
987 max_reroutes
988 )),
989 ),
990 ("last_error", serde_json::json!(msg)),
991 ("agent", serde_json::json!(null)),
992 ("model", serde_json::json!(null)),
993 ];
994 if let Err(e) = task_manager
995 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
996 .await
997 {
998 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
999 }
1000 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
1001 }
1002
1003 tracing::warn!(
1004 task_id = task.id.0,
1005 reroutes,
1006 max_reroutes,
1007 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
1008 );
1009 if let Err(e) = store_set_result(
1010 &Some(Arc::clone(store)),
1011 repo,
1012 &task.id.0,
1013 &[
1014 ("agent", serde_json::json!(null)),
1015 ("model", serde_json::json!(null)),
1016 (
1017 "last_error",
1018 serde_json::json!("no worktree/branch/pr produced"),
1019 ),
1020 ],
1021 )
1022 .await
1023 {
1024 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
1025 } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
1026 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
1027 }
1028 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
1029 }
1030
1031 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
1032 Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
1033 _ => {
1034 tracing::warn!(task_id = task.id.0, "no worktree found for review");
1035 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1036 "no worktree found".to_string(),
1037 )));
1038 }
1039 };
1040
1041 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
1042 Some(b) if !b.is_empty() => b.to_string(),
1043 _ => {
1044 tracing::warn!(task_id = task.id.0, "no branch found for review");
1045 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1046 "no branch found".to_string(),
1047 )));
1048 }
1049 };
1050
1051 let mut missing_worktree_recovered = false;
1052 if !worktree_path.exists() {
1053 match recover_missing_review_worktree(task, repo, store).await {
1054 Ok(recovered) => {
1055 branch_name = recovered.branch;
1056 worktree_path = recovered.work_dir;
1057 missing_worktree_recovered = true;
1058 }
1059 Err(e) => {
1060 let reason = format!("missing review worktree recovery failed: {e}");
1061 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
1062 store_set(
1063 &Some(Arc::clone(store)),
1064 repo,
1065 &task.id.0,
1066 &[("last_error", serde_json::json!(reason.clone()))],
1067 )
1068 .await;
1069 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
1070 }
1071 }
1072 }
1073
1074 if missing_worktree_recovered {
1075 store_set(
1076 &Some(Arc::clone(store)),
1077 repo,
1078 &task.id.0,
1079 &[("last_error", serde_json::json!(""))],
1080 )
1081 .await;
1082 }
1083
1084 let agent_summary = stored_task
1085 .as_ref()
1086 .map(|t| t.summary.clone())
1087 .unwrap_or_default();
1088 let stored_pr_number = stored_task
1089 .as_ref()
1090 .and_then(|t| t.pr_number)
1091 .map(|n| n as u64)
1092 .filter(|&n| n > 0);
1093
1094 let pr_number = match ensure_pr_exists(
1095 task,
1096 &branch_name,
1097 &worktree_path,
1098 repo,
1099 &agent_summary,
1100 store,
1101 task_manager,
1102 stored_pr_number,
1103 )
1104 .await?
1105 {
1106 EnsurePrResult::Ready(n) => n,
1107 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
1108 };
1109
1110 {
1114 let fetch_timeout = std::time::Duration::from_secs(60);
1115 let fetch_future = async {
1116 let mut cmd = Command::new("git");
1117 cmd.args(["fetch", "origin", "--prune"])
1118 .current_dir(&worktree_path);
1119 cmd.kill_on_drop(true);
1121 cmd.output_with_context().await
1122 };
1123
1124 match tokio::time::timeout(fetch_timeout, fetch_future).await {
1125 Ok(Ok(o)) if o.status.success() => {
1126 }
1128 Ok(Ok(o)) => {
1129 tracing::warn!(
1130 task_id = task.id.0,
1131 exit_code = ?o.status.code(),
1132 stdout = %String::from_utf8_lossy(&o.stdout),
1133 stderr = %String::from_utf8_lossy(&o.stderr),
1134 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
1135 );
1136 }
1137 Ok(Err(e)) => {
1138 tracing::warn!(
1139 task_id = task.id.0,
1140 error = %e,
1141 "review: git fetch failed — diff/log may use stale remote refs"
1142 );
1143 }
1144 Err(_) => {
1145 tracing::warn!(
1146 task_id = task.id.0,
1147 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
1148 );
1149 }
1150 }
1151 }
1152
1153 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
1154 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
1155 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
1156 let project_dir = crate::config::get_projects_with_paths()
1159 .unwrap_or_default()
1160 .into_iter()
1161 .find(|(candidate, _)| candidate == repo)
1162 .map(|(_, dir)| dir);
1163 let sandboxed = match &project_dir {
1164 Some(dir) => runner::sandbox::enabled(dir),
1165 None => runner::sandbox::enabled_globally(),
1166 };
1167 let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
1168 let review_prompt = runner::agent::build_review_prompt(
1169 task,
1170 &agent_summary,
1171 &git_diff,
1172 &git_log,
1173 &default_branch,
1174 pr_number,
1175 runner::agent::Shipcheck::detect(&worktree_path)
1176 .await
1177 .for_prompt(sandboxed),
1178 );
1179
1180 let task_agent = stored_task
1181 .as_ref()
1182 .and_then(|t| t.agent.clone())
1183 .unwrap_or_default();
1184 let (review_agent, review_model, same_agent_fallback) =
1185 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
1186
1187 anyhow::ensure!(
1188 review_model.is_some(),
1189 "review model missing for task {} after agent selection — refusing to dispatch",
1190 task.id.0
1191 );
1192
1193 if same_agent_fallback {
1194 tracing::warn!(
1195 task_id = task.id.0,
1196 agent = %review_agent,
1197 "no other agent available for review — falling back to the agent that wrote the code"
1198 );
1199 store_log_activity(
1200 &Some(Arc::clone(store)),
1201 repo,
1202 &task.id.0,
1203 "review_agent_fallback",
1204 None,
1205 None,
1206 Some(&review_agent),
1207 review_model.as_deref(),
1208 Some(&serde_json::json!({
1209 "reason": "all available agents excluded (author + previous reviewers)",
1210 })),
1211 )
1212 .await;
1213 }
1214
1215 tracing::info!(
1216 task_id = task.id.0,
1217 agent = %review_agent,
1218 model = ?review_model,
1219 "spawning review agent"
1220 );
1221
1222 let review_task_id = format!("{}-review", task.id.0);
1223 let review_attempt_dir =
1224 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
1225 let output_file = review_attempt_dir.join("output.json");
1226
1227 let git_name =
1228 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
1229 let git_email = crate::config::get("git.email")
1230 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
1231 let review_timeout_secs = review_timeout_seconds();
1232
1233 let invocation = runner::agent::AgentInvocation {
1234 agent: review_agent.clone(),
1235 model: review_model.clone(),
1236 work_dir: worktree_path.clone(),
1237 system_prompt: runner::agent::review_system_prompt(),
1238 agent_message: review_prompt,
1239 task_id: review_task_id.clone(),
1240 disallowed_tools: build_review_disallowed_tools(&worktree_path),
1241 git_author_name: git_name,
1242 git_author_email: git_email,
1243 output_file: output_file.clone(),
1244 timeout_seconds: review_timeout_secs,
1245 repo: repo.to_string(),
1246 attempt: review_attempt,
1247 sandbox: sandboxed,
1248 main_project_dir,
1249 branch: branch_name.clone(),
1250 };
1251
1252 let head_sha = match GhHttp::new() {
1253 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
1254 Err(_) => None,
1255 };
1256
1257 Ok(ReviewPhase::Ready(ReviewContext {
1258 store_id,
1259 had_prev_push_failure,
1260 review_attempt,
1261 worktree_path,
1262 branch_name,
1263 pr_number,
1264 head_sha,
1265 default_branch,
1266 review_agent,
1267 review_model,
1268 review_task_id,
1269 review_attempt_dir,
1270 output_file,
1271 invocation,
1272 same_agent_fallback,
1273 }))
1274}
1275
1276#[allow(clippy::too_many_arguments)]
1277async fn complete_review_run(
1278 store: &Arc<TaskStore>,
1279 run_id: Option<i64>,
1280 exit_code: Option<i32>,
1281 stdout: &str,
1282 stderr: &str,
1283 parsed: &str,
1284 outcome: &str,
1285 error: &str,
1286 tokens: RunTokenUsage,
1287) {
1288 if let Some(run_id) = run_id {
1289 if let Err(e) = store
1290 .complete_run(&CompleteRun {
1291 run_id,
1292 exit_code,
1293 stdout,
1294 stderr,
1295 parsed,
1296 outcome,
1297 error,
1298 tokens,
1299 })
1300 .await
1301 {
1302 tracing::warn!(
1303 run_id,
1304 error = %e,
1305 "failed to record review run completion in audit trail"
1306 );
1307 }
1308 }
1309}
1310
1311async fn invoke_review_agent(
1312 task: &ExternalTask,
1313 tmux: &Arc<TmuxManager>,
1314 ctx: &ReviewContext,
1315 repo: &str,
1316 store: &Arc<TaskStore>,
1317) -> ReviewPhase<ReviewRun> {
1318 let run_id = if let Some(sid) = ctx.store_id {
1319 match store
1320 .start_run(&StartRun {
1321 task_id: sid,
1322 attempt: ctx.review_attempt as i32,
1323 run_type: "review",
1324 agent: &ctx.review_agent,
1325 model: ctx.review_model.as_deref().unwrap_or(""),
1326 command: "",
1327 prompt: "",
1328 })
1329 .await
1330 {
1331 Ok(run_id) => Some(run_id),
1332 Err(e) => {
1333 tracing::warn!(
1334 task_id = task.id.0,
1335 error = %e,
1336 "failed to record review run start in audit trail"
1337 );
1338 None
1339 }
1340 }
1341 } else {
1342 None
1343 };
1344
1345 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
1346 Ok(session) => session,
1347 Err(e) => {
1348 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
1349 complete_review_run(
1350 store,
1351 run_id,
1352 None,
1353 "",
1354 &e.to_string(),
1355 "",
1356 "failed",
1357 &format!("spawn failed: {e}"),
1358 RunTokenUsage::default(),
1359 )
1360 .await;
1361 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
1362 }
1363 };
1364
1365 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
1366 match GhHttp::new() {
1367 Ok(gh) => {
1368 let app = review_app(repo).await;
1369 post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
1370 if let Err(e) = gh
1371 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
1372 .await
1373 {
1374 tracing::debug!(
1375 task_id = task.id.0,
1376 pr_number = ctx.pr_number,
1377 error = %e,
1378 "failed to post review-started comment"
1379 );
1380 }
1381 }
1382 Err(e) => {
1383 tracing::debug!(
1384 task_id = task.id.0,
1385 pr_number = ctx.pr_number,
1386 error = %e,
1387 "failed to create GitHub client for review-started comment"
1388 );
1389 }
1390 }
1391
1392 let poll_interval = std::time::Duration::from_secs(5);
1393 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
1394 let wait_result = tokio::time::timeout(
1395 timeout_duration,
1396 tmux.wait_for_completion(&session, poll_interval),
1397 )
1398 .await;
1399
1400 match wait_result {
1401 Ok(Ok(_)) => {
1402 tracing::info!(task_id = task.id.0, "review agent completed");
1403 let _ = tmux.kill_session(&session).await;
1404 ReviewPhase::Ready(ReviewRun { run_id })
1405 }
1406 Ok(Err(e)) => {
1407 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
1408 let _ = tmux.kill_session(&session).await;
1409
1410 let exit_code: i32 = tokio::task::spawn_blocking({
1415 let review_attempt_dir = ctx.review_attempt_dir.clone();
1416 move || {
1417 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
1418 .ok()
1419 .and_then(|s| s.trim().parse().ok())
1420 .unwrap_or(-1)
1421 }
1422 })
1423 .await
1424 .unwrap_or(-1);
1425
1426 if exit_code != 0 {
1427 let raw_output =
1432 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1433 .await;
1434 let agent_result =
1435 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1436 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1438 tracing::info!(
1439 task_id = task.id.0,
1440 agent = %ctx.review_agent,
1441 exit_code,
1442 output_found = !raw_output.is_empty(),
1443 "review agent succeeded despite non-zero exit — proceeding to parse output"
1444 );
1445 return ReviewPhase::Ready(ReviewRun { run_id });
1446 }
1447 }
1448
1449 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1450 complete_review_run(
1451 store,
1452 run_id,
1453 Some(exit_code),
1454 "",
1455 &e.to_string(),
1456 "",
1457 "failed",
1458 &format!("agent error: {e}"),
1459 RunTokenUsage::default(),
1460 )
1461 .await;
1462 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1463 }
1464 Err(_) => {
1465 tracing::error!(task_id = task.id.0, "review agent timed out");
1466 let _ = tmux.kill_session(&session).await;
1467 complete_review_run(
1470 store,
1471 run_id,
1472 Some(-1),
1473 "",
1474 "timeout",
1475 "",
1476 "timeout",
1477 "timeout",
1478 RunTokenUsage::default(),
1479 )
1480 .await;
1481 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1482 }
1483 }
1484}
1485
1486async fn record_review_agent_failure(
1487 task_id: &str,
1488 review_agent: &str,
1489 review_model: Option<&str>,
1490 err: &runner::agents::AgentError,
1491) {
1492 match err {
1493 runner::agents::AgentError::RateLimit { message }
1494 | runner::agents::AgentError::Auth { message } => {
1495 tracing::warn!(
1496 task_id,
1497 agent = %review_agent,
1498 "review agent hit error — adding to cooldown"
1499 );
1500 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1501 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1502 {
1503 if let Some(model) = review_model {
1504 crate::engine::cooldown::record_persistent_model_failure(
1506 review_agent,
1507 model,
1508 )
1509 .await;
1510 } else {
1511 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1512 .await;
1513 }
1514 } else {
1515 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1516 }
1517 } else {
1518 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1519 .await;
1520 }
1521 }
1522 runner::agents::AgentError::Interrupted { .. } => {}
1524 runner::agents::AgentError::InvalidResponse { .. } => {
1525 if let Some(model) = review_model {
1527 crate::engine::cooldown::record_transient_model_failure(review_agent, model).await;
1528 }
1529 }
1530 runner::agents::AgentError::ModelUnavailable { .. } => {
1531 if let Some(model) = review_model {
1536 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1537 }
1538 }
1539 _ => {
1540 if let Some(model) = review_model {
1543 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1544 }
1545 }
1546 }
1547}
1548
1549fn review_decision_from_response(
1550 review_response: runner::response::ReviewResponse,
1551) -> ReviewDecision {
1552 match review_response.decision.as_str() {
1553 "approve" => approve_from_evidence(review_response),
1554 "request_changes" => ReviewDecision::RequestChanges {
1555 notes: review_response.notes,
1556 issues: review_response.issues,
1557 },
1558 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1559 }
1560}
1561
1562fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1564 let tests_failed = review_response
1565 .test_results
1566 .as_deref()
1567 .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1568 let has_error = review_response
1569 .issues
1570 .iter()
1571 .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1572 if tests_failed || has_error {
1573 let notes = if tests_failed {
1574 notes_for_failed_tests(review_response.notes)
1575 } else {
1576 review_response.notes
1577 };
1578 ReviewDecision::RequestChanges {
1579 notes,
1580 issues: review_response.issues,
1581 }
1582 } else {
1583 ReviewDecision::Approve {
1584 issues: review_response.issues,
1585 }
1586 }
1587}
1588
1589fn notes_for_failed_tests(notes: String) -> String {
1590 const MARK: &str = "Review reported test_results: fail";
1591 if notes.trim().is_empty() {
1592 MARK.to_string()
1593 } else if notes.contains("test_results: fail") {
1594 notes
1595 } else {
1596 format!("{notes}\n\n{MARK}")
1597 }
1598}
1599
1600fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1601 match decision {
1602 ReviewDecision::Approve { .. } => "approve",
1603 ReviewDecision::RequestChanges { .. } => "request_changes",
1604 ReviewDecision::Failed(_) => "failed",
1605 ReviewDecision::Blocked(_) => "blocked",
1606 ReviewDecision::Skipped => "skipped",
1607 ReviewDecision::Rerouted => "rerouted",
1608 }
1609}
1610
1611async fn parse_review_output(
1612 task: &ExternalTask,
1613 repo: &str,
1614 ctx: &ReviewContext,
1615 run: ReviewRun,
1616 store: &Arc<TaskStore>,
1617) -> ReviewPhase<ParsedReview> {
1618 let (file_exists, file_size, exit_code, stderr) = {
1619 let output_file_clone = ctx.output_file.clone();
1620 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1621 match tokio::task::spawn_blocking(move || {
1622 let file_exists = output_file_clone.exists();
1623 let file_size = std::fs::metadata(&output_file_clone)
1624 .map(|m| m.len())
1625 .unwrap_or(0);
1626 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1627 .ok()
1628 .and_then(|s| s.trim().parse::<i32>().ok())
1629 .unwrap_or(-1);
1630 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1631 .unwrap_or_default();
1632 (file_exists, file_size, exit_code, stderr)
1633 })
1634 .await
1635 {
1636 Ok(tuple) => tuple,
1637 Err(e) => {
1638 tracing::error!(
1639 task_id = task.id.0,
1640 error = %e,
1641 "spawn_blocking panicked reading review output metadata"
1642 );
1643 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1644 }
1645 }
1646 };
1647
1648 tracing::info!(
1649 task_id = task.id.0,
1650 path = %ctx.output_file.display(),
1651 file_exists,
1652 file_size,
1653 "reading review agent output"
1654 );
1655
1656 let raw_output =
1657 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1658 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1659 let has_result_envelope =
1660 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1661 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1662 let token_usage = RunTokenUsage {
1663 input_tokens: agent_result
1664 .as_ref()
1665 .and_then(|r| r.input_tokens)
1666 .unwrap_or(0),
1667 output_tokens: agent_result
1668 .as_ref()
1669 .and_then(|r| r.output_tokens)
1670 .unwrap_or(0),
1671 total_cost_usd: agent_result
1672 .as_ref()
1673 .and_then(|r| r.cost_usd)
1674 .unwrap_or(0.0),
1675 duration_secs: 0.0,
1676 };
1677
1678 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1679 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1680 && !raw_output.contains("\"is_error\":true");
1681 let is_hard_failure =
1683 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1684
1685 if is_hard_failure {
1686 tracing::warn!(
1687 task_id = task.id.0,
1688 exit_code,
1689 raw_output_len = raw_output.len(),
1690 stderr_len = stderr.len(),
1691 "review agent: entering error path"
1692 );
1693 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1694 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1695 record_review_agent_failure(
1696 &task.id.0,
1697 &ctx.review_agent,
1698 ctx.review_model.as_deref(),
1699 &err,
1700 )
1701 .await;
1702 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1703 let stored_error = format_review_error(&ctx.review_agent, &err);
1704 complete_review_run(
1705 store,
1706 run.run_id,
1707 Some(exit_code),
1708 &raw_output,
1709 &stderr,
1710 "",
1711 outcome_for_agent_error(&err),
1712 &stored_error,
1713 token_usage,
1714 )
1715 .await;
1716 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1717 }
1718
1719 let text_for_review = agent_result
1720 .as_ref()
1721 .map(|r| r.result_text.clone())
1722 .filter(|text| !text.is_empty())
1723 .unwrap_or_else(|| {
1724 tracing::debug!(
1725 task_id = task.id.0,
1726 agent = %ctx.review_agent,
1727 "review agent: per-agent extractor returned no text, using raw output"
1728 );
1729 raw_output.clone()
1730 });
1731
1732 let review_response = match runner::response::parse_review_response(&text_for_review) {
1733 Ok(response) => response,
1734 Err(e) => {
1735 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1736 tracing::warn!(
1737 task_id = task.id.0,
1738 agent = %ctx.review_agent,
1739 "review response parsed via keyword fallback"
1740 );
1741 response
1742 } else {
1743 let ndjson_completed = ndjson_completed_no_error;
1749
1750 if ndjson_completed || !has_result_envelope {
1753 let assistant_text = runner::agents::collect_assistant_messages_text(
1754 &ctx.review_agent,
1755 &raw_output,
1756 );
1757 if !assistant_text.is_empty() {
1758 if let Ok(response) =
1759 runner::response::parse_review_response(&assistant_text)
1760 {
1761 tracing::debug!(
1762 task_id = task.id.0,
1763 agent = %ctx.review_agent,
1764 "found review response JSON in assistant message (terminal_reason:completed)"
1765 );
1766 let review_notes_for_comment = response.notes.clone();
1767 let decision = review_decision_from_response(response);
1768 return ReviewPhase::Ready(ParsedReview {
1769 run_id: run.run_id,
1770 exit_code,
1771 stderr,
1772 raw_output,
1773 text_for_review: assistant_text,
1774 token_usage,
1775 review_notes_for_comment,
1776 decision,
1777 });
1778 }
1779 if let Some(response) =
1780 runner::response::infer_review_response(&assistant_text)
1781 {
1782 tracing::warn!(
1783 task_id = task.id.0,
1784 agent = %ctx.review_agent,
1785 "inferred review response from assistant message (terminal_reason:completed)"
1786 );
1787 let review_notes_for_comment = response.notes.clone();
1788 let decision = review_decision_from_response(response);
1789 return ReviewPhase::Ready(ParsedReview {
1790 run_id: run.run_id,
1791 exit_code,
1792 stderr,
1793 raw_output,
1794 text_for_review: assistant_text,
1795 token_usage,
1796 review_notes_for_comment,
1797 decision,
1798 });
1799 }
1800 }
1801 }
1802
1803 let scan_head = runner::agents::patterns::safe_head(
1810 &text_for_review,
1811 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1812 );
1813 let scan_tail = runner::agents::patterns::safe_tail(
1814 &text_for_review,
1815 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1816 );
1817
1818 if let Some(err) = (!ndjson_completed)
1819 .then(|| {
1820 runner::agents::patterns::detect_rate_limit(scan_head)
1821 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1822 })
1823 .flatten()
1824 {
1825 let runner::agents::AgentError::RateLimit { message } = &err else {
1826 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1827 };
1828 tracing::warn!(
1829 task_id = task.id.0,
1830 agent = %ctx.review_agent,
1831 "review agent hit rate limit — adding to cooldown"
1832 );
1833 let summary = runner::fallback::summarize_rate_limit_error(message);
1840 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1841 record_review_agent_failure(
1842 &task.id.0,
1843 &ctx.review_agent,
1844 ctx.review_model.as_deref(),
1845 &err,
1846 )
1847 .await;
1848 complete_review_run(
1849 store,
1850 run.run_id,
1851 Some(exit_code),
1852 &raw_output,
1853 &stderr,
1854 &text_for_review,
1855 outcome_for_agent_error(&err),
1856 &stored_error,
1857 token_usage,
1858 )
1859 .await;
1860 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1861 }
1862
1863 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1864 let runner::agents::AgentError::Auth { message } = &err else {
1865 unreachable!("detect_auth_error only returns AgentError::Auth")
1866 };
1867 tracing::warn!(
1868 task_id = task.id.0,
1869 agent = %ctx.review_agent,
1870 "review agent hit auth error — adding to cooldown"
1871 );
1872 let stored_error = format!("auth error: {message}");
1873 record_review_agent_failure(
1874 &task.id.0,
1875 &ctx.review_agent,
1876 ctx.review_model.as_deref(),
1877 &err,
1878 )
1879 .await;
1880 complete_review_run(
1881 store,
1882 run.run_id,
1883 Some(exit_code),
1884 &raw_output,
1885 &stderr,
1886 &text_for_review,
1887 outcome_for_agent_error(&err),
1888 &stored_error,
1889 token_usage,
1890 )
1891 .await;
1892 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1893 }
1894
1895 if agent_result
1897 .as_ref()
1898 .map(|r| r.truncated_by_length)
1899 .unwrap_or(false)
1900 {
1901 tracing::warn!(
1902 task_id = task.id.0,
1903 agent = %ctx.review_agent,
1904 model = ?ctx.review_model,
1905 "review agent output truncated: step_finish reason=length \
1906 (token budget exceeded before completion)"
1907 );
1908 if let Some(model) = ctx.review_model.as_deref() {
1909 crate::engine::cooldown::record_transient_model_failure(
1910 &ctx.review_agent,
1911 model,
1912 )
1913 .await;
1914 }
1915 let stored_error = "truncated: review output was cut off before completion \
1916 (output/reasoning token budget exceeded)"
1917 .to_string();
1918 complete_review_run(
1919 store,
1920 run.run_id,
1921 Some(exit_code),
1922 &raw_output,
1923 &stderr,
1924 &text_for_review,
1925 "truncated",
1926 &stored_error,
1927 token_usage,
1928 )
1929 .await;
1930 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1931 }
1932
1933 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1936 tracing::warn!(
1937 task_id = task.id.0,
1938 agent = %ctx.review_agent,
1939 model = ?ctx.review_model,
1940 "review session exited mid-thought with no result envelope \
1941 — storing as incomplete and retrying without persistent cooldown"
1942 );
1943 let stored_error =
1944 "incomplete: review session exited before producing a decision".to_string();
1945 complete_review_run(
1946 store,
1947 run.run_id,
1948 Some(exit_code),
1949 &raw_output,
1950 &stderr,
1951 &text_for_review,
1952 "incomplete",
1953 &stored_error,
1954 token_usage,
1955 )
1956 .await;
1957 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1958 }
1959
1960 tracing::error!(
1961 task_id = task.id.0,
1962 error = %e,
1963 agent = %ctx.review_agent,
1964 output = %text_for_review.chars().take(300).collect::<String>(),
1965 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1966 "failed to parse review response"
1967 );
1968 if let Some(model) = ctx.review_model.as_deref() {
1969 crate::engine::cooldown::record_transient_model_failure(
1970 &ctx.review_agent,
1971 model,
1972 )
1973 .await;
1974 }
1975 complete_review_run(
1976 store,
1977 run.run_id,
1978 Some(exit_code),
1979 &raw_output,
1980 &stderr,
1981 &text_for_review,
1982 "parse_error",
1983 &format!("parse error: {e}"),
1984 token_usage,
1985 )
1986 .await;
1987 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1988 "parse error: {e}"
1989 )));
1990 }
1991 }
1992 };
1993
1994 let review_notes_for_comment = review_response.notes.clone();
1995 let decision = review_decision_from_response(review_response);
1996
1997 tracing::info!(
1998 task_id = task.id.0,
1999 pr_number = ctx.pr_number,
2000 decision = ?decision,
2001 "review agent decision received"
2002 );
2003 store_log_activity(
2004 &Some(Arc::clone(store)),
2005 repo,
2006 &task.id.0,
2007 "review_decision",
2008 None,
2009 None,
2010 Some(&ctx.review_agent),
2011 ctx.review_model.as_deref(),
2012 Some(&serde_json::json!({
2013 "decision": review_decision_name(&decision),
2014 "pr_number": ctx.pr_number,
2015 })),
2016 )
2017 .await;
2018
2019 ReviewPhase::Ready(ParsedReview {
2020 run_id: run.run_id,
2021 exit_code,
2022 stderr,
2023 raw_output,
2024 text_for_review,
2025 token_usage,
2026 review_notes_for_comment,
2027 decision,
2028 })
2029}
2030
2031async fn finalize_review_run(
2032 task: &ExternalTask,
2033 ctx: &ReviewContext,
2034 parsed: &ParsedReview,
2035 store: &Arc<TaskStore>,
2036) {
2037 let outcome = match &parsed.decision {
2038 ReviewDecision::Approve { .. }
2039 | ReviewDecision::RequestChanges { .. }
2040 | ReviewDecision::Skipped
2041 | ReviewDecision::Rerouted => "success",
2042 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
2043 };
2044 let error = match &parsed.decision {
2045 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
2046 _ => "",
2047 };
2048
2049 if outcome == "success" {
2050 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
2053 .await;
2054 }
2055
2056 complete_review_run(
2057 store,
2058 parsed.run_id,
2059 Some(parsed.exit_code),
2060 &parsed.raw_output,
2061 &parsed.stderr,
2062 &parsed.text_for_review,
2063 outcome,
2064 error,
2065 parsed.token_usage,
2066 )
2067 .await;
2068
2069 if let Some(store_id) = ctx.store_id {
2070 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
2071 let model = if ctx.review_model_str().is_empty() {
2072 "unknown"
2073 } else {
2074 ctx.review_model_str()
2075 };
2076 if let Err(e) = store
2077 .store_tokens(
2078 store_id,
2079 parsed.token_usage.input_tokens,
2080 parsed.token_usage.output_tokens,
2081 model,
2082 )
2083 .await
2084 {
2085 tracing::warn!(
2086 task_id = task.id.0,
2087 ?e,
2088 "failed to store review agent token usage"
2089 );
2090 }
2091 }
2092 }
2093}
2094
2095async fn restore_review_config_if_needed(
2096 task_id: &str,
2097 worktree_path: &std::path::Path,
2098 default_branch: &str,
2099) {
2100 let _ = Command::new("git")
2101 .args(["checkout", "HEAD", "--", ".orch.yml"])
2102 .current_dir(worktree_path)
2103 .status()
2104 .await;
2105
2106 let merge_base = Command::new("git")
2107 .args(["merge-base", "HEAD", default_branch])
2108 .current_dir(worktree_path)
2109 .output()
2110 .await
2111 .ok()
2112 .filter(|output| output.status.success())
2113 .and_then(|output| String::from_utf8(output.stdout).ok())
2114 .map(|s| s.trim().to_string())
2115 .unwrap_or_default();
2116
2117 if merge_base.is_empty() {
2118 return;
2119 }
2120
2121 let orch_yml_changed = Command::new("git")
2122 .args([
2123 "diff",
2124 "--name-only",
2125 &merge_base,
2126 "HEAD",
2127 "--",
2128 ".orch.yml",
2129 ])
2130 .current_dir(worktree_path)
2131 .output()
2132 .await
2133 .ok()
2134 .filter(|output| output.status.success())
2135 .map(|output| !output.stdout.is_empty())
2136 .unwrap_or(false);
2137
2138 if !orch_yml_changed {
2139 return;
2140 }
2141
2142 tracing::warn!(
2143 task_id,
2144 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
2145 );
2146 let restore_ok = Command::new("git")
2147 .args(["checkout", &merge_base, "--", ".orch.yml"])
2148 .current_dir(worktree_path)
2149 .status()
2150 .await
2151 .map(|status| status.success())
2152 .unwrap_or(false);
2153
2154 if !restore_ok {
2155 return;
2156 }
2157
2158 let staged = Command::new("git")
2159 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
2160 .current_dir(worktree_path)
2161 .output()
2162 .await
2163 .ok()
2164 .filter(|output| output.status.success())
2165 .map(|output| !output.stdout.is_empty())
2166 .unwrap_or(false);
2167
2168 if staged {
2169 let _ = Command::new("git")
2170 .args([
2171 "-c",
2172 "commit.gpgsign=false",
2173 "commit",
2174 "-m",
2175 "revert: restore .orch.yml (review agent must not modify project config)",
2176 ])
2177 .current_dir(worktree_path)
2178 .status()
2179 .await;
2180 }
2181}
2182
2183fn is_missing_path_error_message(err: &str) -> bool {
2184 err.to_ascii_lowercase()
2185 .contains("no such file or directory")
2186}
2187
2188async fn push_review_branch(
2189 task: &ExternalTask,
2190 repo: &str,
2191 ctx: &ReviewContext,
2192 store: &Arc<TaskStore>,
2193) -> Result<(), String> {
2194 let mut worktree_path = ctx.worktree_path.clone();
2195 let mut branch_name = ctx.branch_name.clone();
2196 let mut retried_missing_worktree = false;
2197
2198 let push_result = loop {
2199 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
2200 {
2201 Ok(ok) => break Ok(ok),
2202 Err(e)
2203 if !retried_missing_worktree
2204 && !worktree_path.exists()
2205 && is_missing_path_error_message(&e.to_string()) =>
2206 {
2207 retried_missing_worktree = true;
2208 tracing::warn!(
2209 task_id = task.id.0,
2210 worktree = %worktree_path.display(),
2211 branch = %branch_name,
2212 "review push failed because worktree disappeared; attempting one-time recovery"
2213 );
2214 match recover_missing_review_worktree(task, repo, store).await {
2215 Ok(recovered) => {
2216 worktree_path = recovered.work_dir;
2217 branch_name = recovered.branch;
2218 continue;
2219 }
2220 Err(recover_err) => {
2221 break Err(anyhow::anyhow!(
2222 "missing review worktree recovery failed after push error: {recover_err}"
2223 ));
2224 }
2225 }
2226 }
2227 Err(e) => break Err(e),
2228 }
2229 };
2230
2231 match push_result {
2232 Ok(_) => {
2233 store_log_activity(
2234 &Some(Arc::clone(store)),
2235 repo,
2236 &task.id.0,
2237 "push",
2238 None,
2239 None,
2240 Some(&ctx.review_agent),
2241 ctx.review_model.as_deref(),
2242 Some(&serde_json::json!({
2243 "status": "ok",
2244 "branch": branch_name.clone(),
2245 "default_branch": ctx.default_branch.clone(),
2246 "phase": "review",
2247 })),
2248 )
2249 .await;
2250 if ctx.had_prev_push_failure {
2251 if let Err(e) = store_set_result(
2252 &Some(Arc::clone(store)),
2253 repo,
2254 &task.id.0,
2255 &[
2256 ("last_error", serde_json::json!("")),
2257 ("push_failures", serde_json::json!(0)),
2258 ],
2259 )
2260 .await
2261 {
2262 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
2263 }
2264 }
2265 Ok(())
2266 }
2267 Err(e) => {
2268 store_log_activity(
2269 &Some(Arc::clone(store)),
2270 repo,
2271 &task.id.0,
2272 "push",
2273 None,
2274 None,
2275 Some(&ctx.review_agent),
2276 ctx.review_model.as_deref(),
2277 Some(&serde_json::json!({
2278 "status": "error",
2279 "branch": branch_name.clone(),
2280 "default_branch": ctx.default_branch.clone(),
2281 "phase": "review",
2282 "error": e.to_string(),
2283 })),
2284 )
2285 .await;
2286 tracing::error!(
2287 task_id = task.id.0,
2288 branch = %ctx.branch_name,
2289 error = %e,
2290 "review push failed"
2291 );
2292 store_set(
2293 &Some(Arc::clone(store)),
2294 repo,
2295 &task.id.0,
2296 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
2297 )
2298 .await;
2299 Err(e.to_string())
2300 }
2301 }
2302}
2303
2304fn append_review_issues(
2305 body: &mut String,
2306 issues: &[crate::engine::runner::response::ReviewIssue],
2307) {
2308 if issues.is_empty() {
2309 return;
2310 }
2311 body.push_str("\n**Issues Found:**\n");
2312 for issue in issues {
2313 body.push_str(&format!(
2314 "- `{}` line {}: {} [{}]\n",
2315 issue.file,
2316 issue
2317 .line
2318 .map(|line| line.to_string())
2319 .unwrap_or_else(|| "?".to_string()),
2320 issue.description,
2321 issue.severity
2322 ));
2323 }
2324}
2325
2326fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
2327 match decision {
2328 ReviewDecision::Approve { issues } => {
2329 let mut body = format!(
2330 "## Automated Review \u{2014} Approve\n\n{}",
2331 review_notes_for_comment
2332 );
2333 if !issues.is_empty() && !body.ends_with('\n') {
2334 body.push('\n');
2335 }
2336 append_review_issues(&mut body, issues);
2337 body
2338 }
2339 ReviewDecision::RequestChanges { notes, issues } => {
2340 let mut body = format!(
2341 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
2342 notes
2343 );
2344 append_review_issues(&mut body, issues);
2345 body
2346 }
2347 _ => String::new(),
2348 }
2349}
2350
2351const REVIEW_STATUS_CONTEXT: &str = "orch/review";
2352
2353fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
2355 match decision {
2356 ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
2357 ReviewDecision::RequestChanges { .. } => {
2358 Some(("failure", "Automated review requested changes"))
2359 }
2360 _ => None,
2361 }
2362}
2363
2364async fn post_review_status(
2367 gh: &GhHttp,
2368 repo: &str,
2369 sha: Option<&str>,
2370 state: &str,
2371 description: &str,
2372) {
2373 let Some(sha) = sha else {
2374 return;
2375 };
2376 if let Err(e) = gh
2377 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
2378 .await
2379 {
2380 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
2381 }
2382}
2383
2384async fn post_review_comment(
2385 task: &ExternalTask,
2386 repo: &str,
2387 ctx: &ReviewContext,
2388 decision: &ReviewDecision,
2389 review_notes_for_comment: &str,
2390) -> anyhow::Result<()> {
2391 let gh = match GhHttp::new() {
2392 Ok(gh) => gh,
2393 Err(e) => {
2394 tracing::warn!(
2395 task_id = task.id.0,
2396 error = %e,
2397 "failed to create GH client for review comment"
2398 );
2399 return Ok(());
2400 }
2401 };
2402 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
2403 if ctx.same_agent_fallback && !pr_comment.is_empty() {
2404 pr_comment.push_str(
2405 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
2406 );
2407 }
2408
2409 let head_sha = worktree_head_sha(&ctx.worktree_path)
2412 .await
2413 .or_else(|| ctx.head_sha.clone());
2414
2415 let app = review_app(repo).await;
2416 post_review_decision(
2417 app.as_deref(),
2418 &gh,
2419 repo,
2420 head_sha.as_deref(),
2421 decision,
2422 review_notes_for_comment,
2423 )
2424 .await;
2425
2426 if !pr_comment.is_empty() {
2427 let footer =
2428 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2429 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
2430 if let Err(e) = publish_review(
2431 app.as_deref(),
2432 &gh,
2433 repo,
2434 ctx.pr_number,
2435 head_sha.as_deref(),
2436 decision,
2437 &pr_comment_with_footer,
2438 )
2439 .await
2440 {
2441 tracing::warn!(
2442 task_id = task.id.0,
2443 pr_number = ctx.pr_number,
2444 error = %e,
2445 "failed to post automated review comment on PR"
2446 );
2447 }
2448 }
2449
2450 Ok(())
2451}
2452
2453async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
2455 let mode = crate::review_mode::for_repo(repo);
2456 review_app_for(mode, crate::github::app::shared(), repo).await
2457}
2458
2459async fn review_app_for(
2460 mode: crate::review_mode::ReviewMode,
2461 app: Option<Arc<crate::github::app::AppAuth>>,
2462 repo: &str,
2463) -> Option<Arc<crate::github::app::AppAuth>> {
2464 if mode != crate::review_mode::ReviewMode::App {
2465 return None;
2466 }
2467
2468 let app = app?;
2469 match app.token_for_repo(repo).await {
2470 Ok(Some(_)) => Some(app),
2471 Ok(None) => {
2472 tracing::info!(
2473 repo,
2474 "review.mode is app but the App is not installed here, reviewing with a comment"
2475 );
2476 None
2477 }
2478 Err(e) => {
2479 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2480 None
2481 }
2482 }
2483}
2484
2485async fn post_review_started(
2487 app: Option<&crate::github::app::AppAuth>,
2488 gh: &GhHttp,
2489 repo: &str,
2490 sha: Option<&str>,
2491) {
2492 use crate::github::app::CheckRunState;
2493 match (app, sha) {
2494 (Some(app), Some(sha)) => {
2495 if let Err(e) = app
2496 .post_check_run(repo, sha, CheckRunState::InProgress)
2497 .await
2498 {
2499 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2500 }
2501 }
2502 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2503 }
2504}
2505
2506async fn post_review_decision(
2507 app: Option<&crate::github::app::AppAuth>,
2508 gh: &GhHttp,
2509 repo: &str,
2510 sha: Option<&str>,
2511 decision: &ReviewDecision,
2512 notes: &str,
2513) {
2514 use crate::github::app::CheckRunState;
2515 let Some((state, description)) = review_status(decision) else {
2516 return;
2517 };
2518
2519 match (app, sha) {
2520 (Some(app), Some(sha)) => {
2521 let approved = state == "success";
2522 let completed = CheckRunState::Completed {
2523 approved,
2524 summary: if notes.is_empty() { description } else { notes },
2525 };
2526 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2527 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2528 }
2529 }
2530 _ => post_review_status(gh, repo, sha, state, description).await,
2531 }
2532}
2533
2534async fn publish_review(
2538 app: Option<&crate::github::app::AppAuth>,
2539 gh: &GhHttp,
2540 repo: &str,
2541 pr_number: u64,
2542 head_sha: Option<&str>,
2543 decision: &ReviewDecision,
2544 comment: &str,
2545) -> anyhow::Result<()> {
2546 use crate::github::app::ReviewEvent;
2547 let event = match decision {
2548 ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2549 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2550 _ => None,
2551 };
2552 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2553 let inline = inline_comments(app, repo, pr_number, decision).await;
2554 if let Err(e) = app
2555 .submit_review(repo, pr_number, event, comment, sha, &inline)
2556 .await
2557 {
2558 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2559 }
2560 }
2561 gh.add_comment(repo, &pr_number.to_string(), comment).await
2562}
2563
2564async fn inline_comments(
2567 app: &crate::github::app::AppAuth,
2568 repo: &str,
2569 pr_number: u64,
2570 decision: &ReviewDecision,
2571) -> Vec<crate::github::app::InlineComment> {
2572 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2573 return Vec::new();
2574 };
2575
2576 if issues.is_empty() {
2577 return Vec::new();
2578 }
2579
2580 match app.pull_request_hunks(repo, pr_number).await {
2581 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2582 Err(e) => {
2583 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2584 Vec::new()
2585 }
2586 }
2587}
2588
2589#[allow(clippy::too_many_arguments)]
2590async fn apply_review_decision(
2591 task: &ExternalTask,
2592 backend: &Arc<dyn ExternalBackend>,
2593 repo: &str,
2594 task_manager: &Arc<TaskManager>,
2595 store: &Arc<TaskStore>,
2596 ctx: &ReviewContext,
2597 parsed: &ParsedReview,
2598 router_config: &crate::engine::router::RouterConfig,
2599) -> anyhow::Result<ReviewDecision> {
2600 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2601
2602 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2603 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2604 }
2605
2606 post_review_comment(
2607 task,
2608 repo,
2609 ctx,
2610 &parsed.decision,
2611 &parsed.review_notes_for_comment,
2612 )
2613 .await?;
2614
2615 match &parsed.decision {
2616 ReviewDecision::Approve { .. } => {
2617 record_approved_sha(
2618 store,
2619 repo,
2620 &task.id.0,
2621 ctx.pr_number,
2622 &ctx.worktree_path,
2623 &ctx.branch_name,
2624 &ctx.default_branch,
2625 )
2626 .await;
2627 if let Err(e) = auto_merge_pr(
2632 task,
2633 &ctx.branch_name,
2634 backend,
2635 repo,
2636 &ctx.review_agent,
2637 ctx.review_model_str(),
2638 task_manager,
2639 store,
2640 )
2641 .await
2642 {
2643 let error_msg = e.to_string();
2644 if error_msg.contains("not yet computed") {
2645 tracing::warn!(
2646 task_id = task.id.0,
2647 pr_number = ctx.pr_number,
2648 branch = %ctx.branch_name,
2649 error = %e,
2650 "auto-merge deferred — PR mergeability not yet computed"
2651 );
2652 } else {
2653 tracing::error!(
2654 task_id = task.id.0,
2655 pr_number = ctx.pr_number,
2656 branch = %ctx.branch_name,
2657 error = %e,
2658 "auto-merge failed"
2659 );
2660 }
2661 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2662 }
2663 Ok(parsed.decision.clone())
2664 }
2665 ReviewDecision::RequestChanges { notes, issues } => {
2666 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2667 handle_review_changes(
2668 task,
2669 notes,
2670 issues,
2671 backend,
2672 repo,
2673 ctx.pr_number,
2674 &ctx.review_agent,
2675 ctx.review_model_str(),
2676 task_manager,
2677 store,
2678 router_config,
2679 )
2680 .await?;
2681 Ok(parsed.decision.clone())
2682 }
2683 _ => Ok(parsed.decision.clone()),
2684 }
2685}
2686
2687async fn recover_missing_review_worktree(
2688 task: &ExternalTask,
2689 repo: &str,
2690 store: &Arc<TaskStore>,
2691) -> anyhow::Result<worktree::WorktreeSetup> {
2692 let project_dir = crate::config::get_projects_with_paths()?
2693 .into_iter()
2694 .find(|(candidate_repo, _)| candidate_repo == repo)
2695 .map(|(_, project_dir)| project_dir)
2696 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2697
2698 tracing::warn!(
2699 task_id = task.id.0,
2700 repo,
2701 project_dir = %project_dir.display(),
2702 "review worktree missing, recreating via normal setup"
2703 );
2704
2705 let store_ref = Some(Arc::clone(store));
2706 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2707 .await
2708 .context("recreating missing review worktree")?;
2709
2710 if !wt.work_dir.exists() {
2711 anyhow::bail!(
2712 "recreated review worktree at {} for task {} is still missing",
2713 wt.work_dir.display(),
2714 task.id.0
2715 );
2716 }
2717
2718 Ok(wt)
2719}
2720
2721async fn count_ahead_commits(
2727 worktree_path: &std::path::Path,
2728 default_branch: &str,
2729) -> Result<u64, String> {
2730 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2731 format!(
2732 "worktree path contains non-UTF-8 characters: {:?}",
2733 worktree_path
2734 )
2735 })?;
2736
2737 let output = tokio::process::Command::new("git")
2738 .args([
2739 "-C",
2740 worktree_str,
2741 "rev-list",
2742 "--count",
2743 &format!("origin/{default_branch}..HEAD"),
2744 ])
2745 .output()
2746 .await
2747 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2748
2749 if !output.status.success() {
2750 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2751 return Err(format!(
2752 "git rev-list exited with {:?}: {}",
2753 output.status.code(),
2754 if stderr.is_empty() {
2755 String::from_utf8_lossy(&output.stdout).trim().to_string()
2756 } else {
2757 stderr
2758 }
2759 ));
2760 }
2761
2762 String::from_utf8_lossy(&output.stdout)
2763 .trim()
2764 .parse::<u64>()
2765 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2766}
2767
2768#[allow(clippy::too_many_arguments)]
2775async fn ensure_pr_exists(
2776 task: &ExternalTask,
2777 branch_name: &str,
2778 worktree_path: &std::path::Path,
2779 repo: &str,
2780 agent_summary: &str,
2781 store: &Arc<TaskStore>,
2782 task_manager: &Arc<TaskManager>,
2783 stored_pr_number: Option<u64>,
2784) -> anyhow::Result<EnsurePrResult> {
2785 let gh_check = GhHttp::new()?;
2786
2787 if let Some(n) = stored_pr_number {
2788 tracing::info!(
2789 task_id = task.id.0,
2790 pr_number = n,
2791 branch = %branch_name,
2792 "open PR found in store, proceeding with review"
2793 );
2794 return Ok(EnsurePrResult::Ready(n));
2795 }
2796
2797 match gh_check.get_pr_number(repo, branch_name).await {
2798 Ok(Some(n)) => {
2799 tracing::info!(
2800 task_id = task.id.0,
2801 pr_number = n,
2802 branch = %branch_name,
2803 "open PR found, proceeding with review"
2804 );
2805 Ok(EnsurePrResult::Ready(n))
2806 }
2807 Ok(None) => {
2808 let default_branch = worktree::detect_default_branch(worktree_path).await;
2810 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2811 Ok(count) => count > 0,
2812 Err(e) => {
2813 tracing::error!(
2814 task_id = task.id.0,
2815 branch = %branch_name,
2816 default_branch = %default_branch,
2817 err = %e,
2818 "could not determine if branch has commits ahead of default"
2819 );
2820 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2821 format!("git ancestry check failed: {e}"),
2822 )));
2823 }
2824 };
2825
2826 if has_commits {
2827 tracing::warn!(
2829 task_id = task.id.0,
2830 branch = %branch_name,
2831 "no open PR but branch has commits — attempting to create PR"
2832 );
2833 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2834 anyhow::anyhow!(
2835 "worktree path contains non-UTF-8 characters: {:?}",
2836 worktree_path
2837 )
2838 })?;
2839 let auth_env = runner::git_ops::build_git_auth_env().await;
2841
2842 let push_timeout = std::time::Duration::from_secs(120);
2847 match tokio::time::timeout(
2848 push_timeout,
2849 tokio::process::Command::new("git")
2850 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2851 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2852 .output(),
2853 )
2854 .await
2855 {
2856 Err(_elapsed) => {
2857 tracing::warn!(
2858 task_id = task.id.0,
2859 branch = %branch_name,
2860 timeout_secs = push_timeout.as_secs(),
2861 "review gate fallback push timed out"
2862 );
2863 }
2864 Ok(Ok(o)) => {
2865 if !o.status.success() {
2866 tracing::warn!(
2867 task_id = task.id.0,
2868 branch = %branch_name,
2869 exit_code = ?o.status.code(),
2870 stdout = %String::from_utf8_lossy(&o.stdout),
2871 stderr = %String::from_utf8_lossy(&o.stderr),
2872 "review gate fallback push failed"
2873 );
2874 } else {
2875 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2876 }
2877 }
2878 Ok(Err(e)) => {
2879 tracing::error!(
2880 task_id = task.id.0,
2881 branch = %branch_name,
2882 error = %e,
2883 "review gate fallback push command failed to run"
2884 );
2885 }
2886 }
2887
2888 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2889 let pr_body = format!(
2890 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2891 );
2892 let gh = GhHttp::new()?;
2893 match gh
2894 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2895 .await
2896 {
2897 Ok(url) => match parse_pr_number_from_url(&url) {
2898 Ok(pr_num) => {
2899 store_set_result(
2900 &Some(Arc::clone(store)),
2901 repo,
2902 &task.id.0,
2903 &[("pr_number", serde_json::json!(pr_num as i64))],
2904 )
2905 .await?;
2906 tracing::info!(
2907 task_id = task.id.0,
2908 branch = %branch_name,
2909 pr_url = %url,
2910 "created missing PR via GhHttp — continuing review"
2911 );
2912 Ok(EnsurePrResult::Ready(pr_num))
2913 }
2914 Err(e) => {
2915 tracing::warn!(
2916 task_id = task.id.0,
2917 branch = %branch_name,
2918 pr_url = %url,
2919 error = %e,
2920 "created missing PR via GhHttp, but failed to parse PR number"
2921 );
2922 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2923 format!("created missing PR, but could not parse PR number: {e}"),
2924 )))
2925 }
2926 },
2927 Err(e) => {
2928 let e_str = format!("{e}");
2929 if e_str.contains("already exists") {
2930 let backoff_delays = [2u64, 4u64];
2934 let mut found_pr: Option<u64> = None;
2935 for delay_secs in backoff_delays {
2936 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2937 .await;
2938 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2939 {
2940 found_pr = Some(n);
2941 break;
2942 }
2943 }
2944 if let Some(n) = found_pr {
2945 tracing::info!(
2946 task_id = task.id.0,
2947 pr_number = n,
2948 branch = %branch_name,
2949 "found existing PR after create_pr 422 — retrying review"
2950 );
2951 store_set_result(
2952 &Some(Arc::clone(store)),
2953 repo,
2954 &task.id.0,
2955 &[("pr_number", serde_json::json!(n as i64))],
2956 )
2957 .await?;
2958 return Ok(EnsurePrResult::Ready(n));
2959 }
2960 }
2961 tracing::warn!(
2962 task_id = task.id.0,
2963 branch = %branch_name,
2964 error = %e,
2965 "create_pr failed via GhHttp, falling back to CLI"
2966 );
2967 let pr_timeout = std::time::Duration::from_secs(120);
2970 let mut gh_cmd = tokio::process::Command::new("gh");
2971 gh_cmd.args([
2972 "pr",
2973 "create",
2974 "--repo",
2975 repo,
2976 "--head",
2977 branch_name,
2978 "--title",
2979 &task.title,
2980 "--body",
2981 &pr_body,
2982 ]);
2983 gh_cmd.current_dir(worktree_path);
2984 gh_cmd.kill_on_drop(true);
2985
2986 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2987 match pr_result {
2988 Ok(Ok(o)) if o.status.success() => {
2989 let stdout = String::from_utf8_lossy(&o.stdout);
2990 match parse_pr_number_from_url(stdout.trim()) {
2991 Ok(pr_num) => {
2992 store_set_result(
2993 &Some(Arc::clone(store)),
2994 repo,
2995 &task.id.0,
2996 &[("pr_number", serde_json::json!(pr_num as i64))],
2997 )
2998 .await?;
2999 tracing::info!(
3000 task_id = task.id.0,
3001 branch = %branch_name,
3002 "created missing PR via CLI — continuing review"
3003 );
3004 Ok(EnsurePrResult::Ready(pr_num))
3005 }
3006 Err(e) => {
3007 tracing::warn!(
3008 task_id = task.id.0,
3009 branch = %branch_name,
3010 stdout = %stdout,
3011 error = %e,
3012 "created missing PR via CLI, but failed to parse PR number"
3013 );
3014 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3015 format!("created missing PR via CLI, but could not parse PR number: {e}")
3016 )))
3017 }
3018 }
3019 }
3020 Ok(Ok(o)) => {
3021 let stderr = String::from_utf8_lossy(&o.stderr);
3022 if stderr.contains("already exists") {
3023 if let Some(pr_url) =
3024 stderr.lines().find(|l| l.trim().starts_with("https://"))
3025 {
3026 match parse_pr_number_from_url(pr_url.trim()) {
3027 Ok(pr_num) => {
3028 if let Err(e) = store_set_result(
3029 &Some(Arc::clone(store)),
3030 repo,
3031 &task.id.0,
3032 &[(
3033 "pr_number",
3034 serde_json::json!(pr_num as i64),
3035 )],
3036 )
3037 .await
3038 {
3039 tracing::warn!(
3040 task_id = task.id.0,
3041 pr_num,
3042 err = %e,
3043 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
3044 );
3045 }
3046 tracing::info!(
3047 task_id = task.id.0,
3048 branch = %branch_name,
3049 pr_url = %pr_url,
3050 "PR already exists (from CLI stderr) — retrying review"
3051 );
3052 return Ok(EnsurePrResult::Ready(pr_num));
3053 }
3054 Err(e) => {
3055 tracing::warn!(
3056 task_id = task.id.0,
3057 branch = %branch_name,
3058 pr_url = %pr_url,
3059 error = %e,
3060 "found existing PR URL in CLI stderr, but failed to parse PR number"
3061 );
3062 }
3064 }
3065 }
3066 }
3067 tracing::error!(
3068 task_id = task.id.0,
3069 branch = %branch_name,
3070 stderr = %stderr,
3071 "failed to create missing PR — work may be stuck"
3072 );
3073
3074 if crate::engine::runner::git_ops::is_transient_github_api_error(
3081 &stderr,
3082 ) {
3083 tracing::warn!(
3084 task_id = task.id.0,
3085 branch = %branch_name,
3086 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
3087 );
3088 return Ok(EnsurePrResult::EarlyReturn(
3089 ReviewDecision::Failed(format!(
3090 "transient github error creating PR: {stderr}"
3091 )),
3092 ));
3093 }
3094
3095 match store_increment(
3096 &Some(Arc::clone(store)),
3097 repo,
3098 &task.id.0,
3099 "pr_create_failures",
3100 )
3101 .await
3102 {
3103 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3104 return Ok(EnsurePrResult::EarlyReturn(
3105 ReviewDecision::Blocked(format!(
3106 "no PR, create failed {failures} times: {stderr}"
3107 )),
3108 ));
3109 }
3110 Ok(_) => {}
3111 Err(e) => {
3112 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3113 }
3114 }
3115 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3116 format!("no PR, create failed: {stderr}"),
3117 )))
3118 }
3119 Ok(Err(e)) => {
3120 tracing::error!(
3121 task_id = task.id.0,
3122 error = %e,
3123 "failed to run gh pr create"
3124 );
3125 let e_str = format!("{e}");
3126 if crate::engine::runner::git_ops::is_transient_github_api_error(
3127 &e_str,
3128 ) {
3129 tracing::warn!(
3130 task_id = task.id.0,
3131 branch = %branch_name,
3132 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
3133 );
3134 return Ok(EnsurePrResult::EarlyReturn(
3135 ReviewDecision::Failed(format!("transient gh error: {e}")),
3136 ));
3137 }
3138
3139 match store_increment(
3140 &Some(Arc::clone(store)),
3141 repo,
3142 &task.id.0,
3143 "pr_create_failures",
3144 )
3145 .await
3146 {
3147 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3148 return Ok(EnsurePrResult::EarlyReturn(
3149 ReviewDecision::Blocked(format!(
3150 "no PR, gh error {failures} times: {e}"
3151 )),
3152 ));
3153 }
3154 Ok(_) => {}
3155 Err(e) => {
3156 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3157 }
3158 }
3159 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3160 format!("no PR, gh error: {e}"),
3161 )))
3162 }
3163 Err(_elapsed) => {
3164 tracing::error!(
3166 task_id = task.id.0,
3167 timeout_secs = pr_timeout.as_secs(),
3168 "gh pr create CLI fallback timed out"
3169 );
3170 let e_str = format!(
3171 "gh pr create timed out after {}s",
3172 pr_timeout.as_secs()
3173 );
3174 if crate::engine::runner::git_ops::is_transient_github_api_error(
3175 &e_str,
3176 ) {
3177 tracing::warn!(
3178 task_id = task.id.0,
3179 branch = %branch_name,
3180 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
3181 );
3182 return Ok(EnsurePrResult::EarlyReturn(
3183 ReviewDecision::Failed(format!(
3184 "transient gh error: {e_str}"
3185 )),
3186 ));
3187 }
3188
3189 match store_increment(
3190 &Some(Arc::clone(store)),
3191 repo,
3192 &task.id.0,
3193 "pr_create_failures",
3194 )
3195 .await
3196 {
3197 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3198 return Ok(EnsurePrResult::EarlyReturn(
3199 ReviewDecision::Blocked(format!(
3200 "no PR, gh timeout {failures} times: {e_str}"
3201 )),
3202 ));
3203 }
3204 Ok(_) => {}
3205 Err(e) => {
3206 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3207 }
3208 }
3209 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3210 format!("no PR, gh timeout: {e_str}"),
3211 )))
3212 }
3213 }
3214 }
3215 }
3216 } else {
3217 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
3219 Ok(v) => v,
3220 Err(e) => {
3221 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
3222 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3223 format!("merge check failed: {e}"),
3224 )));
3225 }
3226 };
3227 if merged {
3228 tracing::info!(
3229 task_id = task.id.0,
3230 branch = %branch_name,
3231 "PR already merged, marking done"
3232 );
3233 if let Err(e) = task_manager
3234 .update_task_status(&task.id, Status::Done)
3235 .await
3236 {
3237 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3238 }
3239 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3240 }
3241
3242 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
3243 .await
3244 .map(|t| t.last_error)
3245 .unwrap_or_default();
3246 let reason = if !agent_summary.is_empty() {
3247 agent_summary.to_string()
3248 } else {
3249 last_error.clone()
3250 };
3251
3252 if last_error.contains("exceeded max attempts") {
3253 tracing::warn!(
3254 task_id = task.id.0,
3255 branch = %branch_name,
3256 "no PR and no commits after max attempts — marking blocked to stop loop"
3257 );
3258 let fields = [
3262 (
3263 "block_reason",
3264 serde_json::json!("max attempts exceeded — no PR or commits produced"),
3265 ),
3266 ("last_error", serde_json::json!(last_error)),
3267 ];
3268 if let Err(e) = task_manager
3269 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3270 .await
3271 {
3272 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3273 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3274 format!("failed to write block_reason: {e}"),
3275 )));
3276 }
3277 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3278 }
3279
3280 if last_error.contains("422") && last_error.contains("head") {
3281 tracing::info!(
3282 task_id = task.id.0,
3283 branch = %branch_name,
3284 "no PR — 422/head-invalid means work already merged, marking done"
3285 );
3286 if let Err(e) = task_manager
3287 .update_task_status(&task.id, Status::Done)
3288 .await
3289 {
3290 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
3291 }
3292 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3293 }
3294
3295 if last_error.contains("no fallback agents")
3296 || last_error.contains("all agents exhausted")
3297 {
3298 tracing::warn!(
3299 task_id = task.id.0,
3300 branch = %branch_name,
3301 last_error = %last_error,
3302 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
3303 );
3304 let fields = [
3308 (
3309 "block_reason",
3310 serde_json::json!("all agents exhausted — no PR or commits produced"),
3311 ),
3312 ("last_error", serde_json::json!(last_error)),
3313 ];
3314 if let Err(e) = task_manager
3315 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3316 .await
3317 {
3318 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3319 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3320 format!("failed to write block_reason: {e}"),
3321 )));
3322 }
3323 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3324 }
3325
3326 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
3333 .ok()
3334 .and_then(|s| s.parse().ok())
3335 .or_else(|| {
3336 config::get("workflow.max_attempts")
3337 .ok()
3338 .and_then(|s| s.parse().ok())
3339 })
3340 .unwrap_or(3);
3341
3342 tracing::warn!(
3343 task_id = task.id.0,
3344 branch = %branch_name,
3345 reason = %reason,
3346 "no PR and no commits — re-routing for retry"
3347 );
3348
3349 if should_skip_no_code_reroute_increment(&last_error) {
3353 tracing::warn!(
3354 task_id = task.id.0,
3355 last_error = %last_error,
3356 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
3357 );
3358 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3359 format!("transient github error: {last_error}"),
3360 )));
3361 }
3362
3363 let reroutes = match store_increment(
3365 &Some(Arc::clone(store)),
3366 repo,
3367 &task.id.0,
3368 "no_code_reroutes",
3369 )
3370 .await
3371 {
3372 Ok(v) => v,
3373 Err(e) => {
3374 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
3375 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3377 format!("transient store error: {e}"),
3378 )));
3379 }
3380 };
3381
3382 if reroutes as u32 >= max_reroutes {
3383 tracing::error!(
3384 task_id = task.id.0,
3385 reroutes,
3386 max_reroutes,
3387 "reached max reroute attempts for no-pr-result — blocking for human review"
3388 );
3389 let msg = format!(
3390 "no PR or code changes after {}/{} reroute attempts",
3391 reroutes, max_reroutes
3392 );
3393 let fields = [
3397 (
3398 "block_reason",
3399 serde_json::json!(format!(
3400 "max reroute attempts ({}) reached — no PR or code changes produced",
3401 max_reroutes
3402 )),
3403 ),
3404 ("last_error", serde_json::json!(msg)),
3405 ("agent", serde_json::json!(null)),
3406 ("model", serde_json::json!(null)),
3407 ];
3408 if let Err(e) = task_manager
3409 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3410 .await
3411 {
3412 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3413 }
3414 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
3415 } else if task.id.0.starts_with("internal:") {
3416 tracing::info!(
3423 task_id = task.id.0,
3424 "no PR and no commits for internal task — marking done"
3425 );
3426 if let Err(e) = task_manager
3427 .update_task_status(&task.id, Status::Done)
3428 .await
3429 {
3430 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3431 }
3432 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3433 } else {
3434 if let Err(e) = store_set_result(
3439 &Some(Arc::clone(store)),
3440 repo,
3441 &task.id.0,
3442 &[
3443 ("agent", serde_json::json!(null)),
3444 ("model", serde_json::json!(null)),
3445 (
3446 "last_error",
3447 serde_json::json!("no PR or code changes produced"),
3448 ),
3449 ],
3450 )
3451 .await
3452 {
3453 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3454 } else if let Err(e) =
3456 task_manager.update_task_status(&task.id, Status::New).await
3457 {
3458 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3459 }
3460 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3461 }
3462 }
3463 }
3464 Err(e) => {
3465 tracing::warn!(
3466 task_id = task.id.0,
3467 branch = %branch_name,
3468 error = %e,
3469 "failed to check PR status"
3470 );
3471 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3472 format!("PR check failed: {e}"),
3473 )))
3474 }
3475 }
3476}
3477
3478pub(crate) fn review_timeout_seconds() -> u64 {
3480 review_timeout_secs(
3481 crate::config::get("workflow.review_timeout_seconds")
3482 .ok()
3483 .and_then(|s| s.parse().ok()),
3484 )
3485}
3486
3487pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3491
3492pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3496 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3497}
3498
3499pub(crate) async fn review_and_merge(
3505 task: &ExternalTask,
3506 backend: &Arc<dyn ExternalBackend>,
3507 tmux: &Arc<TmuxManager>,
3508 repo: &str,
3509 router: &Arc<RwLock<Router>>,
3510 task_manager: &Arc<TaskManager>,
3511 store: &Arc<TaskStore>,
3512) -> anyhow::Result<ReviewDecision> {
3513 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3514 ReviewPhase::Ready(ctx) => ctx,
3515 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3516 };
3517
3518 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3519 ReviewPhase::Ready(run) => run,
3520 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3521 };
3522
3523 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3524 ReviewPhase::Ready(parsed) => parsed,
3525 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3526 };
3527
3528 finalize_review_run(task, &ctx, &parsed, store).await;
3529
3530 let router_config = router.read().await.config.clone();
3531 apply_review_decision(
3532 task,
3533 backend,
3534 repo,
3535 task_manager,
3536 store,
3537 &ctx,
3538 &parsed,
3539 &router_config,
3540 )
3541 .await
3542}
3543
3544#[cfg(test)]
3545mod tests {
3546 use super::*;
3547
3548 #[test]
3549 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3550 assert_eq!(review_timeout_secs(None), 900);
3551 assert_eq!(review_timeout_secs(Some(600)), 600);
3552 assert_eq!(review_timeout_secs(Some(60)), 300);
3553 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3554 }
3555
3556 use crate::backends::ExternalTask;
3557 use crate::engine::router::RouterConfig;
3558 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3559 use crate::store::{TaskStatus, TaskStore};
3560 use tempfile::TempDir;
3561
3562 #[test]
3567 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3568 assert_eq!(
3569 no_session_review_floor(600, 900),
3570 900 + REVIEW_RESULT_DELIVERY_SECS
3571 );
3572 }
3573
3574 #[test]
3575 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3576 assert_eq!(no_session_review_floor(1800, 900), 1800);
3577 }
3578
3579 #[test]
3580 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3581 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3582 let floored = no_session_review_floor(configured, review_timeout);
3583 assert!(
3584 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3585 "{floored} must leave a finished review time to deliver its result"
3586 );
3587 }
3588 }
3589
3590 #[test]
3591 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3592 let err = runner::agents::AgentError::RateLimit {
3596 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3597 for this billing cycle. Your quota will be refreshed soon."
3598 .to_string(),
3599 };
3600 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3601 }
3602
3603 #[test]
3604 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3605 let err = runner::agents::AgentError::RateLimit {
3606 message: "429 Too Many Requests".to_string(),
3607 };
3608 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3609 }
3610
3611 #[test]
3612 fn outcome_for_agent_error_generic_auth_unaffected() {
3613 let err = runner::agents::AgentError::Auth {
3614 message: "401 Unauthorized".to_string(),
3615 };
3616 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3617 }
3618
3619 #[test]
3622 fn parse_pr_number_standard_url() {
3623 assert_eq!(
3624 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3625 Ok(42)
3626 );
3627 }
3628
3629 #[test]
3630 fn parse_pr_number_trailing_slash() {
3631 assert_eq!(
3632 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3633 Ok(42)
3634 );
3635 }
3636
3637 #[test]
3638 fn parse_pr_number_with_query_string() {
3639 assert_eq!(
3640 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3641 Ok(42)
3642 );
3643 }
3644
3645 #[test]
3646 fn parse_pr_number_with_hash_fragment() {
3647 assert_eq!(
3648 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3649 Ok(42)
3650 );
3651 }
3652
3653 #[test]
3654 fn parse_pr_number_with_query_and_hash() {
3655 assert_eq!(
3656 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3657 Ok(42)
3658 );
3659 }
3660
3661 #[test]
3662 fn parse_pr_number_with_leading_whitespace() {
3663 assert_eq!(
3664 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3665 Ok(42)
3666 );
3667 }
3668
3669 #[test]
3670 fn parse_pr_number_wrong_domain() {
3671 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3672 let result = parse_pr_number_from_url(url);
3673 assert!(result.is_err());
3674 assert!(result
3675 .unwrap_err()
3676 .contains("does not start with 'https://github.com/'"));
3677 }
3678
3679 #[test]
3680 fn parse_pr_number_missing_pull_segment() {
3681 let url = "https://github.com/owner/repo/issues/42";
3683 let result = parse_pr_number_from_url(url);
3684 assert!(result.is_err());
3685 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3686 }
3687
3688 #[test]
3689 fn parse_pr_number_zero_is_rejected() {
3690 let url = "https://github.com/owner/repo/pull/0";
3691 let result = parse_pr_number_from_url(url);
3692 assert!(result.is_err());
3693 assert!(result.unwrap_err().contains("cannot be zero"));
3694 }
3695
3696 #[test]
3697 fn parse_pr_number_non_numeric_segment() {
3698 let url = "https://github.com/owner/repo/pull/abc";
3699 let result = parse_pr_number_from_url(url);
3700 assert!(result.is_err());
3701 assert!(result.unwrap_err().contains("not a valid positive integer"));
3702 }
3703
3704 #[test]
3705 fn parse_pr_number_empty_string() {
3706 let result = parse_pr_number_from_url("");
3707 assert!(result.is_err());
3708 assert!(result.unwrap_err().contains("empty"));
3709 }
3710
3711 #[test]
3712 fn parse_pr_number_http_not_https() {
3713 let url = "http://github.com/owner/repo/pull/42";
3715 let result = parse_pr_number_from_url(url);
3716 assert!(result.is_err());
3717 assert!(result
3718 .unwrap_err()
3719 .contains("does not start with 'https://github.com/'"));
3720 }
3721
3722 #[test]
3730 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3731 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3735 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3736 let err = runner::agents::AgentError::RateLimit {
3737 message: raw_fragment.to_string(),
3738 };
3739
3740 let stored = format_review_error("minimax", &err);
3741
3742 assert!(
3745 !stored.contains("{\"type\""),
3746 "stored error must not contain raw NDJSON envelope, got: {stored}"
3747 );
3748 assert!(
3749 !stored.contains("subtype"),
3750 "stored error must not contain raw api_retry fragment, got: {stored}"
3751 );
3752 assert!(
3753 !stored.contains("session_id"),
3754 "stored error must not contain session_id from provider payload, got: {stored}"
3755 );
3756 assert_eq!(
3757 stored,
3758 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3759 );
3760 }
3761
3762 #[test]
3763 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3764 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3769 let err = runner::agents::AgentError::RateLimit {
3770 message: raw_fragment.to_string(),
3771 };
3772
3773 let stored = format_review_error("glm", &err);
3774
3775 assert!(stored.starts_with("glm rate limit:"));
3776 assert!(
3777 !stored.contains("session_id"),
3778 "stored error must not leak session_id from truncated fragment, got: {stored}"
3779 );
3780 assert!(
3783 stored.len() <= 200,
3784 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3785 stored.len()
3786 );
3787 }
3788
3789 #[test]
3790 fn format_review_error_preserves_non_rate_limit_variants() {
3791 let err = runner::agents::AgentError::Auth {
3792 message: "invalid bearer token".to_string(),
3793 };
3794 assert_eq!(
3795 format_review_error("kimi", &err),
3796 "kimi auth error: invalid bearer token"
3797 );
3798
3799 let err = runner::agents::AgentError::Timeout {
3800 elapsed: std::time::Duration::from_secs(1801),
3801 };
3802 assert_eq!(
3803 format_review_error("kimi", &err),
3804 "kimi timed out after 1801s"
3805 );
3806 }
3807
3808 fn git(dir: &std::path::Path, args: &[&str]) {
3809 let mut cmd = std::process::Command::new("git");
3810 cmd.current_dir(dir);
3811 if args.first() == Some(&"commit") {
3813 cmd.args(["-c", "commit.gpgsign=false"]);
3814 }
3815 let status = cmd.args(args).status().unwrap();
3816 assert!(status.success(), "git {:?} failed", args);
3817 }
3818
3819 #[test]
3820 fn test_pull_request_review_requests_changes() {
3821 let review = PullRequestReview {
3822 review: GitHubReview {
3823 id: 1,
3824 user: GitHubUser {
3825 login: "reviewer".to_string(),
3826 },
3827 body: Some("Please fix".to_string()),
3828 state: "CHANGES_REQUESTED".to_string(),
3829 html_url: None,
3830 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3831 commit_id: None,
3832 },
3833 comments: vec![],
3834 };
3835 assert!(review.requests_changes());
3836 }
3837
3838 #[test]
3839 fn test_pull_request_review_does_not_request_changes() {
3840 let review = PullRequestReview {
3841 review: GitHubReview {
3842 id: 1,
3843 user: GitHubUser {
3844 login: "reviewer".to_string(),
3845 },
3846 body: Some("LGTM".to_string()),
3847 state: "APPROVED".to_string(),
3848 html_url: None,
3849 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3850 commit_id: None,
3851 },
3852 comments: vec![],
3853 };
3854 assert!(!review.requests_changes());
3855 }
3856
3857 #[test]
3858 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3859 let review = PullRequestReview {
3860 review: GitHubReview {
3861 id: 1,
3862 user: GitHubUser {
3863 login: "reviewer".to_string(),
3864 },
3865 body: None,
3866 state: "CHANGES_REQUESTED".to_string(),
3867 html_url: None,
3868 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3869 commit_id: None,
3870 },
3871 comments: vec![
3872 GitHubReviewComment {
3873 id: 1,
3874 user: GitHubUser {
3875 login: "reviewer".to_string(),
3876 },
3877 body: "Fix this issue".to_string(),
3878 path: "src/main.rs".to_string(),
3879 line: Some(10),
3880 original_line: Some(10),
3881 commit_id: "abc".to_string(),
3882 original_commit_id: "abc".to_string(),
3883 html_url: "url".to_string(),
3884 created_at: "2024-01-01T00:00:00Z".to_string(),
3885 updated_at: "2024-01-01T00:00:00Z".to_string(),
3886 in_reply_to_id: None,
3887 diff_hunk: Some(
3888 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3889 ),
3890 },
3891 GitHubReviewComment {
3892 id: 2,
3893 user: GitHubUser {
3894 login: "reviewer".to_string(),
3895 },
3896 body: "".to_string(),
3897 path: "src/lib.rs".to_string(),
3898 line: Some(20),
3899 original_line: Some(20),
3900 commit_id: "abc".to_string(),
3901 original_commit_id: "abc".to_string(),
3902 html_url: "url".to_string(),
3903 created_at: "2024-01-01T00:00:00Z".to_string(),
3904 updated_at: "2024-01-01T00:00:00Z".to_string(),
3905 in_reply_to_id: None,
3906 diff_hunk: None,
3907 },
3908 GitHubReviewComment {
3909 id: 3,
3910 user: GitHubUser {
3911 login: "reviewer".to_string(),
3912 },
3913 body: "Reply to this".to_string(),
3914 path: "src/lib.rs".to_string(),
3915 line: Some(30),
3916 original_line: Some(30),
3917 commit_id: "abc".to_string(),
3918 original_commit_id: "abc".to_string(),
3919 html_url: "url".to_string(),
3920 created_at: "2024-01-01T00:00:00Z".to_string(),
3921 updated_at: "2024-01-01T00:00:00Z".to_string(),
3922 in_reply_to_id: Some(1),
3923 diff_hunk: None,
3924 },
3925 ],
3926 };
3927 let actionable = review.actionable_comments();
3928 assert_eq!(actionable.len(), 1);
3929 assert_eq!(actionable[0].id, 1);
3930 assert_eq!(actionable[0].body, "Fix this issue");
3931 assert_eq!(actionable[0].path, "src/main.rs");
3932 assert_eq!(
3933 actionable[0].diff_hunk.as_ref().unwrap(),
3934 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3935 );
3936 }
3937
3938 #[test]
3939 fn test_router_config_model_for_complexity_returns_none_without_config() {
3940 let cfg = RouterConfig::default();
3943 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3944 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3945 assert!(cfg
3946 .model_for_complexity("unknown_agent_xyz", "simple", "")
3947 .is_none());
3948 }
3949
3950 #[test]
3951 fn review_status_maps_decisions() {
3952 assert_eq!(
3953 review_status(&ReviewDecision::Approve { issues: vec![] })
3954 .unwrap()
3955 .0,
3956 "success"
3957 );
3958 let changes = ReviewDecision::RequestChanges {
3959 notes: String::new(),
3960 issues: vec![],
3961 };
3962 assert_eq!(review_status(&changes).unwrap().0, "failure");
3963 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3964 }
3965
3966 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
3967 runner::response::ReviewIssue {
3968 file: "src/engine/tick.rs".into(),
3969 line: Some(142),
3970 severity: severity.into(),
3971 description: "index out of bounds on the last element".into(),
3972 }
3973 }
3974
3975 fn sample_review_response(
3976 decision: &str,
3977 test_results: Option<&str>,
3978 issues: Vec<runner::response::ReviewIssue>,
3979 ) -> runner::response::ReviewResponse {
3980 runner::response::ReviewResponse {
3981 decision: decision.into(),
3982 notes: "looks fine".into(),
3983 test_results: test_results.map(str::to_string),
3984 issues,
3985 }
3986 }
3987
3988 #[test]
3989 fn approve_with_failing_tests_becomes_request_changes() {
3990 let decision =
3991 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
3992 match decision {
3993 ReviewDecision::RequestChanges { notes, issues } => {
3994 assert!(
3995 notes.contains("test_results: fail"),
3996 "notes must name the failing test run, got {notes:?}"
3997 );
3998 assert!(issues.is_empty());
3999 }
4000 other => panic!("expected RequestChanges, got {other:?}"),
4001 }
4002 }
4003
4004 #[test]
4005 fn approve_with_error_issue_becomes_request_changes() {
4006 let issue = sample_review_issue("error");
4007 let decision = review_decision_from_response(sample_review_response(
4008 "approve",
4009 Some("pass"),
4010 vec![issue.clone()],
4011 ));
4012 match decision {
4013 ReviewDecision::RequestChanges { issues, .. } => {
4014 assert_eq!(issues, vec![issue]);
4015 }
4016 other => panic!("expected RequestChanges, got {other:?}"),
4017 }
4018 }
4019
4020 #[test]
4021 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
4022 let issue = sample_review_issue("warning");
4023 let decision = review_decision_from_response(sample_review_response(
4024 "approve",
4025 Some("pass"),
4026 vec![issue.clone()],
4027 ));
4028 match &decision {
4029 ReviewDecision::Approve { issues } => {
4030 assert_eq!(issues, &vec![issue.clone()]);
4031 }
4032 other => panic!("expected Approve, got {other:?}"),
4033 }
4034 let comment = build_pr_review_comment(&decision, "looks fine");
4035 assert!(
4036 comment.contains("Approve"),
4037 "approve header missing from {comment}"
4038 );
4039 assert!(
4040 comment.contains(&issue.description),
4041 "warning issue missing from {comment}"
4042 );
4043 assert!(
4044 comment.contains("warning"),
4045 "warning severity missing from {comment}"
4046 );
4047 }
4048
4049 #[test]
4050 fn review_started_comment_includes_agent_and_model() {
4051 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
4052 let expected = format!("🔍 Automated review started{}", expected_footer);
4053 assert_eq!(review_started_comment("kimi", "opus"), expected);
4054 }
4055
4056 fn no_code_review_task(id: &str) -> ExternalTask {
4059 ExternalTask {
4060 id: crate::backends::ExternalId(id.to_string()),
4061 title: "opencode discovery timeout".to_string(),
4062 body: "".to_string(),
4063 state: "open".to_string(),
4064 labels: vec![],
4065 author: "tester".to_string(),
4066 created_at: "2026-01-01T00:00:00Z".to_string(),
4067 updated_at: "2026-01-01T00:00:00Z".to_string(),
4068 url: "https://example.com/issues/1".to_string(),
4069 }
4070 }
4071
4072 #[tokio::test]
4076 async fn carried_approval_counts_as_this_rounds_decision() {
4077 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4078 let id = store
4079 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
4080 .await
4081 .unwrap();
4082 let task_id = format!("internal:{id}");
4083 store
4084 .update_status(id, crate::store::TaskStatus::InReview)
4085 .await
4086 .unwrap();
4087 assert_eq!(
4088 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4089 Some(false)
4090 );
4091
4092 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
4093
4094 assert_eq!(
4095 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4096 Some(true),
4097 "the stale in_review check must see this round as decided"
4098 );
4099 }
4100
4101 #[test]
4102 fn approval_covers_head_only_for_the_exact_commit() {
4103 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
4104 assert!(
4105 !approval_covers_head(Some("abc123"), Some("def456")),
4106 "a moved PR head must be reviewed"
4107 );
4108 assert!(
4109 !approval_covers_head(None, Some("abc123")),
4110 "no recorded approval means review"
4111 );
4112 assert!(
4113 !approval_covers_head(Some("abc123"), None),
4114 "an unreadable PR head must not skip the review"
4115 );
4116 assert!(!approval_covers_head(Some(""), Some("")));
4117 }
4118
4119 fn pr_for(
4120 head_repo: Option<&str>,
4121 head: &str,
4122 base: &str,
4123 ) -> crate::github::types::GitHubPullRequest {
4124 serde_json::from_value(serde_json::json!({
4125 "number": 7,
4126 "title": "t",
4127 "state": "open",
4128 "head": {"ref": head, "sha": "h", "repo": head_repo.map(|r| serde_json::json!({"full_name": r}))},
4129 "base": {"ref": base, "sha": "b"},
4130 "html_url": "https://github.com/owner/repo/pull/7",
4131 }))
4132 .unwrap()
4133 }
4134
4135 #[test]
4136 fn a_carried_approval_must_match_the_approved_repo_branch_and_base() {
4137 let approval = ApprovalEvent {
4138 sha: "a".into(),
4139 repo: "owner/repo".into(),
4140 branch: "gh-issue-1".into(),
4141 base: "main".into(),
4142 };
4143 let ok = pr_for(Some("owner/repo"), "gh-issue-1", "main");
4144 assert!(pr_matches_approval(&ok, &approval, "gh-issue-1"));
4145 assert!(!pr_matches_approval(
4146 &pr_for(Some("fork/repo"), "gh-issue-1", "main"),
4147 &approval,
4148 "gh-issue-1"
4149 ));
4150 assert!(!pr_matches_approval(
4151 &pr_for(None, "gh-issue-1", "main"),
4152 &approval,
4153 "gh-issue-1"
4154 ));
4155 assert!(!pr_matches_approval(
4156 &pr_for(Some("owner/repo"), "other", "main"),
4157 &approval,
4158 "gh-issue-1"
4159 ));
4160 assert!(!pr_matches_approval(
4161 &pr_for(Some("owner/repo"), "gh-issue-1", "release"),
4162 &approval,
4163 "gh-issue-1"
4164 ));
4165 assert!(!pr_matches_approval(&ok, &approval, "another-task-branch"));
4166
4167 let legacy = ApprovalEvent::parse("abc123").unwrap();
4168 assert_eq!(legacy.sha, "abc123");
4169 assert!(!pr_matches_approval(&ok, &legacy, "gh-issue-1"));
4170 let json = serde_json::to_string(&approval).unwrap();
4171 assert_eq!(ApprovalEvent::parse(&json), Some(approval));
4172 assert_eq!(ApprovalEvent::parse(" "), None);
4173 }
4174
4175 #[tokio::test]
4176 async fn only_the_same_commits_rebased_onto_the_base_carry_the_approval() {
4177 let dir = tempfile::tempdir().unwrap();
4178 let path = dir.path();
4179 let git = |args: &[&str]| {
4180 let out = std::process::Command::new("git")
4181 .args([
4182 "-c",
4183 "user.name=agent",
4184 "-c",
4185 "user.email=agent@t",
4186 "-c",
4187 "commit.gpgsign=false",
4188 ])
4189 .args(args)
4190 .current_dir(path)
4191 .output()
4192 .unwrap();
4193 assert!(
4194 out.status.success(),
4195 "git {args:?}: {}",
4196 String::from_utf8_lossy(&out.stderr)
4197 );
4198 String::from_utf8_lossy(&out.stdout).trim().to_string()
4199 };
4200 let write_commit = |file: &str, body: &str| {
4201 std::fs::write(path.join(file), body).unwrap();
4202 git(&["add", file]);
4203 git(&["commit", "-q", "-m", body]);
4204 };
4205 git(&["init", "-q", "-b", "main"]);
4206 write_commit("a", "base");
4207 git(&["checkout", "-q", "-b", "pr"]);
4208 write_commit("b", "first");
4209 write_commit("c", "second");
4210 let approved = git(&["rev-parse", "HEAD"]);
4211 git(&["checkout", "-q", "main"]);
4212 write_commit("d", "base moved");
4213 git(&["checkout", "-q", "-b", "rebased", &approved]);
4214 git(&["rebase", "-q", "main"]);
4215 let rebased = git(&["rev-parse", "HEAD"]);
4216 assert!(same_commits_rebased(path, &approved, "main", &rebased).await);
4217
4218 write_commit("c", "second, changed");
4220 let changed = git(&["rev-parse", "HEAD"]);
4221 assert!(!same_commits_rebased(path, &approved, "main", &changed).await);
4222
4223 git(&["checkout", "-q", "-b", "other-author", &rebased]);
4225 git(&[
4226 "-c",
4227 "user.name=someone",
4228 "-c",
4229 "user.email=someone@t",
4230 "commit",
4231 "-q",
4232 "--amend",
4233 "--reset-author",
4234 "--no-edit",
4235 ]);
4236 let reauthored = git(&["rev-parse", "HEAD"]);
4237 assert!(!same_commits_rebased(path, &approved, "main", &reauthored).await);
4238 }
4239
4240 #[tokio::test]
4241 async fn only_a_clean_merge_of_the_base_counts_as_base_only() {
4242 let dir = tempfile::tempdir().unwrap();
4243 let path = dir.path();
4244 let git = |args: &[&str]| {
4245 let out = std::process::Command::new("git")
4246 .args([
4247 "-c",
4248 "user.name=t",
4249 "-c",
4250 "user.email=t@t",
4251 "-c",
4252 "commit.gpgsign=false",
4253 ])
4254 .args(args)
4255 .current_dir(path)
4256 .output()
4257 .unwrap();
4258 assert!(
4259 out.status.success(),
4260 "git {args:?}: {}",
4261 String::from_utf8_lossy(&out.stderr)
4262 );
4263 String::from_utf8_lossy(&out.stdout).trim().to_string()
4264 };
4265 let commit = |file: &str, msg: &str| {
4266 std::fs::write(path.join(file), msg).unwrap();
4267 git(&["add", file]);
4268 git(&["commit", "-q", "-m", msg]);
4269 git(&["rev-parse", "HEAD"])
4270 };
4271 git(&["init", "-q", "-b", "main"]);
4272 commit("a", "base");
4273 git(&["checkout", "-q", "-b", "pr"]);
4274 let approved = commit("b", "approved change");
4275 git(&["checkout", "-q", "main"]);
4276 let base_tip = commit("c", "base moved");
4277 git(&["checkout", "-q", "pr"]);
4278 git(&["merge", "-q", "--no-edit", "main"]);
4279 let clean = git(&["rev-parse", "HEAD"]);
4280 assert!(is_clean_merge(path, &approved, &base_tip, &clean).await);
4281
4282 std::fs::write(path.join("d"), "unreviewed").unwrap();
4283 git(&["add", "d"]);
4284 git(&["commit", "-q", "--amend", "--no-edit"]);
4285 let forged = git(&["rev-parse", "HEAD"]);
4286 assert!(!is_clean_merge(path, &approved, &base_tip, &forged).await);
4287 }
4288
4289 #[test]
4290 fn only_a_merge_of_the_approved_commit_carries_the_approval() {
4291 let parents = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
4292 assert_eq!(
4293 base_merge_parent("approved", &parents(&["approved", "base"])),
4294 Some("base")
4295 );
4296 assert_eq!(base_merge_parent("approved", &parents(&["approved"])), None);
4298 assert_eq!(
4299 base_merge_parent("approved", &parents(&["other", "base"])),
4300 None
4301 );
4302 assert_eq!(
4303 base_merge_parent("approved", &parents(&["approved", "a", "b"])),
4304 None
4305 );
4306 }
4307
4308 #[tokio::test]
4313 async fn approved_sha_round_trips_through_the_kv_store() {
4314 let temp = TempDir::new().unwrap();
4315 let wt = temp.path();
4316 git(wt, &["init", "-b", "main"]);
4317 git(wt, &["config", "user.name", "Test"]);
4318 git(wt, &["config", "user.email", "test@test.com"]);
4319 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
4320 git(wt, &["add", "."]);
4321 git(wt, &["commit", "-m", "apply the fix"]);
4322
4323 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4324 let repo = "owner/repo";
4325 let task_id = "3783";
4326
4327 record_approved_sha(&store, repo, task_id, 77, wt, "gh-issue-3783", "main").await;
4328 let recorded = read_approval(&store, repo, task_id, 77)
4329 .await
4330 .unwrap()
4331 .expect("approval must be recorded");
4332 assert_eq!(recorded.repo, repo);
4333 assert_eq!(recorded.branch, "gh-issue-3783");
4334 assert_eq!(recorded.base, "main");
4335 assert_eq!(
4336 recorded.sha,
4337 worktree_head_sha(wt).await.unwrap(),
4338 "the recorded sha must be the commit that was pushed to the PR"
4339 );
4340 assert!(
4341 store
4342 .kv_get(&approved_sha_key(repo, task_id, 78))
4343 .await
4344 .unwrap()
4345 .is_none(),
4346 "an approval must not leak to another PR"
4347 );
4348
4349 clear_approved_sha(&store, repo, task_id, 77).await;
4350 assert!(
4351 store
4352 .kv_get(&approved_sha_key(repo, task_id, 77))
4353 .await
4354 .unwrap()
4355 .is_none(),
4356 "requesting changes must drop the approval"
4357 );
4358 }
4359
4360 #[tokio::test]
4361 async fn build_review_context_reroutes_external_task_with_no_code() {
4362 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4363 let repo = "owner/repo";
4364 let ext = no_code_review_task("3617");
4365 store.ensure_external_task(repo, &ext).await.unwrap();
4366
4367 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4368 let task_manager = Arc::new(TaskManager::with_store(
4369 backend,
4370 Arc::clone(&store),
4371 repo.to_string(),
4372 ));
4373 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4374
4375 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4376 .await
4377 .unwrap();
4378
4379 assert!(
4380 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
4381 "external task with no worktree/branch/pr must reroute, not skip-to-done"
4382 );
4383
4384 let stored = store
4385 .get_by_external_id(repo, &ext.id.0)
4386 .await
4387 .unwrap()
4388 .expect("task must still exist");
4389 assert_eq!(stored.status, TaskStatus::New);
4390 assert!(stored.agent.is_none());
4391 assert!(stored.model.is_none());
4392 }
4393
4394 #[tokio::test]
4395 async fn build_review_context_blocks_external_task_after_max_reroutes() {
4396 let temp_home = TempDir::new().unwrap();
4399 let orch_dir = temp_home.path().join(".orch");
4400 std::fs::create_dir_all(&orch_dir).unwrap();
4401 let old_orch_home = std::env::var_os("ORCH_HOME");
4402 std::env::set_var("ORCH_HOME", &orch_dir);
4403
4404 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4405 let repo = "owner/repo";
4406 let ext = no_code_review_task("3618");
4407 store.ensure_external_task(repo, &ext).await.unwrap();
4408
4409 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4410 let task_manager = Arc::new(TaskManager::with_store(
4411 backend,
4412 Arc::clone(&store),
4413 repo.to_string(),
4414 ));
4415 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4416
4417 for _ in 0..3 {
4419 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4420 .await
4421 .unwrap();
4422 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
4423 break;
4424 }
4425 }
4426
4427 let stored = store
4428 .get_by_external_id(repo, &ext.id.0)
4429 .await
4430 .unwrap()
4431 .expect("task must still exist");
4432 assert_eq!(stored.status, TaskStatus::Blocked);
4433
4434 if let Some(old) = old_orch_home {
4435 std::env::set_var("ORCH_HOME", old);
4436 } else {
4437 std::env::remove_var("ORCH_HOME");
4438 }
4439 }
4440
4441 #[tokio::test]
4442 async fn build_review_context_marks_internal_task_done_with_no_code() {
4443 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4444 let repo = "owner/repo";
4445 let internal_id = store
4446 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
4447 .await
4448 .unwrap();
4449 let ext_id = format!("internal:{internal_id}");
4450 let ext = no_code_review_task(&ext_id);
4451
4452 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4453 let task_manager = Arc::new(TaskManager::with_store(
4454 backend,
4455 Arc::clone(&store),
4456 repo.to_string(),
4457 ));
4458 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4459
4460 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4461 .await
4462 .unwrap();
4463
4464 assert!(matches!(
4465 phase,
4466 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
4467 ));
4468 let stored = store.get(internal_id).await.unwrap();
4469 assert_eq!(stored.status, TaskStatus::Done);
4470 }
4471
4472 #[test]
4473 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
4474 let last_error =
4475 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
4476 assert!(!should_skip_no_code_reroute_increment(last_error));
4477 }
4478
4479 #[test]
4488 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
4489 let padding = "x".repeat(10_000);
4490 let text_for_review = format!(
4491 "{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}"
4492 );
4493
4494 let scan_head = runner::agents::patterns::safe_head(
4495 &text_for_review,
4496 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
4497 );
4498 let scan_tail = runner::agents::patterns::safe_tail(
4499 &text_for_review,
4500 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4501 );
4502
4503 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
4504 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
4505 }
4506
4507 #[test]
4508 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
4509 let padding = "x".repeat(10_000);
4510 let text_for_review =
4511 format!("{padding}\nError: rate limit exceeded, please try again later");
4512
4513 let scan_tail = runner::agents::patterns::safe_tail(
4514 &text_for_review,
4515 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4516 );
4517
4518 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
4519 }
4520
4521 #[tokio::test]
4522 async fn recover_missing_review_worktree_recreates_worktree() {
4523 let temp_home = TempDir::new().unwrap();
4524 let orch_dir = temp_home.path().join(".orch");
4525 std::fs::create_dir_all(&orch_dir).unwrap();
4526 let old_orch_home = std::env::var_os("ORCH_HOME");
4527 std::env::set_var("ORCH_HOME", &orch_dir);
4528
4529 let project_dir = temp_home.path().join("project");
4530 std::fs::create_dir_all(&project_dir).unwrap();
4531 git(&project_dir, &["init", "-b", "main"]);
4532 git(&project_dir, &["config", "user.name", "Test User"]);
4533 git(&project_dir, &["config", "user.email", "test@example.com"]);
4534 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
4535 git(&project_dir, &["add", "README.md"]);
4536 git(&project_dir, &["commit", "-m", "init"]);
4537
4538 std::fs::write(
4539 orch_dir.join("config.yml"),
4540 format!("projects:\n - {}\n", project_dir.display()),
4541 )
4542 .unwrap();
4543 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
4544
4545 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4546 let repo = "owner/repo";
4547 store
4548 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
4549 .await
4550 .unwrap();
4551 let ext = ExternalTask {
4552 id: crate::backends::ExternalId("1".to_string()),
4553 title: "Fix review recovery".to_string(),
4554 body: "".to_string(),
4555 state: "open".to_string(),
4556 labels: vec![],
4557 author: "tester".to_string(),
4558 created_at: "2026-01-01T00:00:00Z".to_string(),
4559 updated_at: "2026-01-01T00:00:00Z".to_string(),
4560 url: "https://example.com/issues/1".to_string(),
4561 };
4562 store.ensure_external_task(repo, &ext).await.unwrap();
4563
4564 let recovered = recover_missing_review_worktree(&ext, repo, &store)
4565 .await
4566 .unwrap();
4567
4568 assert!(recovered.work_dir.exists());
4569 assert!(recovered.branch.starts_with("gh-issue-1-"));
4570
4571 if let Some(old) = old_orch_home {
4572 std::env::set_var("ORCH_HOME", old);
4573 } else {
4574 std::env::remove_var("ORCH_HOME");
4575 }
4576 }
4577
4578 #[tokio::test]
4581 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
4582 let temp = TempDir::new().unwrap();
4583 let dir = temp.path();
4584
4585 let bare = temp.path().join("bare.git");
4587 std::fs::create_dir_all(&bare).unwrap();
4588 std::process::Command::new("git")
4589 .args(["init", "--bare"])
4590 .current_dir(&bare)
4591 .output()
4592 .unwrap();
4593
4594 git(dir, &["init", "-b", "main"]);
4595 git(dir, &["config", "user.name", "Test"]);
4596 git(dir, &["config", "user.email", "test@test.com"]);
4597 std::fs::write(dir.join("f"), "a").unwrap();
4598 git(dir, &["add", "f"]);
4599 git(dir, &["commit", "-m", "init"]);
4600 std::process::Command::new("git")
4601 .args(["remote", "add", "origin", bare.to_str().unwrap()])
4602 .current_dir(dir)
4603 .output()
4604 .unwrap();
4605 std::process::Command::new("git")
4606 .args(["push", "origin", "main"])
4607 .current_dir(dir)
4608 .output()
4609 .unwrap();
4610
4611 let result = count_ahead_commits(dir, "main").await;
4613 assert!(
4614 result.is_ok(),
4615 "expected Ok when origin/main exists, got {result:?}"
4616 );
4617 assert_eq!(
4618 result.unwrap(),
4619 0,
4620 "expected 0 commits ahead when branch matches origin/main"
4621 );
4622 }
4623
4624 #[tokio::test]
4625 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4626 let temp = TempDir::new().unwrap();
4627 let dir = temp.path();
4628 git(dir, &["init", "-b", "main"]);
4629 git(dir, &["config", "user.name", "Test"]);
4630 git(dir, &["config", "user.email", "test@test.com"]);
4631 std::fs::write(dir.join("f"), "a").unwrap();
4632 git(dir, &["add", "f"]);
4633 git(dir, &["commit", "-m", "init"]);
4634 let result = count_ahead_commits(dir, "main").await;
4636 assert!(
4637 result.is_err(),
4638 "expected Err when origin/main does not exist, got {result:?}"
4639 );
4640 let err = result.unwrap_err();
4641 assert!(
4642 err.contains("git rev-list exited"),
4643 "error should mention exit failure, got: {err}"
4644 );
4645 }
4646
4647 #[tokio::test]
4648 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4649 let temp = TempDir::new().unwrap();
4651 let dir = temp.path();
4652 let result = count_ahead_commits(dir, "main").await;
4654 assert!(
4655 result.is_err(),
4656 "expected Err when directory is not a git repo, got {result:?}"
4657 );
4658 }
4659
4660 #[serial_test::serial(cooldown_state)]
4664 #[tokio::test]
4665 async fn review_invalid_response_applies_short_model_cooldown() {
4666 crate::engine::cooldown::reset_global_state().await;
4667 let agent = "opencode-3314-review-invalid";
4668 let model = "opencode/north-mini-code-free";
4669 let key = format!("{agent}:{model}");
4670
4671 assert!(
4672 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4673 "model should not be in cooldown before record_review_agent_failure"
4674 );
4675
4676 let err = runner::agents::AgentError::InvalidResponse {
4677 raw: "empty review output".to_string(),
4678 };
4679 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4680
4681 let now = chrono::Utc::now().timestamp();
4682 let until = crate::engine::cooldown::cooldown_until(&key)
4683 .expect("review InvalidResponse should set model cooldown");
4684 let remaining = until.saturating_sub(now);
4685 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4686 assert!(
4687 (base - 30..=base).contains(&remaining),
4688 "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4689 );
4690 }
4691
4692 #[serial_test::serial(cooldown_state)]
4694 #[tokio::test]
4695 async fn review_output_cooldown_caps_at_fifteen_minutes() {
4696 crate::engine::cooldown::reset_global_state().await;
4697 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4698 crate::engine::cooldown::init_cooldown_store(store).await;
4699 let agent = "opencode-review-output-cap";
4700 let model = "opencode/north-mini-code-free";
4701 let key = format!("{agent}:{model}");
4702 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4703 let max = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4704
4705 for (i, expected) in [base, max, max].into_iter().enumerate() {
4706 let err = runner::agents::AgentError::InvalidResponse {
4707 raw: format!("failed to parse review response {i}"),
4708 };
4709 record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4710 let now = chrono::Utc::now().timestamp();
4711 let remaining = crate::engine::cooldown::cooldown_until(&key)
4712 .expect("unusable review output should set model cooldown")
4713 .saturating_sub(now);
4714 assert!(
4715 (expected - 30..=expected).contains(&remaining),
4716 "failure {} should cool the model for {expected}s, got {remaining}s",
4717 i + 1
4718 );
4719 }
4720 }
4721
4722 #[serial_test::serial(cooldown_state)]
4728 #[tokio::test]
4729 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4730 crate::engine::cooldown::reset_global_state().await;
4731 let agent = "kimi-3551-review-inline";
4732 let model = "opus";
4733 let key = format!("{agent}:{model}");
4734
4735 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4737 You've reached your usage limit for this billing cycle. Your quota will be \
4738 refreshed next cycle.";
4739
4740 let scan_tail = runner::agents::patterns::safe_tail(
4741 text_for_review,
4742 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4743 );
4744 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4745 .expect("billing-cycle message must still be caught by the rate-limit scan");
4746
4747 assert_eq!(
4748 outcome_for_agent_error(&err),
4749 "billing_cycle_exhausted",
4750 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4751 );
4752
4753 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4754
4755 let now = chrono::Utc::now().timestamp();
4756 let until = crate::engine::cooldown::cooldown_until(&key)
4757 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4758 let remaining = until.saturating_sub(now);
4759 assert!(
4764 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4765 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4766 model cooldown, got {remaining}s — may have used the generic short agent \
4767 cooldown instead"
4768 );
4769 }
4770
4771 fn test_review_task(id: &str) -> ExternalTask {
4774 ExternalTask {
4775 id: crate::backends::ExternalId(id.to_string()),
4776 title: "Test review task".to_string(),
4777 body: "".to_string(),
4778 state: "open".to_string(),
4779 labels: vec![],
4780 author: "tester".to_string(),
4781 created_at: "2026-01-01T00:00:00Z".to_string(),
4782 updated_at: "2026-01-01T00:00:00Z".to_string(),
4783 url: "https://example.com/issues/1".to_string(),
4784 }
4785 }
4786
4787 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4788 ReviewContext {
4789 store_id: None,
4790 had_prev_push_failure: false,
4791 review_attempt: 1,
4792 worktree_path: work_dir.to_path_buf(),
4793 branch_name: "gh-issue-1-test".to_string(),
4794 pr_number: 1,
4795 head_sha: None,
4796 default_branch: "main".to_string(),
4797 review_agent: agent.to_string(),
4798 review_model: Some(model.to_string()),
4799 review_task_id: "review-1".to_string(),
4800 review_attempt_dir: work_dir.to_path_buf(),
4801 output_file: work_dir.join("output.txt"),
4802 invocation: runner::agent::AgentInvocation {
4803 agent: agent.to_string(),
4804 model: Some(model.to_string()),
4805 work_dir: work_dir.to_path_buf(),
4806 system_prompt: String::new(),
4807 agent_message: String::new(),
4808 task_id: "review-1".to_string(),
4809 disallowed_tools: vec![],
4810 git_author_name: "Test".to_string(),
4811 git_author_email: "test@example.com".to_string(),
4812 output_file: work_dir.join("output.txt"),
4813 timeout_seconds: 0,
4814 repo: "owner/repo".to_string(),
4815 attempt: 1,
4816 sandbox: false,
4817 main_project_dir: work_dir.to_path_buf(),
4818 branch: String::new(),
4819 },
4820 same_agent_fallback: false,
4821 }
4822 }
4823
4824 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4825 ParsedReview {
4826 run_id: None,
4827 exit_code: 0,
4828 stderr: String::new(),
4829 raw_output: String::new(),
4830 text_for_review: String::new(),
4831 token_usage: RunTokenUsage::default(),
4832 review_notes_for_comment: String::new(),
4833 decision,
4834 }
4835 }
4836
4837 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4838 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4839 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4840 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4841 }
4842
4843 #[serial_test::serial(cooldown_state)]
4847 #[tokio::test]
4848 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4849 crate::engine::cooldown::reset_global_state().await;
4850 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4851 let work_dir = TempDir::new().unwrap();
4852 let agent = "kimi";
4853 let model = "opus-3734-mid-thought";
4854 let ctx = test_review_context(agent, model, work_dir.path());
4855 let redraw = "^D\u{0008}".repeat(20);
4856 let ndjson = format!(
4857 "{}\n{}\n{}\n{}\n{}",
4858 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4859 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}}}"#,
4860 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4861 "\x1b[?25h",
4862 redraw,
4863 );
4864 write_review_output(work_dir.path(), &ndjson, 0);
4865
4866 let task = test_review_task("3734");
4867 let phase = parse_review_output(
4868 &task,
4869 "owner/repo",
4870 &ctx,
4871 ReviewRun { run_id: None },
4872 &store,
4873 )
4874 .await;
4875
4876 match phase {
4877 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4878 assert!(
4879 reason.contains("incomplete:"),
4880 "mid-thought review must be stored as incomplete, got {reason}"
4881 );
4882 }
4883 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4884 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4885 }
4886 assert!(
4887 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4888 "mid-thought incomplete must not apply persistent model cooldown"
4889 );
4890 }
4891
4892 #[serial_test::serial(cooldown_state)]
4895 #[tokio::test]
4896 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4897 crate::engine::cooldown::reset_global_state().await;
4898 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4899 let work_dir = TempDir::new().unwrap();
4900 let agent = "kimi";
4901 let model = "opus-3734-tool-use";
4902 let ctx = test_review_context(agent, model, work_dir.path());
4903 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4904 write_review_output(work_dir.path(), ndjson, 0);
4905
4906 let task = test_review_task("3734-tool");
4907 let phase = parse_review_output(
4908 &task,
4909 "owner/repo",
4910 &ctx,
4911 ReviewRun { run_id: None },
4912 &store,
4913 )
4914 .await;
4915
4916 match phase {
4917 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4918 assert!(
4919 reason.contains("incomplete:"),
4920 "tool_use without envelope must be incomplete, got {reason}"
4921 );
4922 }
4923 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4924 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4925 }
4926 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4927 }
4928
4929 #[serial_test::serial(cooldown_state)]
4932 #[tokio::test]
4933 async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
4934 crate::engine::cooldown::reset_global_state().await;
4935 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4936 let work_dir = TempDir::new().unwrap();
4937 let agent = "kimi";
4938 let model = "opus-3734-format";
4939 let ctx = test_review_context(agent, model, work_dir.path());
4940 let ndjson = concat!(
4941 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4942 "\n",
4943 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4944 );
4945 write_review_output(work_dir.path(), ndjson, 0);
4946
4947 let task = test_review_task("3734-format");
4948 let phase = parse_review_output(
4949 &task,
4950 "owner/repo",
4951 &ctx,
4952 ReviewRun { run_id: None },
4953 &store,
4954 )
4955 .await;
4956
4957 match phase {
4958 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4959 assert!(
4960 reason.contains("parse error"),
4961 "finished unparseable envelope must stay a parse error, got {reason}"
4962 );
4963 }
4964 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4965 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4966 }
4967 let now = chrono::Utc::now().timestamp();
4968 let remaining = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
4969 .expect("finished format failure must apply a model cooldown")
4970 .saturating_sub(now);
4971 assert!(
4972 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
4973 "finished format failure must apply the 5 minute cooldown, got {remaining}s"
4974 );
4975 }
4976
4977 #[serial_test::serial(cooldown_state)]
4980 #[tokio::test]
4981 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4982 crate::engine::cooldown::reset_global_state().await;
4983 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4984 let work_dir = TempDir::new().unwrap();
4985 let agent = "kimi";
4986 let model = "opus-3734-assistant";
4987 let ctx = test_review_context(agent, model, work_dir.path());
4988 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4989 write_review_output(work_dir.path(), ndjson, 0);
4990
4991 let task = test_review_task("3734-asst");
4992 let phase = parse_review_output(
4993 &task,
4994 "owner/repo",
4995 &ctx,
4996 ReviewRun { run_id: None },
4997 &store,
4998 )
4999 .await;
5000
5001 match phase {
5002 ReviewPhase::Ready(parsed) => {
5003 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
5004 }
5005 ReviewPhase::EarlyReturn(_) => {
5006 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
5007 }
5008 }
5009 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
5010 }
5011
5012 #[serial_test::serial(cooldown_state)]
5015 #[tokio::test]
5016 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
5017 crate::engine::cooldown::reset_global_state().await;
5018 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5019 let work_dir = TempDir::new().unwrap();
5020 let agent = "kimi";
5021 let model = "opus-3734-done";
5022 let ctx = test_review_context(agent, model, work_dir.path());
5023 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
5024 write_review_output(work_dir.path(), ndjson, 0);
5025
5026 let task = test_review_task("3734-done");
5027 let phase = parse_review_output(
5028 &task,
5029 "owner/repo",
5030 &ctx,
5031 ReviewRun { run_id: None },
5032 &store,
5033 )
5034 .await;
5035
5036 match phase {
5037 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5038 assert!(
5039 reason.contains("parse error"),
5040 "unfinished prose without a decision must not approve, got {reason}"
5041 );
5042 }
5043 ReviewPhase::Ready(_) => {
5044 panic!("task-path status=done synthesis must not become a review approval")
5045 }
5046 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5047 }
5048 }
5049
5050 #[serial_test::serial(cooldown_state)]
5056 #[tokio::test]
5057 async fn finalize_review_run_success_resets_generic_model_failure_count() {
5058 crate::engine::cooldown::reset_global_state().await;
5059 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5060 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5061
5062 let agent = "test-3478-agent";
5063 let model = "test-3478-model";
5064 let failure_key = format!("failure_count:{agent}:{model}");
5065
5066 let err = runner::agents::AgentError::AgentFailed {
5070 message: "agent reported a generic failure".to_string(),
5071 };
5072 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
5073 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5074 assert_eq!(
5075 store.kv_get(&failure_key).await.unwrap().as_deref(),
5076 Some("1"),
5077 "sanity check: failure should have incremented the persisted count"
5078 );
5079
5080 let work_dir = TempDir::new().unwrap();
5081 let ctx = test_review_context(agent, model, work_dir.path());
5082 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5083 let task = test_review_task("1");
5084
5085 finalize_review_run(&task, &ctx, &parsed, &store).await;
5086 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5087
5088 assert_eq!(
5089 store.kv_get(&failure_key).await.unwrap().as_deref(),
5090 Some("0"),
5091 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
5092 );
5093 }
5094
5095 #[serial_test::serial(cooldown_state)]
5101 #[tokio::test]
5102 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
5103 crate::engine::cooldown::reset_global_state().await;
5104 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5105 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5106
5107 let agent = "test-3478-interleave-agent";
5108 let model = "test-3478-interleave-model";
5109 let cooldown_key = format!("{agent}:{model}");
5110
5111 let work_dir = TempDir::new().unwrap();
5112 let ctx = test_review_context(agent, model, work_dir.path());
5113 let task = test_review_task("2");
5114
5115 for i in 0..4 {
5116 let err = runner::agents::AgentError::AgentFailed {
5117 message: format!("failure {i}"),
5118 };
5119 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
5120
5121 let now = chrono::Utc::now().timestamp();
5122 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5123 .expect("failure should set cooldown");
5124 let remaining = until.saturating_sub(now);
5125
5126 assert!(
5127 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
5128 "failure #{} must stay at base backoff since the prior success reset the \
5129 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
5130 here means review successes are not resetting failure_count",
5131 i + 1,
5132 crate::engine::cooldown::BACKOFF_BASE_SECS
5133 );
5134
5135 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5139 finalize_review_run(&task, &ctx, &parsed, &store).await;
5140 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5141 }
5142 }
5143
5144 #[serial_test::serial(cooldown_state)]
5150 #[tokio::test]
5151 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
5152 crate::engine::cooldown::reset_global_state().await;
5153 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5154 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5155
5156 let agent = "test-3571-agent";
5157 let model = "test-3571-model";
5158 let cooldown_key = format!("{agent}:{model}");
5159
5160 let work_dir = TempDir::new().unwrap();
5161 let ctx = test_review_context(agent, model, work_dir.path());
5162 let task = test_review_task("3");
5163
5164 let err = runner::agents::AgentError::ModelUnavailable {
5166 message: "model not found".to_string(),
5167 model: model.to_string(),
5168 };
5169 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
5170 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5171
5172 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5175 finalize_review_run(&task, &ctx, &parsed, &store).await;
5176 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5177
5178 let err2 = runner::agents::AgentError::ModelUnavailable {
5181 message: "model not found again".to_string(),
5182 model: model.to_string(),
5183 };
5184 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
5185 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5186
5187 let now = chrono::Utc::now().timestamp();
5188 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5189 .expect("second failure should set cooldown");
5190 let remaining = until.saturating_sub(now);
5191
5192 assert!(
5193 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
5194 "second persistent model failure should escalate past the 4h base despite an \
5195 intervening success (issue #3571); got {remaining}s, base is {}s",
5196 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
5197 );
5198 }
5199
5200 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
5201 Arc::new(crate::github::app::AppAuth::new(
5202 "12345".to_string(),
5203 crate::github::app::test_pem().to_string(),
5204 server.uri(),
5205 ))
5206 }
5207
5208 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
5209 use wiremock::matchers::{method, path};
5210 use wiremock::{Mock, ResponseTemplate};
5211 Mock::given(method("GET"))
5212 .and(path(format!("/repos/{repo}/installation")))
5213 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5214 .mount(server)
5215 .await;
5216 Mock::given(method("POST"))
5217 .and(path("/app/installations/42/access_tokens"))
5218 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5219 "token": "inst-1",
5220 "expires_at": "2099-01-01T00:00:00Z",
5221 })))
5222 .mount(server)
5223 .await;
5224 }
5225
5226 #[tokio::test]
5227 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
5228 use crate::review_mode::ReviewMode;
5229 use wiremock::matchers::{method, path};
5230 use wiremock::{Mock, MockServer, ResponseTemplate};
5231 let server = MockServer::start().await;
5232 Mock::given(method("GET"))
5233 .and(path("/repos/org/b/installation"))
5234 .respond_with(ResponseTemplate::new(404))
5235 .expect(1)
5236 .mount(&server)
5237 .await;
5238 let app = app_for_test(&server);
5239
5240 assert!(
5241 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
5242 .await
5243 .is_none()
5244 );
5245 assert!(
5247 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
5248 .await
5249 .is_none()
5250 );
5251 assert!(review_app_for(ReviewMode::App, None, "org/b")
5252 .await
5253 .is_none());
5254 }
5255
5256 #[tokio::test]
5257 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
5258 use wiremock::matchers::{body_partial_json, method, path};
5259 use wiremock::{Mock, MockServer, ResponseTemplate};
5260 let server = MockServer::start().await;
5261 mount_installed(&server, "o/r").await;
5262 Mock::given(method("POST"))
5263 .and(path("/repos/o/r/check-runs"))
5264 .and(body_partial_json(serde_json::json!({
5265 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
5266 })))
5267 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5268 .expect(1)
5269 .mount(&server)
5270 .await;
5271 Mock::given(method("POST"))
5272 .and(path("/repos/o/r/check-runs"))
5273 .and(body_partial_json(serde_json::json!({
5274 "name": "orch/review", "head_sha": "abc123", "status": "completed",
5275 "conclusion": "failure", "output": {"summary": "fix it"},
5276 })))
5277 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5278 .expect(1)
5279 .mount(&server)
5280 .await;
5281 let app = app_for_test(&server);
5282 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5283 let decision = ReviewDecision::RequestChanges {
5284 notes: "fix it".to_string(),
5285 issues: vec![],
5286 };
5287
5288 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
5289 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
5290 let requests = server.received_requests().await.expect("requests");
5291 assert!(requests
5292 .iter()
5293 .all(|req| !req.url.path().contains("/statuses/")));
5294 }
5295
5296 #[tokio::test]
5297 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
5298 use crate::engine::runner::response::ReviewIssue;
5299 use wiremock::matchers::{method, path, query_param};
5300 use wiremock::{Mock, MockServer, ResponseTemplate};
5301 let server = MockServer::start().await;
5302 mount_installed(&server, "o/r").await;
5303 Mock::given(method("GET"))
5304 .and(path("/repos/o/r/pulls/9/files"))
5305 .and(query_param("page", "1"))
5306 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
5307 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
5308 ])))
5309 .expect(1)
5310 .mount(&server)
5311 .await;
5312 Mock::given(method("POST"))
5313 .and(path("/repos/o/r/pulls/9/reviews"))
5314 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5315 .expect(1)
5316 .mount(&server)
5317 .await;
5318 Mock::given(method("POST"))
5319 .and(path("/repos/o/r/issues/9/comments"))
5320 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5321 .mount(&server)
5322 .await;
5323 let issue = |file: &str, line: Option<u32>| ReviewIssue {
5324 file: file.to_string(),
5325 line,
5326 severity: "error".to_string(),
5327 description: "wrong".to_string(),
5328 };
5329 let decision = ReviewDecision::RequestChanges {
5330 notes: "fix".to_string(),
5331 issues: vec![
5332 issue("src/a.rs", Some(11)),
5333 issue("src/a.rs", Some(50)),
5334 issue("src/b.rs", Some(3)),
5335 issue("src/a.rs", None),
5336 ],
5337 };
5338 let app = app_for_test(&server);
5339 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5340
5341 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
5342 .await
5343 .expect("comment posted");
5344 let requests = server.received_requests().await.expect("requests");
5345 let review = requests
5346 .iter()
5347 .find(|req| req.url.path().ends_with("/reviews"))
5348 .expect("review submitted");
5349 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
5350 assert_eq!(
5351 body["comments"],
5352 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
5353 );
5354 }
5355
5356 #[allow(clippy::await_holding_lock)]
5357 #[tokio::test]
5358 async fn without_an_app_the_review_path_posts_only_the_comment() {
5359 use wiremock::matchers::{method, path};
5360 use wiremock::{Mock, MockServer, ResponseTemplate};
5361 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
5362 let prev = std::env::var_os("GH_TOKEN");
5363 std::env::set_var("GH_TOKEN", "owner-token");
5364 let server = MockServer::start().await;
5365 Mock::given(method("POST"))
5366 .and(path("/repos/o/r/issues/9/comments"))
5367 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5368 .expect(1)
5369 .mount(&server)
5370 .await;
5371 let gh = GhHttp::for_test(server.uri(), None);
5372 let result = publish_review(
5373 None,
5374 &gh,
5375 "o/r",
5376 9,
5377 Some("abc123"),
5378 &ReviewDecision::Approve { issues: vec![] },
5379 "## Automated Review \u{2014} Approve\n\nok",
5380 )
5381 .await;
5382 match prev {
5383 Some(value) => std::env::set_var("GH_TOKEN", value),
5384 None => std::env::remove_var("GH_TOKEN"),
5385 }
5386 result.expect("comment posted");
5387 let requests = server.received_requests().await.expect("requests");
5388 assert!(requests
5389 .iter()
5390 .all(|req| !req.url.path().contains("/reviews")));
5391 }
5392
5393 #[tokio::test]
5394 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
5395 use wiremock::matchers::{body_partial_json, method, path};
5396 use wiremock::{Mock, MockServer, ResponseTemplate};
5397 let server = MockServer::start().await;
5398 Mock::given(method("POST"))
5399 .and(path("/app/installations/42/access_tokens"))
5400 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5401 "token": "inst-1",
5402 "expires_at": "2099-01-01T00:00:00Z",
5403 })))
5404 .mount(&server)
5405 .await;
5406 Mock::given(method("POST"))
5407 .and(path("/repos/o/r/pulls/9/reviews"))
5408 .and(body_partial_json(serde_json::json!({
5409 "event": "REQUEST_CHANGES",
5410 "commit_id": "abc123",
5411 })))
5412 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5413 .expect(1)
5414 .mount(&server)
5415 .await;
5416 Mock::given(method("POST"))
5417 .and(path("/repos/o/r/issues/9/comments"))
5418 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5419 .expect(1)
5420 .mount(&server)
5421 .await;
5422 Mock::given(method("GET"))
5423 .and(path("/repos/o/r/installation"))
5424 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5425 .mount(&server)
5426 .await;
5427 let app = Arc::new(crate::github::app::AppAuth::new(
5428 "12345".to_string(),
5429 crate::github::app::test_pem().to_string(),
5430 server.uri(),
5431 ));
5432 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5433 let decision = ReviewDecision::RequestChanges {
5434 notes: "fix it".to_string(),
5435 issues: vec![],
5436 };
5437 publish_review(
5438 Some(&app),
5439 &gh,
5440 "o/r",
5441 9,
5442 Some("abc123"),
5443 &decision,
5444 "## Automated Review \u{2014} Changes Requested\n\nfix it",
5445 )
5446 .await
5447 .expect("comment posted");
5448 }
5449}