1use crate::backends::{ExternalBackend, ExternalId, Status};
25use crate::config;
26use crate::engine::auto_merge::{dedup_reviews, handle_review_changes, MAX_MERGE_CONFLICT_RETRIES};
27use crate::engine::tasks::TaskManager;
28use crate::engine::EngineConfig;
29use crate::github::http::{GhHttp, PrReviewBatchData};
30use crate::github::types::{GitHubComment, GitHubReviewComment, PullRequestReview};
31use crate::store::TaskStore;
32use crate::store::{store_increment_by_id, store_reset_failure_counters, store_set_result_by_id};
33use async_trait::async_trait;
34use dashmap::{DashMap, DashSet};
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::Duration;
38
39use super::sync::ReviewTaskSnapshot;
40
41#[allow(clippy::double_must_use)] #[async_trait]
46pub(crate) trait GhReviewClient: Send + Sync {
47 async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>>;
48 async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool>;
49 async fn batch_fetch_pr_review_data(
50 &self,
51 repo: &str,
52 pr_numbers: &[u64],
53 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>>;
54 async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool>;
55}
56
57#[async_trait]
58impl GhReviewClient for GhHttp {
59 async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
60 self.get_pr_number(repo, branch).await
61 }
62 async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
63 self.is_pr_merged(repo, branch).await
64 }
65 async fn batch_fetch_pr_review_data(
66 &self,
67 repo: &str,
68 pr_numbers: &[u64],
69 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
70 self.batch_fetch_pr_review_data(repo, pr_numbers).await
71 }
72 async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
73 self.is_collaborator(repo, username).await
74 }
75}
76
77#[derive(Debug, Clone, Copy)]
79enum ConflictAction {
80 RerouteToAgent,
82 BlockForHuman,
84}
85
86async fn handle_merge_conflict(
91 id: &ExternalId,
92 pr_number: u64,
93 retries: u64,
94 attempts: u64,
95 task_manager: &Arc<TaskManager>,
96 store: &Arc<TaskStore>,
97 store_id: i64,
98) -> ConflictAction {
99 let task_id = &id.0;
100 let max_attempts: u64 = config::get("workflow.max_attempts")
101 .ok()
102 .and_then(|s| s.parse().ok())
103 .unwrap_or(5);
104 if retries >= MAX_MERGE_CONFLICT_RETRIES {
105 tracing::error!(
106 task_id,
107 pr_number,
108 retries,
109 "PR approved but merge conflict retry limit reached — blocking for human review"
110 );
111 let fields = [
112 (
113 "block_reason",
114 serde_json::json!(format!(
115 "merge conflict retry limit ({}) reached",
116 MAX_MERGE_CONFLICT_RETRIES
117 )),
118 ),
119 (
120 "last_error",
121 serde_json::json!(format!(
122 "PR approved but has unresolved merge conflicts after {} retries",
123 retries
124 )),
125 ),
126 ];
127 if let Err(e) = task_manager
128 .update_task_status_and_result(id, Status::Blocked, &fields)
129 .await
130 {
131 tracing::error!(task_id, err = %e, "failed to write block_reason and set Blocked");
132 }
133 return ConflictAction::BlockForHuman;
134 }
135 if attempts >= max_attempts {
136 tracing::error!(
137 task_id,
138 pr_number,
139 attempts,
140 max_attempts,
141 "PR approved but merge-conflict reroute would exceed max attempts — blocking for human review"
142 );
143 let fields = [
144 (
145 "block_reason",
146 serde_json::json!(format!(
147 "approved PR still needs merge-conflict resolution but task already hit max attempts ({attempts}/{max_attempts}); manual intervention required"
148 )),
149 ),
150 (
151 "last_error",
152 serde_json::json!(format!(
153 "review_poll refused merge-conflict reroute because task exceeded max attempts ({attempts}/{max_attempts})"
154 )),
155 ),
156 ];
157 if let Err(e) = task_manager
158 .update_task_status_and_result(id, Status::Blocked, &fields)
159 .await
160 {
161 tracing::error!(task_id, err = %e, "failed to write max-attempts block_reason and set Blocked");
162 }
163 return ConflictAction::BlockForHuman;
164 }
165 let _repo = "TEMP"; if let Err(e) = store_set_result_by_id(
167 &Some(Arc::clone(store)),
168 store_id,
169 &[
170 (
171 "last_error",
172 serde_json::json!(
173 "merge conflict detected in review_poll; task agent must rebase and resolve conflicts"
174 ),
175 ),
176 (
177 "route_reason",
178 serde_json::json!("re-dispatch after merge conflict detected in review_poll"),
179 ),
180 ],
181 )
182 .await
183 {
184 tracing::warn!(task_id, err = %e, "store write failed");
185 }
186 tracing::info!(
187 task_id,
188 pr_number,
189 retries,
190 "PR approved but has merge conflicts — re-routing task agent to resolve"
191 );
192 if let Err(e) =
193 store_increment_by_id(&Some(Arc::clone(store)), store_id, "merge_conflict_retries").await
194 {
195 tracing::warn!(task_id, err = %e, "failed to increment merge_conflict_retries — skipping dispatch to avoid bypassing retry limit");
196 return ConflictAction::BlockForHuman;
197 }
198 if let Err(e) = task_manager.update_task_status(id, Status::Routed).await {
199 tracing::warn!(task_id, err = %e, "failed to set Routed for conflict retry");
200 }
201 ConflictAction::RerouteToAgent
202}
203
204#[allow(clippy::double_must_use)] #[async_trait]
206trait ReviewBatchFailCounterStore: Send + Sync {
207 async fn kv_delete(&self, key: &str) -> anyhow::Result<()>;
208 async fn kv_set(&self, key: &str, value: &str) -> anyhow::Result<()>;
209}
210
211#[async_trait]
212impl ReviewBatchFailCounterStore for TaskStore {
213 async fn kv_delete(&self, key: &str) -> anyhow::Result<()> {
214 TaskStore::kv_delete(self, key).await
215 }
216
217 async fn kv_set(&self, key: &str, value: &str) -> anyhow::Result<()> {
218 TaskStore::kv_set(self, key, value).await
219 }
220}
221
222async fn reset_batch_fail_counter(
228 store: &dyn ReviewBatchFailCounterStore,
229 batch_fail_key: &str,
230) -> bool {
231 match store.kv_delete(batch_fail_key).await {
232 Ok(()) => true,
233 Err(delete_err) => {
234 tracing::warn!(
235 key = %batch_fail_key,
236 err = %delete_err,
237 "failed to clear review batch failure counter, falling back to zero reset"
238 );
239 match store.kv_set(batch_fail_key, "0").await {
240 Ok(()) => {
241 tracing::warn!(
242 key = %batch_fail_key,
243 "review batch failure counter reset to 0 after delete failure"
244 );
245 true
246 }
247 Err(set_err) => {
248 tracing::error!(
249 key = %batch_fail_key,
250 delete_err = %delete_err,
251 set_err = %set_err,
252 "failed to reset review batch failure counter; stale value may cause false degradation"
253 );
254 false
255 }
256 }
257 }
258 }
259}
260
261#[allow(clippy::too_many_arguments)]
267pub(crate) async fn review_open_prs(
268 backend: &Arc<dyn ExternalBackend>,
269 repo: &str,
270 _config: &EngineConfig,
271 task_manager: &Arc<TaskManager>,
272 store: &Arc<TaskStore>,
273 dispatching: &Arc<DashMap<String, String>>,
274 auto_merge_in_flight: &Arc<DashSet<String>>,
275 in_review_tasks: &[ReviewTaskSnapshot],
276 gh: &dyn GhReviewClient,
277 router_config: &crate::engine::router::config::RouterConfig,
278) -> anyhow::Result<()> {
279 if in_review_tasks.is_empty() {
280 tracing::debug!(count = 0, "checking in_review tasks for PR reviews");
281 return Ok(());
282 }
283
284 tracing::info!(
285 count = in_review_tasks.len(),
286 "checking in_review tasks for PR reviews"
287 );
288
289 struct ReadyTask {
292 task: crate::backends::ExternalTask,
293 store_id: i64,
294 stored: crate::store::Task,
295 branch: String,
296 pr_number: Option<u64>,
298 }
299
300 let mut ready_tasks: Vec<ReadyTask> = Vec::new();
301
302 for snapshot in in_review_tasks {
303 let task = snapshot.external.clone();
304 let task_id = &task.id.0;
305
306 let dispatch_key = format!("{}/{}", repo, task_id);
308 if dispatching.contains_key(&dispatch_key) {
309 tracing::debug!(
310 task_id,
311 "task locked by dispatch flow, skipping review_open_prs"
312 );
313 continue;
314 }
315
316 let store_id = snapshot.stored.id;
317 let stored = snapshot.stored.clone();
318
319 let branch = if stored.branch.is_empty() {
320 tracing::warn!(
321 task_id,
322 "in_review task has no branch info — setting needs_review"
323 );
324 if let Err(e) = task_manager
325 .update_task_status(&task.id, Status::NeedsReview)
326 .await
327 {
328 tracing::warn!(task_id, err = %e, "failed to update status");
329 }
330 continue;
331 } else {
332 stored.branch.clone()
333 };
334
335 let stored_pr_number = stored.pr_number.map(|n| n as u64);
336 ready_tasks.push(ReadyTask {
337 task,
338 store_id,
339 stored,
340 branch,
341 pr_number: stored_pr_number,
342 });
343 }
344
345 if ready_tasks.is_empty() {
346 return Ok(());
347 }
348
349 let lookup_indices: Vec<usize> = ready_tasks
354 .iter()
355 .enumerate()
356 .filter_map(|(i, t)| if t.pr_number.is_none() { Some(i) } else { None })
357 .collect();
358
359 if !lookup_indices.is_empty() {
360 let branches: Vec<String> = lookup_indices
361 .iter()
362 .map(|&i| ready_tasks[i].branch.clone())
363 .collect();
364
365 let pr_number_results =
366 futures::future::join_all(branches.iter().map(|b| gh.get_pr_number(repo, b))).await;
367
368 for (&task_idx, result) in lookup_indices.iter().zip(pr_number_results.iter()) {
369 let task_id = ready_tasks[task_idx].task.id.0.clone();
370 let branch = ready_tasks[task_idx].branch.clone();
371 match result {
372 Ok(Some(n)) => {
373 ready_tasks[task_idx].pr_number = Some(*n);
374 if let Err(e) = store_set_result_by_id(
375 &Some(Arc::clone(store)),
376 ready_tasks[task_idx].store_id,
377 &[("pr_number", serde_json::json!(*n as i64))],
378 )
379 .await
380 {
381 tracing::warn!(task_id, err = %e, "failed to persist pr_number");
382 }
383 }
384 Ok(None) => {
385 }
387 Err(e) => {
388 let e_str = format!("{e}");
389 if crate::engine::runner::git_ops::is_transient_github_api_error(&e_str) {
392 tracing::warn!(task_id, branch = %branch, err = %e, "transient failure getting PR number; will retry later");
393 } else {
394 tracing::warn!(task_id, branch = %branch, err = %e, "failed to get PR number");
395 }
396 }
397 }
398 }
399 }
400
401 let mut tasks_with_pr: Vec<ReadyTask> = Vec::new();
406 let mut no_pr_tasks: Vec<ReadyTask> = Vec::new();
407
408 for task_info in ready_tasks {
409 if task_info.pr_number.is_none() {
410 no_pr_tasks.push(task_info);
411 } else {
412 tasks_with_pr.push(task_info);
413 }
414 }
415
416 if !no_pr_tasks.is_empty() {
417 let merged_results =
418 futures::future::join_all(no_pr_tasks.iter().map(|t| gh.is_pr_merged(repo, &t.branch)))
419 .await;
420
421 for (task_info, merged_result) in no_pr_tasks.into_iter().zip(merged_results) {
422 let task_id = task_info.task.id.0.as_str();
423 let branch = task_info.branch.as_str();
424
425 let merged = match merged_result {
426 Ok(v) => v,
427 Err(e) => {
428 let e_str = format!("{e}");
429 if crate::engine::runner::git_ops::is_transient_github_api_error(&e_str) {
430 tracing::warn!(task_id, branch = %branch, err = %e, "transient GitHub error checking merge status; will retry later");
431 continue;
432 }
433 tracing::warn!(task_id, branch = %branch, err = %e, "merge check failed, skipping task this tick");
434 continue;
435 }
436 };
437
438 if merged {
439 tracing::info!(task_id, branch = %branch, "PR already merged, marking done");
440 if let Err(e) = task_manager
441 .update_task_status(&task_info.task.id, Status::Done)
442 .await
443 {
444 tracing::warn!(task_id, err = %e, "failed to update status to done");
445 }
446 } else {
447 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
448 .ok()
449 .and_then(|s| s.parse().ok())
450 .or_else(|| {
451 config::get("workflow.max_attempts")
452 .ok()
453 .and_then(|s| s.parse().ok())
454 })
455 .unwrap_or(3)
456 .max(1);
457
458 let last_error = task_info.stored.last_error.clone();
461 if crate::engine::runner::git_ops::is_transient_github_api_error(&last_error) {
462 tracing::warn!(task_id, "transient GitHub error recorded in last_error; skipping persistent no_pr_reroutes increment and retrying later");
463 continue;
464 }
465
466 let reroutes = match store_increment_by_id(
467 &Some(Arc::clone(store)),
468 task_info.store_id,
469 "no_code_reroutes",
470 )
471 .await
472 {
473 Ok(v) => v,
474 Err(e) => {
475 tracing::warn!(task_id, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
476 continue;
477 }
478 };
479
480 if reroutes >= max_reroutes as u64 {
481 tracing::error!(
482 task_id,
483 reroutes,
484 max_reroutes,
485 "reached max reroute attempts for in_review no-PR — blocking for human review"
486 );
487 let msg = format!(
488 "no PR or code changes after {}/{} reroute attempts",
489 reroutes, max_reroutes
490 );
491 let fields = [
495 (
496 "block_reason",
497 serde_json::json!(format!(
498 "max reroute attempts ({}) reached — no PR or code changes produced",
499 max_reroutes
500 )),
501 ),
502 ("last_error", serde_json::json!(msg)),
503 ("agent", serde_json::json!(null)),
504 ("model", serde_json::json!(null)),
505 ];
506 if let Err(e) = task_manager
507 .update_task_status_and_result(&task_info.task.id, Status::Blocked, &fields)
508 .await
509 {
510 tracing::error!(task_id, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
511 continue;
512 }
513 } else {
514 tracing::warn!(
515 task_id,
516 branch = %branch,
517 reroutes,
518 max_reroutes,
519 "in_review but no open PR — re-dispatching"
520 );
521 if let Err(e) = task_manager
522 .update_task_status(&task_info.task.id, Status::Routed)
523 .await
524 {
525 tracing::warn!(task_id, err = %e, "failed to update status to routed — will retry next tick");
526 continue;
531 }
532 }
533 }
534 }
535 }
536
537 if tasks_with_pr.is_empty() {
538 return Ok(());
539 }
540
541 let pr_numbers: Vec<u64> = tasks_with_pr.iter().filter_map(|t| t.pr_number).collect();
544
545 let batch_fail_key = format!("review_batch_fail:{repo}");
546 let batch = match gh.batch_fetch_pr_review_data(repo, &pr_numbers).await {
547 Ok(data) => {
548 let _ = reset_batch_fail_counter(store.as_ref(), &batch_fail_key).await;
549 data
550 }
551 Err(e) => {
552 let fails = match store.kv_increment(&batch_fail_key).await {
557 Ok(n) => n,
558 Err(kv_err) => {
559 tracing::warn!(kv_err = %kv_err, err = %e, "failed to track batch_fail counter — treating as degraded");
560 u64::MAX
562 }
563 };
564 if fails >= 10 {
565 tracing::error!(
566 err = %e,
567 consecutive_failures = fails,
568 pr_count = pr_numbers.len(),
569 "review polling degraded — batch fetch failing repeatedly"
570 );
571 } else {
572 tracing::warn!(
573 err = %e,
574 consecutive_failures = fails,
575 pr_count = pr_numbers.len(),
576 "batch PR review fetch failed, skipping this tick"
577 );
578 }
579 return Ok(());
580 }
581 };
582
583 let mut unique_users: std::collections::HashSet<String> = Default::default();
589 for data in batch.values() {
590 for c in &data.issue_comments {
591 if c.body.starts_with("## Automated Review") {
592 unique_users.insert(c.user.login.clone());
593 }
594 }
595 }
596
597 let collab_logins: Vec<String> = unique_users.into_iter().collect();
598 let collab_results =
599 futures::future::join_all(collab_logins.iter().map(|u| gh.is_collaborator(repo, u))).await;
600 let collab_cache: HashMap<String, Option<bool>> = collab_logins
601 .into_iter()
602 .zip(collab_results)
603 .map(|(u, r)| match r {
604 Ok(v) => (u, Some(v)),
605 Err(e) => {
606 tracing::warn!(user = %u, error = %e, "is_collaborator check failed; skipping review comment");
607 (u, None)
608 }
609 })
610 .collect();
611
612 for task_info in tasks_with_pr {
615 let task = &task_info.task;
616 let stored_task = &task_info.stored;
617 let task_id = &task.id.0;
618 let Some(pr_number) = task_info.pr_number else {
619 tracing::warn!(task_id, "task in tasks_with_pr has no pr_number — skipping");
622 continue;
623 };
624
625 if let Err(e) = store_set_result_by_id(
627 &Some(Arc::clone(store)),
628 task_info.store_id,
629 &[("pr_number", serde_json::json!(pr_number as i64))],
630 )
631 .await
632 {
633 tracing::warn!(task_id, err = %e, "failed to persist pr_number");
634 }
635
636 let batch_data = match batch.get(&pr_number) {
637 Some(d) => d,
638 None => {
639 tracing::warn!(
640 task_id,
641 pr_number,
642 "PR not in batch data, skipping this tick"
643 );
644 continue;
645 }
646 };
647
648 let review_ts_map: serde_json::Value =
649 match serde_json::from_str(&stored_task.review_ts_map) {
650 Ok(serde_json::Value::Object(m)) => serde_json::Value::Object(m),
651 _ => serde_json::json!({}),
652 };
653
654 let reviews = &batch_data.reviews;
655 let all_comments = &batch_data.review_comments;
656
657 let deduped_reviews = dedup_reviews(reviews);
659
660 let any_changes_requested = deduped_reviews
661 .values()
662 .any(|r| r.state == "CHANGES_REQUESTED");
663 let all_approved =
664 !deduped_reviews.is_empty() && deduped_reviews.values().all(|r| r.state == "APPROVED");
665
666 let automated_review =
668 automated_review_from_comments(&batch_data.issue_comments, &collab_cache, pr_number);
669 let comment_approved = automated_review.as_deref() == Some("approve");
670 let comment_changes_requested = automated_review.as_deref() == Some("changes_requested");
671
672 if (all_approved || comment_approved)
675 && !comment_changes_requested
676 && !any_changes_requested
677 {
678 let already_merged = batch_data.merged;
679
680 if already_merged {
681 tracing::info!(
682 task_id,
683 pr_number,
684 "PR already merged, marking task as done"
685 );
686 if let Err(e) = task_manager
687 .update_task_status(&task.id, Status::Done)
688 .await
689 {
690 tracing::warn!(task_id, err = %e, "failed to update task status to done");
691 }
692 } else {
693 let is_conflicting = batch_data.mergeable == Some(false);
694
695 if is_conflicting {
696 let retries = stored_task.merge_conflict_retries.max(0) as u64;
697 match handle_merge_conflict(
698 &task.id,
699 pr_number,
700 retries,
701 stored_task.attempts.max(0) as u64,
702 task_manager,
703 store,
704 task_info.store_id,
705 )
706 .await
707 {
708 ConflictAction::BlockForHuman => continue,
709 ConflictAction::RerouteToAgent => {}
710 }
711 }
712
713 tracing::info!(
714 task_id,
715 pr_number,
716 comment_approved,
717 "PR approved but not yet merged — attempting auto-merge"
718 );
719 let task_agent = stored_task
720 .agent
721 .clone()
722 .unwrap_or_else(|| "orch".to_string());
723 let task_model = stored_task
724 .model
725 .clone()
726 .unwrap_or_else(|| "unknown".to_string());
727
728 if auto_merge_in_flight.contains(task_id) {
732 tracing::debug!(
733 task_id,
734 pr_number,
735 "auto-merge already in flight, skipping spawn"
736 );
737 } else {
738 auto_merge_in_flight.insert(task_id.to_string());
741 let task_clone = task.clone();
742 let branch_clone = task_info.branch.clone();
743 let backend_clone = Arc::clone(backend);
744 let repo_string = repo.to_string();
745 let task_manager_clone = Arc::clone(task_manager);
746 let store_clone = Arc::clone(store);
747 let in_flight_clone = Arc::clone(auto_merge_in_flight);
748
749 tokio::spawn(async move {
750 let _guard = scopeguard::guard((), |_| {
751 in_flight_clone.remove(&task_clone.id.0);
752 });
753
754 if let Err(e) = crate::engine::auto_merge::auto_merge_pr(
755 &task_clone,
756 &branch_clone,
757 &backend_clone,
758 &repo_string,
759 &task_agent,
760 &task_model,
761 &task_manager_clone,
762 &store_clone,
763 )
764 .await
765 {
766 tracing::warn!(
767 task_id = task_clone.id.0,
768 pr_number,
769 err = %e,
770 "auto-merge failed, keeping task in_review for next tick"
771 );
772 }
773 });
774 }
775 }
776 continue;
777 }
778
779 if !any_changes_requested && !comment_changes_requested {
781 continue;
782 }
783
784 let mut review_context = String::new();
786 let mut updated_review_ts_map = review_ts_map.clone();
787
788 for review in deduped_reviews
789 .values()
790 .filter(|r| r.state == "CHANGES_REQUESTED")
791 {
792 let reviewer_login = &review.user.login;
793 let reviewer_last_ts = updated_review_ts_map
794 .get(reviewer_login)
795 .and_then(|v| v.as_str())
796 .unwrap_or_default();
797
798 if !reviewer_last_ts.is_empty() && review.submitted_at.as_str() <= reviewer_last_ts {
800 continue;
801 }
802
803 updated_review_ts_map[reviewer_login] = serde_json::json!(review.submitted_at.clone());
805 let review_comments: Vec<GitHubReviewComment> = all_comments
806 .iter()
807 .filter(|c| {
808 c.user.login == review.user.login && c.created_at >= review.submitted_at
809 })
810 .cloned()
811 .collect();
812
813 let pr_review = PullRequestReview {
814 review: (*review).clone(),
815 comments: review_comments.clone(),
816 };
817
818 review_context.push_str(&format!(
819 "### Review by @{} (CHANGES REQUESTED)\n",
820 pr_review.review.user.login
821 ));
822
823 if let Some(ref body) = pr_review.review.body {
824 if !body.trim().is_empty() {
825 review_context.push_str(&format!("**Overall Feedback:** {}\n\n", body));
826 }
827 }
828
829 let actionable = pr_review.actionable_comments();
830 if !actionable.is_empty() {
831 review_context.push_str("**Comments to address:**\n\n");
832 for comment in actionable {
833 review_context.push_str(&format!(
834 "#### File: `{}` (line {})\n",
835 comment.path,
836 comment.line.map(|l| l.to_string()).unwrap_or_default()
837 ));
838
839 if let Some(ref diff_hunk) = comment.diff_hunk {
840 review_context.push_str("```diff\n");
841 review_context.push_str(diff_hunk);
842 review_context.push_str("\n```\n\n");
843 }
844
845 review_context.push_str(&format!("> {}\n\n", comment.body));
846 }
847 }
848 }
849
850 let mut new_comment_review_ts: Option<String> = None;
855 if comment_changes_requested {
856 let last_comment_ts = stored_task.last_comment_review_ts.clone();
857
858 let mut newest_match: Option<&crate::github::types::GitHubComment> = None;
862 for c in &batch_data.issue_comments {
863 if !c
864 .body
865 .starts_with("## Automated Review \u{2014} Changes Requested")
866 {
867 continue;
868 }
869 if !last_comment_ts.is_empty() && c.created_at <= last_comment_ts {
870 continue;
871 }
872 match newest_match {
873 None => newest_match = Some(c),
874 Some(prev) => {
875 if c.created_at > prev.created_at {
876 newest_match = Some(c);
877 }
878 }
879 }
880 }
881
882 if let Some(c) = newest_match {
883 let body: String = c.body.lines().skip(1).collect::<Vec<_>>().join("\n");
884 review_context.push_str("### Automated Review (Changes Requested)\n\n");
885 review_context.push_str(&body);
886 review_context.push('\n');
887
888 new_comment_review_ts = Some(c.created_at.clone());
890 }
891 }
892
893 const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
895 if review_context.len() > MAX_REVIEW_CONTEXT_BYTES {
896 let mut boundary = MAX_REVIEW_CONTEXT_BYTES;
897 while !review_context.is_char_boundary(boundary) {
898 boundary -= 1;
899 }
900 if let Some(pos) = review_context[..boundary].rfind('\n') {
901 review_context.truncate(pos);
902 } else {
903 review_context.truncate(boundary);
904 }
905 review_context.push_str("\n... (review context truncated)");
906 }
907
908 if !review_context.is_empty() {
910 let task_agent = stored_task
911 .agent
912 .clone()
913 .unwrap_or_else(|| "orch".to_string());
914 let task_model = stored_task
915 .model
916 .clone()
917 .unwrap_or_else(|| "unknown".to_string());
918 let review_cycles = stored_task.review_cycles.max(0) as u32;
919 let max_cycles: u32 = crate::config::get("workflow.max_review_cycles")
920 .ok()
921 .and_then(|v| v.parse().ok())
922 .unwrap_or(2);
923
924 if let Err(e) = handle_review_changes(
932 task,
933 &review_context,
934 &[],
935 backend,
936 repo,
937 pr_number,
938 &task_agent,
939 &task_model,
940 task_manager,
941 store,
942 router_config,
943 )
944 .await
945 {
946 tracing::warn!(task_id, err = %e, "failed to handle review feedback — will retry next tick");
947 continue;
948 }
949
950 let mut fields: Vec<(&str, serde_json::Value)> =
952 vec![("review_ts_map", serde_json::json!(updated_review_ts_map))];
953 if let Some(ref ts) = new_comment_review_ts {
954 fields.push(("last_comment_review_ts", serde_json::json!(ts)));
955 }
956 let mut save_ok = false;
958 for _ in 0..3 {
959 if store_set_result_by_id(&Some(Arc::clone(store)), task_info.store_id, &fields)
960 .await
961 .is_ok()
962 {
963 save_ok = true;
964 break;
965 }
966 tokio::time::sleep(Duration::from_millis(200)).await;
967 }
968 if !save_ok {
969 tracing::error!(
970 task_id,
971 "failed to persist review watermark after retries — next tick will re-dispatch same review"
972 );
973 continue;
974 }
975
976 if review_cycles < max_cycles {
977 tracing::info!(task_id, "re-dispatching task to address review feedback");
978 store_reset_failure_counters(&Some(Arc::clone(store)), repo, task_id).await;
979 }
980 }
981 }
982
983 Ok(())
984}
985
986fn automated_review_from_comments(
992 issue_comments: &[GitHubComment],
993 collab_cache: &HashMap<String, Option<bool>>,
994 pr_number: u64,
995) -> Option<String> {
996 let mut newest: Option<&GitHubComment> = None;
998 for c in issue_comments {
999 if !c.body.starts_with("## Automated Review") {
1000 continue;
1001 }
1002 match collab_cache.get(&c.user.login).copied().flatten() {
1003 None if collab_cache.contains_key(&c.user.login) => {
1004 tracing::warn!(
1005 user = %c.user.login,
1006 pr_number,
1007 "skipping automated review comment: collaborator check unavailable"
1008 );
1009 continue;
1010 }
1011 None | Some(false) => {
1012 tracing::warn!(
1013 user = %c.user.login,
1014 pr_number,
1015 "ignoring automated review comment from non-collaborator"
1016 );
1017 continue;
1018 }
1019 Some(true) => {}
1020 }
1021 match newest {
1022 None => newest = Some(c),
1023 Some(prev) => {
1024 if c.created_at > prev.created_at {
1025 newest = Some(c);
1026 }
1027 }
1028 }
1029 }
1030
1031 if let Some(c) = newest {
1032 let first_line = c.body.lines().next().unwrap_or("");
1033 if first_line.contains("Automated Review \u{2014} Approve") {
1034 Some("approve".to_string())
1035 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
1036 Some("changes_requested".to_string())
1037 } else {
1038 None
1039 }
1040 } else {
1041 None
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::backends::{ExternalId, ExternalTask, Mention};
1049 use crate::engine::EngineConfig;
1050 use crate::github::http::PrReviewBatchData;
1051 use crate::github::types::{GitHubComment, GitHubReview, GitHubUser};
1052 use crate::store::{NewTask, TaskStatus, TaskStore};
1053 use async_trait::async_trait;
1054 use dashmap::{DashMap, DashSet};
1055 use std::collections::HashMap;
1056 use std::sync::{Arc, Mutex};
1057 use tracing::Level;
1058 use tracing_subscriber::EnvFilter;
1059
1060 #[derive(Default)]
1063 struct MockGh {
1064 pr_numbers: HashMap<String, Option<u64>>,
1066 merged: HashMap<String, bool>,
1068 batch_data: HashMap<u64, PrReviewBatchData>,
1070 batch_error: bool,
1072 collaborators: HashMap<String, bool>,
1074 }
1075
1076 #[async_trait]
1077 impl GhReviewClient for MockGh {
1078 async fn get_pr_number(&self, _repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1079 Ok(self.pr_numbers.get(branch).copied().flatten())
1080 }
1081 async fn is_pr_merged(&self, _repo: &str, branch: &str) -> anyhow::Result<bool> {
1082 Ok(self.merged.get(branch).copied().unwrap_or(false))
1083 }
1084 async fn batch_fetch_pr_review_data(
1085 &self,
1086 _repo: &str,
1087 _pr_numbers: &[u64],
1088 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
1089 if self.batch_error {
1090 anyhow::bail!("simulated batch GraphQL error");
1091 }
1092 Ok(self.batch_data.clone())
1093 }
1094 async fn is_collaborator(&self, _repo: &str, username: &str) -> anyhow::Result<bool> {
1095 Ok(self.collaborators.get(username).copied().unwrap_or(false))
1096 }
1097 }
1098
1099 struct MockBackend;
1102
1103 #[async_trait]
1104 impl crate::backends::ExternalBackend for MockBackend {
1105 fn name(&self) -> &str {
1106 "mock"
1107 }
1108 async fn create_task(
1109 &self,
1110 _t: &str,
1111 _b: &str,
1112 _l: &[String],
1113 ) -> anyhow::Result<ExternalId> {
1114 Ok(ExternalId("1".to_string()))
1115 }
1116 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
1117 Ok(make_ext_task(&id.0))
1118 }
1119 async fn list_by_status(
1120 &self,
1121 _s: crate::backends::Status,
1122 ) -> anyhow::Result<Vec<ExternalTask>> {
1123 Ok(vec![])
1124 }
1125 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1126 Ok(vec![])
1127 }
1128 async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1129 Ok(())
1130 }
1131 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1132 Ok(())
1133 }
1134 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1135 Ok(())
1136 }
1137 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1138 Ok(vec![])
1139 }
1140 async fn create_sub_task(
1141 &self,
1142 _p: &ExternalId,
1143 _t: &str,
1144 _b: &str,
1145 _l: &[String],
1146 ) -> anyhow::Result<ExternalId> {
1147 Ok(ExternalId("child".to_string()))
1148 }
1149 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1150 Ok(())
1151 }
1152 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1153 Ok(false)
1154 }
1155 async fn health_check(&self) -> anyhow::Result<()> {
1156 Ok(())
1157 }
1158 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1159 Ok(false)
1160 }
1161 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1162 Ok(Some("bot".to_string()))
1163 }
1164 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1165 Ok(vec![])
1166 }
1167 async fn update_status(
1168 &self,
1169 _id: &ExternalId,
1170 _s: crate::backends::Status,
1171 ) -> anyhow::Result<()> {
1172 Ok(())
1173 }
1174 }
1175
1176 fn make_ext_task(id: &str) -> ExternalTask {
1179 ExternalTask {
1180 id: ExternalId(id.to_string()),
1181 title: "test task".to_string(),
1182 body: "".to_string(),
1183 state: "open".to_string(),
1184 labels: vec![],
1185 author: "user".to_string(),
1186 created_at: "2026-01-01T00:00:00Z".to_string(),
1187 updated_at: "2026-01-01T00:00:00Z".to_string(),
1188 url: "".to_string(),
1189 }
1190 }
1191
1192 fn make_comment(id: u64, login: &str, body: &str, created_at: &str) -> GitHubComment {
1193 GitHubComment {
1194 id,
1195 body: body.to_string(),
1196 user: GitHubUser {
1197 login: login.to_string(),
1198 },
1199 created_at: created_at.to_string(),
1200 updated_at: None,
1201 html_url: None,
1202 issue_url: None,
1203 author_association: None,
1204 }
1205 }
1206
1207 fn make_review(
1208 id: u64,
1209 login: &str,
1210 state: &str,
1211 submitted_at: &str,
1212 body: Option<&str>,
1213 ) -> GitHubReview {
1214 GitHubReview {
1215 id,
1216 user: GitHubUser {
1217 login: login.to_string(),
1218 },
1219 body: body.map(str::to_string),
1220 state: state.to_string(),
1221 html_url: None,
1222 submitted_at: submitted_at.to_string(),
1223 commit_id: None,
1224 }
1225 }
1226
1227 async fn make_store() -> Arc<TaskStore> {
1228 Arc::new(TaskStore::open_memory().await.unwrap())
1229 }
1230
1231 fn default_config() -> EngineConfig {
1232 EngineConfig::default()
1233 }
1234
1235 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1236 impl std::io::Write for CaptureWriter {
1237 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1238 self.0.lock().unwrap().extend_from_slice(buf);
1239 Ok(buf.len())
1240 }
1241
1242 fn flush(&mut self) -> std::io::Result<()> {
1243 Ok(())
1244 }
1245 }
1246
1247 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
1248 type Writer = CaptureWriter;
1249 fn make_writer(&'a self) -> Self::Writer {
1250 CaptureWriter(self.0.clone())
1251 }
1252 }
1253
1254 fn with_captured_warn_logs<F>(f: F) -> String
1255 where
1256 F: FnOnce(),
1257 {
1258 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1259 let subscriber = tracing_subscriber::fmt()
1260 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
1261 .with_writer(CaptureWriter(Arc::clone(&output)))
1262 .with_ansi(false)
1263 .finish();
1264 let guard = tracing::subscriber::set_default(subscriber);
1265 f();
1266 drop(guard);
1267 let captured = String::from_utf8_lossy(&output.lock().unwrap()).to_string();
1268 captured
1269 }
1270
1271 #[derive(Clone)]
1272 struct MockBatchFailStore {
1273 delete_error: Option<String>,
1274 set_error: Option<String>,
1275 set_calls: Arc<Mutex<Vec<(String, String)>>>,
1276 }
1277
1278 #[async_trait]
1279 impl ReviewBatchFailCounterStore for MockBatchFailStore {
1280 async fn kv_delete(&self, _key: &str) -> anyhow::Result<()> {
1281 if let Some(msg) = &self.delete_error {
1282 anyhow::bail!("{msg}");
1283 }
1284 Ok(())
1285 }
1286
1287 async fn kv_set(&self, key: &str, value: &str) -> anyhow::Result<()> {
1288 self.set_calls
1289 .lock()
1290 .unwrap()
1291 .push((key.to_string(), value.to_string()));
1292 if let Some(msg) = &self.set_error {
1293 anyhow::bail!("{msg}");
1294 }
1295 Ok(())
1296 }
1297 }
1298
1299 #[test]
1302 fn arc_empty_input_returns_none() {
1303 let result = automated_review_from_comments(&[], &HashMap::new(), 1);
1304 assert_eq!(result, None);
1305 }
1306
1307 #[test]
1308 fn arc_non_automated_comment_ignored() {
1309 let comments = vec![make_comment(
1310 1,
1311 "alice",
1312 "Great work!",
1313 "2026-01-01T00:00:00Z",
1314 )];
1315 let cache = HashMap::from([("alice".to_string(), Some(true))]);
1316 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1317 }
1318
1319 #[test]
1320 fn arc_collaborator_approve_returns_approve() {
1321 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1322 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1323 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1324 assert_eq!(
1325 automated_review_from_comments(&comments, &cache, 1),
1326 Some("approve".to_string())
1327 );
1328 }
1329
1330 #[test]
1331 fn arc_collaborator_changes_requested_returns_changes_requested() {
1332 let body = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
1333 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1334 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1335 assert_eq!(
1336 automated_review_from_comments(&comments, &cache, 1),
1337 Some("changes_requested".to_string())
1338 );
1339 }
1340
1341 #[test]
1342 fn arc_non_collaborator_comment_ignored() {
1343 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1344 let comments = vec![make_comment(1, "outsider", body, "2026-01-01T00:00:00Z")];
1345 let cache = HashMap::from([("outsider".to_string(), Some(false))]);
1346 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1347 }
1348
1349 #[test]
1350 fn arc_unknown_user_not_in_cache_is_ignored() {
1351 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1353 let comments = vec![make_comment(1, "unknown", body, "2026-01-01T00:00:00Z")];
1354 let cache: HashMap<String, Option<bool>> = HashMap::new();
1355 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1356 }
1357
1358 #[test]
1359 fn arc_collab_check_failed_none_in_cache_skipped() {
1360 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1362 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1363 let cache: HashMap<String, Option<bool>> = HashMap::from([("bot".to_string(), None)]);
1364 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1365 }
1366
1367 #[test]
1368 fn arc_newest_comment_wins() {
1369 let body_approve = "## Automated Review \u{2014} Approve\n\nLooks good.";
1370 let body_changes = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
1371 let comments = vec![
1372 make_comment(1, "bot", body_approve, "2026-01-01T00:00:00Z"),
1373 make_comment(2, "bot", body_changes, "2026-01-02T00:00:00Z"),
1374 ];
1375 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1376 assert_eq!(
1378 automated_review_from_comments(&comments, &cache, 1),
1379 Some("changes_requested".to_string())
1380 );
1381 }
1382
1383 #[test]
1384 fn arc_automated_review_header_without_known_verdict_returns_none() {
1385 let body = "## Automated Review — Unknown\n\nSome text.";
1387 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1388 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1389 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1390 }
1391
1392 #[tokio::test]
1395 async fn review_open_prs_empty_tasks_returns_ok_without_info_log() {
1396 let store = make_store().await;
1397 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1398 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1399 Arc::clone(&backend),
1400 Arc::clone(&store),
1401 "owner/repo".to_string(),
1402 ));
1403 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1404 let in_flight = Arc::new(DashSet::new());
1405 let gh = MockGh::default();
1406
1407 let result = review_open_prs(
1408 &backend,
1409 "owner/repo",
1410 &default_config(),
1411 &tm,
1412 &store,
1413 &dispatching,
1414 &in_flight,
1415 &[],
1416 &gh,
1417 &crate::engine::router::RouterConfig::default(),
1418 )
1419 .await;
1420
1421 assert!(result.is_ok());
1422 }
1423
1424 #[tokio::test]
1427 async fn review_open_prs_dispatching_guard_skips_task() {
1428 let store = make_store().await;
1429 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1430 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1431 Arc::clone(&backend),
1432 Arc::clone(&store),
1433 "owner/repo".to_string(),
1434 ));
1435
1436 let task_store_id = store
1438 .create(&NewTask {
1439 repo: "owner/repo".to_string(),
1440 origin: "github".to_string(),
1441 title: "task1".to_string(),
1442 ..Default::default()
1443 })
1444 .await
1445 .unwrap();
1446 let _ = crate::store::store_set_by_id(
1447 &Some(&store),
1448 task_store_id,
1449 &[("branch", serde_json::json!("my-branch"))],
1450 )
1451 .await;
1452 store
1453 .update_status(task_store_id, TaskStatus::InReview)
1454 .await
1455 .unwrap();
1456 let stored = store.get(task_store_id).await.unwrap();
1457 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1458 external: crate::engine::tasks::store_task_to_external(&stored),
1459 stored,
1460 };
1461
1462 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1464 dispatching.insert(
1465 format!("owner/repo/{}", snapshot.external.id.0),
1466 snapshot.external.id.0.clone(),
1467 );
1468
1469 let mut gh = MockGh::default();
1471 gh.pr_numbers.insert("my-branch".to_string(), Some(42));
1472 let in_flight = Arc::new(DashSet::new());
1477 let result = review_open_prs(
1478 &backend,
1479 "owner/repo",
1480 &default_config(),
1481 &tm,
1482 &store,
1483 &dispatching,
1484 &in_flight,
1485 &[snapshot],
1486 &gh,
1487 &crate::engine::router::RouterConfig::default(),
1488 )
1489 .await;
1490
1491 assert!(result.is_ok());
1492 let after = store.get(task_store_id).await.unwrap();
1494 assert_eq!(after.status, TaskStatus::InReview);
1495 }
1496
1497 #[tokio::test]
1500 async fn review_open_prs_no_pr_merged_marks_done() {
1501 let store = make_store().await;
1502 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1503 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1504 Arc::clone(&backend),
1505 Arc::clone(&store),
1506 "owner/repo".to_string(),
1507 ));
1508
1509 let task_id = store
1510 .create(&NewTask {
1511 repo: "owner/repo".to_string(),
1512 origin: "github".to_string(),
1513 title: "task1".to_string(),
1514 ..Default::default()
1515 })
1516 .await
1517 .unwrap();
1518 let _ = crate::store::store_set_by_id(
1519 &Some(&store),
1520 task_id,
1521 &[("branch", serde_json::json!("feat-branch"))],
1522 )
1523 .await;
1524 store
1525 .update_status(task_id, TaskStatus::InReview)
1526 .await
1527 .unwrap();
1528 let stored = store.get(task_id).await.unwrap();
1529 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1530 external: crate::engine::tasks::store_task_to_external(&stored),
1531 stored,
1532 };
1533
1534 let mut gh = MockGh::default();
1537 gh.merged.insert("feat-branch".to_string(), true);
1538
1539 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1540 let in_flight = Arc::new(DashSet::new());
1541 review_open_prs(
1542 &backend,
1543 "owner/repo",
1544 &default_config(),
1545 &tm,
1546 &store,
1547 &dispatching,
1548 &in_flight,
1549 &[snapshot],
1550 &gh,
1551 &crate::engine::router::RouterConfig::default(),
1552 )
1553 .await
1554 .unwrap();
1555
1556 let after = store.get(task_id).await.unwrap();
1557 assert_eq!(after.status, TaskStatus::Done);
1558 }
1559
1560 #[tokio::test]
1563 async fn review_open_prs_no_pr_not_merged_reroutes() {
1564 let store = make_store().await;
1565 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1566 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1567 Arc::clone(&backend),
1568 Arc::clone(&store),
1569 "owner/repo".to_string(),
1570 ));
1571
1572 let task_id = store
1573 .create(&NewTask {
1574 repo: "owner/repo".to_string(),
1575 origin: "github".to_string(),
1576 title: "task2".to_string(),
1577 ..Default::default()
1578 })
1579 .await
1580 .unwrap();
1581 let _ = crate::store::store_set_by_id(
1582 &Some(&store),
1583 task_id,
1584 &[("branch", serde_json::json!("feat-branch"))],
1585 )
1586 .await;
1587 store
1588 .update_status(task_id, TaskStatus::InReview)
1589 .await
1590 .unwrap();
1591 let stored = store.get(task_id).await.unwrap();
1592 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1593 external: crate::engine::tasks::store_task_to_external(&stored),
1594 stored,
1595 };
1596
1597 let gh = MockGh::default(); let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1601 let in_flight = Arc::new(DashSet::new());
1602 review_open_prs(
1603 &backend,
1604 "owner/repo",
1605 &default_config(),
1606 &tm,
1607 &store,
1608 &dispatching,
1609 &in_flight,
1610 &[snapshot],
1611 &gh,
1612 &crate::engine::router::RouterConfig::default(),
1613 )
1614 .await
1615 .unwrap();
1616
1617 let after = store.get(task_id).await.unwrap();
1618 assert_eq!(after.status, TaskStatus::Routed);
1619 assert_eq!(after.no_code_reroutes, 1);
1620 }
1621
1622 #[tokio::test]
1625 async fn review_open_prs_no_pr_max_reroutes_blocks_task() {
1626 let store = make_store().await;
1627 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1628 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1629 Arc::clone(&backend),
1630 Arc::clone(&store),
1631 "owner/repo".to_string(),
1632 ));
1633
1634 let task_id = store
1635 .create(&NewTask {
1636 repo: "owner/repo".to_string(),
1637 origin: "github".to_string(),
1638 title: "task3".to_string(),
1639 ..Default::default()
1640 })
1641 .await
1642 .unwrap();
1643 let _ = crate::store::store_set_by_id(
1644 &Some(&store),
1645 task_id,
1646 &[("branch", serde_json::json!("feat-branch"))],
1647 )
1648 .await;
1649 store
1650 .update_status(task_id, TaskStatus::InReview)
1651 .await
1652 .unwrap();
1653
1654 let _ = crate::store::store_set_by_id(
1657 &Some(&store),
1658 task_id,
1659 &[("no_code_reroutes", serde_json::json!(99i64))],
1660 )
1661 .await;
1662
1663 let stored = store.get(task_id).await.unwrap();
1664 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1665 external: crate::engine::tasks::store_task_to_external(&stored),
1666 stored,
1667 };
1668
1669 let gh = MockGh::default();
1670 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1671 let in_flight = Arc::new(DashSet::new());
1672 review_open_prs(
1673 &backend,
1674 "owner/repo",
1675 &default_config(),
1676 &tm,
1677 &store,
1678 &dispatching,
1679 &in_flight,
1680 &[snapshot],
1681 &gh,
1682 &crate::engine::router::RouterConfig::default(),
1683 )
1684 .await
1685 .unwrap();
1686
1687 let after = store.get(task_id).await.unwrap();
1688 assert_eq!(after.status, TaskStatus::Blocked);
1689 assert!(
1690 after.block_reason.is_some(),
1691 "block_reason must be set atomically with Blocked status"
1692 );
1693 }
1694
1695 #[tokio::test]
1698 async fn review_open_prs_transient_error_in_is_pr_merged_skips_task() {
1699 struct TransientGh;
1700
1701 #[async_trait]
1702 impl GhReviewClient for TransientGh {
1703 async fn get_pr_number(
1704 &self,
1705 _repo: &str,
1706 _branch: &str,
1707 ) -> anyhow::Result<Option<u64>> {
1708 Ok(None)
1709 }
1710 async fn is_pr_merged(&self, _repo: &str, _branch: &str) -> anyhow::Result<bool> {
1711 anyhow::bail!("500 Internal Server Error (transient GitHub 5xx)")
1712 }
1713 async fn batch_fetch_pr_review_data(
1714 &self,
1715 _repo: &str,
1716 _pr_numbers: &[u64],
1717 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
1718 Ok(HashMap::new())
1719 }
1720 async fn is_collaborator(&self, _repo: &str, _username: &str) -> anyhow::Result<bool> {
1721 Ok(false)
1722 }
1723 }
1724
1725 let store = make_store().await;
1726 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1727 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1728 Arc::clone(&backend),
1729 Arc::clone(&store),
1730 "owner/repo".to_string(),
1731 ));
1732
1733 let task_id = store
1734 .create(&NewTask {
1735 repo: "owner/repo".to_string(),
1736 origin: "github".to_string(),
1737 title: "task4".to_string(),
1738 ..Default::default()
1739 })
1740 .await
1741 .unwrap();
1742 let _ = crate::store::store_set_by_id(
1743 &Some(&store),
1744 task_id,
1745 &[("branch", serde_json::json!("feat-branch"))],
1746 )
1747 .await;
1748 store
1749 .update_status(task_id, TaskStatus::InReview)
1750 .await
1751 .unwrap();
1752 let stored = store.get(task_id).await.unwrap();
1753 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1754 external: crate::engine::tasks::store_task_to_external(&stored),
1755 stored,
1756 };
1757
1758 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1759 let in_flight = Arc::new(DashSet::new());
1760 review_open_prs(
1761 &backend,
1762 "owner/repo",
1763 &default_config(),
1764 &tm,
1765 &store,
1766 &dispatching,
1767 &in_flight,
1768 &[snapshot],
1769 &TransientGh,
1770 &crate::engine::router::RouterConfig::default(),
1771 )
1772 .await
1773 .unwrap();
1774
1775 let after = store.get(task_id).await.unwrap();
1777 assert_eq!(after.status, TaskStatus::InReview);
1778 assert_eq!(after.no_code_reroutes, 0);
1779 }
1780
1781 #[tokio::test]
1784 async fn review_open_prs_batch_fetch_failure_returns_ok() {
1785 let store = make_store().await;
1786 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1787 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1788 Arc::clone(&backend),
1789 Arc::clone(&store),
1790 "owner/repo".to_string(),
1791 ));
1792
1793 let task_id = store
1794 .create(&NewTask {
1795 repo: "owner/repo".to_string(),
1796 origin: "github".to_string(),
1797 title: "task5".to_string(),
1798 ..Default::default()
1799 })
1800 .await
1801 .unwrap();
1802 let _ = crate::store::store_set_by_id(
1803 &Some(&store),
1804 task_id,
1805 &[("branch", serde_json::json!("feat-branch"))],
1806 )
1807 .await;
1808 let _ = crate::store::store_set_by_id(
1810 &Some(&store),
1811 task_id,
1812 &[("pr_number", serde_json::json!(99i64))],
1813 )
1814 .await;
1815 store
1816 .update_status(task_id, TaskStatus::InReview)
1817 .await
1818 .unwrap();
1819 let stored = store.get(task_id).await.unwrap();
1820 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1821 external: crate::engine::tasks::store_task_to_external(&stored),
1822 stored,
1823 };
1824
1825 let gh = MockGh {
1826 batch_error: true,
1827 ..Default::default()
1828 };
1829 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1830 let in_flight = Arc::new(DashSet::new());
1831 let result = review_open_prs(
1832 &backend,
1833 "owner/repo",
1834 &default_config(),
1835 &tm,
1836 &store,
1837 &dispatching,
1838 &in_flight,
1839 &[snapshot],
1840 &gh,
1841 &crate::engine::router::RouterConfig::default(),
1842 )
1843 .await;
1844
1845 assert!(result.is_ok());
1847 let after = store.get(task_id).await.unwrap();
1849 assert_eq!(after.status, TaskStatus::InReview);
1850 }
1851
1852 #[tokio::test]
1853 async fn review_open_prs_conflict_with_max_attempts_blocks_instead_of_reroute() {
1854 let store = make_store().await;
1855 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1856 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1857 Arc::clone(&backend),
1858 Arc::clone(&store),
1859 "owner/repo".to_string(),
1860 ));
1861
1862 let task_id = store
1863 .create(&NewTask {
1864 repo: "owner/repo".to_string(),
1865 origin: "github".to_string(),
1866 title: "task-conflict".to_string(),
1867 ..Default::default()
1868 })
1869 .await
1870 .unwrap();
1871 let _ = crate::store::store_set_by_id(
1872 &Some(&store),
1873 task_id,
1874 &[
1875 ("branch", serde_json::json!("feat-conflict")),
1876 ("pr_number", serde_json::json!(77i64)),
1877 ("attempts", serde_json::json!(99i64)),
1878 ],
1879 )
1880 .await;
1881 store
1882 .update_status(task_id, TaskStatus::InReview)
1883 .await
1884 .unwrap();
1885 let stored = store.get(task_id).await.unwrap();
1886 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1887 external: crate::engine::tasks::store_task_to_external(&stored),
1888 stored,
1889 };
1890
1891 let mut gh = MockGh::default();
1892 gh.batch_data.insert(
1893 77,
1894 PrReviewBatchData {
1895 merged: false,
1896 mergeable: Some(false),
1897 reviews: vec![make_review(
1898 1,
1899 "reviewer",
1900 "APPROVED",
1901 "2026-01-01T00:00:00Z",
1902 None,
1903 )],
1904 review_comments: vec![],
1905 issue_comments: vec![],
1906 },
1907 );
1908
1909 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1910 let in_flight = Arc::new(DashSet::new());
1911 review_open_prs(
1912 &backend,
1913 "owner/repo",
1914 &default_config(),
1915 &tm,
1916 &store,
1917 &dispatching,
1918 &in_flight,
1919 &[snapshot],
1920 &gh,
1921 &crate::engine::router::RouterConfig::default(),
1922 )
1923 .await
1924 .unwrap();
1925
1926 let after = store.get(task_id).await.unwrap();
1927 assert_eq!(after.status, TaskStatus::Blocked);
1928 assert_eq!(
1929 after.merge_conflict_retries, 0,
1930 "review_poll must not increment merge_conflict_retries when reroute is impossible"
1931 );
1932 assert!(
1933 after
1934 .block_reason
1935 .as_deref()
1936 .unwrap_or("")
1937 .contains("max attempts"),
1938 "block_reason should explain that reroute was refused after max attempts"
1939 );
1940 }
1941
1942 #[test]
1943 fn reset_batch_fail_counter_delete_failure_falls_back_to_zero_and_logs_warning() {
1944 let key = "review_batch_fail:owner/repo";
1945 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1946 let store = MockBatchFailStore {
1947 delete_error: Some("simulated delete failure".to_string()),
1948 set_error: None,
1949 set_calls: Arc::clone(&calls),
1950 };
1951
1952 let logs = with_captured_warn_logs(|| {
1953 let rt = tokio::runtime::Builder::new_current_thread()
1954 .enable_all()
1955 .build()
1956 .unwrap();
1957 let ok = rt.block_on(reset_batch_fail_counter(&store, key));
1958 assert!(ok, "fallback zero-reset should succeed");
1959 });
1960
1961 let set_calls = calls.lock().unwrap();
1962 assert_eq!(set_calls.len(), 1);
1963 assert_eq!(set_calls[0], (key.to_string(), "0".to_string()));
1964 assert!(
1965 logs.contains("falling back to zero reset"),
1966 "expected warning log to mention fallback reset, got: {logs}"
1967 );
1968 }
1969
1970 #[test]
1971 fn reset_batch_fail_counter_delete_and_set_failure_logs_error() {
1972 let key = "review_batch_fail:owner/repo";
1973 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1974 let store = MockBatchFailStore {
1975 delete_error: Some("simulated delete failure".to_string()),
1976 set_error: Some("simulated set failure".to_string()),
1977 set_calls: Arc::clone(&calls),
1978 };
1979
1980 let logs = with_captured_warn_logs(|| {
1981 let rt = tokio::runtime::Builder::new_current_thread()
1982 .enable_all()
1983 .build()
1984 .unwrap();
1985 let ok = rt.block_on(reset_batch_fail_counter(&store, key));
1986 assert!(
1987 !ok,
1988 "double failure should be surfaced as unsuccessful reset"
1989 );
1990 });
1991
1992 let set_calls = calls.lock().unwrap();
1993 assert_eq!(set_calls.len(), 1);
1994 assert!(
1995 logs.contains("failed to reset review batch failure counter"),
1996 "expected error log when both delete and set fail, got: {logs}"
1997 );
1998 }
1999
2000 #[tokio::test]
2003 async fn review_open_prs_watermark_dedup_skips_already_processed_review() {
2004 let store = make_store().await;
2005 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
2006 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
2007 Arc::clone(&backend),
2008 Arc::clone(&store),
2009 "owner/repo".to_string(),
2010 ));
2011
2012 let task_id = store
2013 .create(&NewTask {
2014 repo: "owner/repo".to_string(),
2015 origin: "github".to_string(),
2016 title: "task6".to_string(),
2017 ..Default::default()
2018 })
2019 .await
2020 .unwrap();
2021 let ts = "2026-01-05T00:00:00Z";
2023 let _ = crate::store::store_set_by_id(
2024 &Some(&store),
2025 task_id,
2026 &[
2027 ("branch", serde_json::json!("feat-branch")),
2028 ("pr_number", serde_json::json!(10i64)),
2029 (
2030 "review_ts_map",
2031 serde_json::json!(serde_json::json!({"reviewer1": ts}).to_string()),
2032 ),
2033 ],
2034 )
2035 .await;
2036 store
2037 .update_status(task_id, TaskStatus::InReview)
2038 .await
2039 .unwrap();
2040 let stored = store.get(task_id).await.unwrap();
2041 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
2042 external: crate::engine::tasks::store_task_to_external(&stored),
2043 stored,
2044 };
2045
2046 let review = make_review(1, "reviewer1", "CHANGES_REQUESTED", ts, Some("Fix this"));
2048 let batch_data = PrReviewBatchData {
2049 merged: false,
2050 mergeable: Some(true),
2051 reviews: vec![review],
2052 review_comments: vec![],
2053 issue_comments: vec![],
2054 };
2055 let mut gh = MockGh::default();
2056 gh.batch_data.insert(10, batch_data);
2057
2058 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
2059 let in_flight = Arc::new(DashSet::new());
2060 review_open_prs(
2061 &backend,
2062 "owner/repo",
2063 &default_config(),
2064 &tm,
2065 &store,
2066 &dispatching,
2067 &in_flight,
2068 &[snapshot],
2069 &gh,
2070 &crate::engine::router::RouterConfig::default(),
2071 )
2072 .await
2073 .unwrap();
2074
2075 let after = store.get(task_id).await.unwrap();
2077 assert_eq!(after.status, TaskStatus::InReview);
2078 }
2079
2080 #[tokio::test]
2083 async fn review_open_prs_last_comment_review_ts_dedup_skips_old_comment() {
2084 let store = make_store().await;
2085 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
2086 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
2087 Arc::clone(&backend),
2088 Arc::clone(&store),
2089 "owner/repo".to_string(),
2090 ));
2091
2092 let task_id = store
2093 .create(&NewTask {
2094 repo: "owner/repo".to_string(),
2095 origin: "github".to_string(),
2096 title: "task7".to_string(),
2097 ..Default::default()
2098 })
2099 .await
2100 .unwrap();
2101 let _ = crate::store::store_set_by_id(
2102 &Some(&store),
2103 task_id,
2104 &[("branch", serde_json::json!("feat-branch"))],
2105 )
2106 .await;
2107 let _ = crate::store::store_set_by_id(
2108 &Some(&store),
2109 task_id,
2110 &[("pr_number", serde_json::json!(20i64))],
2111 )
2112 .await;
2113 let ts = "2026-01-05T00:00:00Z";
2115 let _ = crate::store::store_set_by_id(
2116 &Some(&store),
2117 task_id,
2118 &[("last_comment_review_ts", serde_json::json!(ts))],
2119 )
2120 .await;
2121 store
2122 .update_status(task_id, TaskStatus::InReview)
2123 .await
2124 .unwrap();
2125 let stored = store.get(task_id).await.unwrap();
2126 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
2127 external: crate::engine::tasks::store_task_to_external(&stored),
2128 stored,
2129 };
2130
2131 let comment_body = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
2132 let comment = make_comment(1, "bot", comment_body, ts);
2133 let batch_data = PrReviewBatchData {
2134 merged: false,
2135 mergeable: Some(true),
2136 reviews: vec![],
2137 review_comments: vec![],
2138 issue_comments: vec![comment],
2139 };
2140 let mut gh = MockGh::default();
2141 gh.batch_data.insert(20, batch_data);
2142 gh.collaborators.insert("bot".to_string(), true);
2143
2144 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
2145 let in_flight = Arc::new(DashSet::new());
2146 review_open_prs(
2147 &backend,
2148 "owner/repo",
2149 &default_config(),
2150 &tm,
2151 &store,
2152 &dispatching,
2153 &in_flight,
2154 &[snapshot],
2155 &gh,
2156 &crate::engine::router::RouterConfig::default(),
2157 )
2158 .await
2159 .unwrap();
2160
2161 let after = store.get(task_id).await.unwrap();
2163 assert_eq!(after.status, TaskStatus::InReview);
2164 }
2165
2166 #[test]
2169 fn review_context_truncation_at_16kb_boundary() {
2170 const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
2172
2173 let repeated = "a".repeat(200);
2175 let mut review_context = String::new();
2176 while review_context.len() <= MAX_REVIEW_CONTEXT_BYTES {
2177 review_context.push_str(&repeated);
2178 review_context.push('\n');
2179 }
2180
2181 assert!(review_context.len() > MAX_REVIEW_CONTEXT_BYTES);
2182
2183 if review_context.len() > MAX_REVIEW_CONTEXT_BYTES {
2185 let mut boundary = MAX_REVIEW_CONTEXT_BYTES;
2186 while !review_context.is_char_boundary(boundary) {
2187 boundary -= 1;
2188 }
2189 if let Some(pos) = review_context[..boundary].rfind('\n') {
2190 review_context.truncate(pos);
2191 } else {
2192 review_context.truncate(boundary);
2193 }
2194 review_context.push_str("\n... (review context truncated)");
2195 }
2196
2197 assert!(review_context.len() <= MAX_REVIEW_CONTEXT_BYTES + 40); assert!(review_context.ends_with("... (review context truncated)"));
2199 assert!(std::str::from_utf8(review_context.as_bytes()).is_ok());
2201 }
2202}