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 stored_task.pr_number.map(|n| n as u64) != Some(pr_number) {
627 if let Err(e) = store_set_result_by_id(
628 &Some(Arc::clone(store)),
629 task_info.store_id,
630 &[("pr_number", serde_json::json!(pr_number as i64))],
631 )
632 .await
633 {
634 tracing::warn!(task_id, err = %e, "failed to persist pr_number");
635 }
636 }
637
638 let batch_data = match batch.get(&pr_number) {
639 Some(d) => d,
640 None => {
641 tracing::warn!(
642 task_id,
643 pr_number,
644 "PR not in batch data, skipping this tick"
645 );
646 continue;
647 }
648 };
649
650 let review_ts_map: serde_json::Value =
651 match serde_json::from_str(&stored_task.review_ts_map) {
652 Ok(serde_json::Value::Object(m)) => serde_json::Value::Object(m),
653 _ => serde_json::json!({}),
654 };
655
656 let reviews = &batch_data.reviews;
657 let all_comments = &batch_data.review_comments;
658
659 let deduped_reviews = dedup_reviews(reviews);
661
662 let any_changes_requested = deduped_reviews
663 .values()
664 .any(|r| r.state == "CHANGES_REQUESTED");
665 let all_approved =
666 !deduped_reviews.is_empty() && deduped_reviews.values().all(|r| r.state == "APPROVED");
667
668 let automated_review =
670 automated_review_from_comments(&batch_data.issue_comments, &collab_cache, pr_number);
671 let comment_approved = automated_review.as_deref() == Some("approve");
672 let comment_changes_requested = automated_review.as_deref() == Some("changes_requested");
673
674 if (all_approved || comment_approved)
677 && !comment_changes_requested
678 && !any_changes_requested
679 {
680 let already_merged = batch_data.merged;
681
682 if already_merged {
683 tracing::info!(
684 task_id,
685 pr_number,
686 "PR already merged, marking task as done"
687 );
688 if let Err(e) = task_manager
689 .update_task_status(&task.id, Status::Done)
690 .await
691 {
692 tracing::warn!(task_id, err = %e, "failed to update task status to done");
693 }
694 } else {
695 let is_conflicting = batch_data.mergeable == Some(false);
696
697 if is_conflicting {
698 let retries = stored_task.merge_conflict_retries.max(0) as u64;
699 match handle_merge_conflict(
700 &task.id,
701 pr_number,
702 retries,
703 stored_task.attempts.max(0) as u64,
704 task_manager,
705 store,
706 task_info.store_id,
707 )
708 .await
709 {
710 ConflictAction::BlockForHuman => continue,
711 ConflictAction::RerouteToAgent => {}
712 }
713 }
714
715 tracing::info!(
716 task_id,
717 pr_number,
718 comment_approved,
719 "PR approved but not yet merged — attempting auto-merge"
720 );
721 let task_agent = stored_task
722 .agent
723 .clone()
724 .unwrap_or_else(|| "orch".to_string());
725 let task_model = stored_task
726 .model
727 .clone()
728 .unwrap_or_else(|| "unknown".to_string());
729
730 if auto_merge_in_flight.contains(task_id) {
734 tracing::debug!(
735 task_id,
736 pr_number,
737 "auto-merge already in flight, skipping spawn"
738 );
739 } else {
740 auto_merge_in_flight.insert(task_id.to_string());
743 let task_clone = task.clone();
744 let branch_clone = task_info.branch.clone();
745 let backend_clone = Arc::clone(backend);
746 let repo_string = repo.to_string();
747 let task_manager_clone = Arc::clone(task_manager);
748 let store_clone = Arc::clone(store);
749 let in_flight_clone = Arc::clone(auto_merge_in_flight);
750
751 tokio::spawn(async move {
752 let _guard = scopeguard::guard((), |_| {
753 in_flight_clone.remove(&task_clone.id.0);
754 });
755
756 if let Err(e) = crate::engine::auto_merge::auto_merge_pr(
757 &task_clone,
758 &branch_clone,
759 &backend_clone,
760 &repo_string,
761 &task_agent,
762 &task_model,
763 &task_manager_clone,
764 &store_clone,
765 )
766 .await
767 {
768 tracing::warn!(
769 task_id = task_clone.id.0,
770 pr_number,
771 err = %e,
772 "auto-merge failed, keeping task in_review for next tick"
773 );
774 }
775 });
776 }
777 }
778 continue;
779 }
780
781 if !any_changes_requested && !comment_changes_requested {
783 continue;
784 }
785
786 let mut review_context = String::new();
788 let mut updated_review_ts_map = review_ts_map.clone();
789
790 for review in deduped_reviews
791 .values()
792 .filter(|r| r.state == "CHANGES_REQUESTED")
793 {
794 let reviewer_login = &review.user.login;
795 let reviewer_last_ts = updated_review_ts_map
796 .get(reviewer_login)
797 .and_then(|v| v.as_str())
798 .unwrap_or_default();
799
800 if !reviewer_last_ts.is_empty() && review.submitted_at.as_str() <= reviewer_last_ts {
802 continue;
803 }
804
805 updated_review_ts_map[reviewer_login] = serde_json::json!(review.submitted_at.clone());
807 let review_comments: Vec<GitHubReviewComment> = all_comments
808 .iter()
809 .filter(|c| {
810 c.user.login == review.user.login && c.created_at >= review.submitted_at
811 })
812 .cloned()
813 .collect();
814
815 let pr_review = PullRequestReview {
816 review: (*review).clone(),
817 comments: review_comments.clone(),
818 };
819
820 review_context.push_str(&format!(
821 "### Review by @{} (CHANGES REQUESTED)\n",
822 pr_review.review.user.login
823 ));
824
825 if let Some(ref body) = pr_review.review.body {
826 if !body.trim().is_empty() {
827 review_context.push_str(&format!("**Overall Feedback:** {}\n\n", body));
828 }
829 }
830
831 let actionable = pr_review.actionable_comments();
832 if !actionable.is_empty() {
833 review_context.push_str("**Comments to address:**\n\n");
834 for comment in actionable {
835 review_context.push_str(&format!(
836 "#### File: `{}` (line {})\n",
837 comment.path,
838 comment.line.map(|l| l.to_string()).unwrap_or_default()
839 ));
840
841 if let Some(ref diff_hunk) = comment.diff_hunk {
842 review_context.push_str("```diff\n");
843 review_context.push_str(diff_hunk);
844 review_context.push_str("\n```\n\n");
845 }
846
847 review_context.push_str(&format!("> {}\n\n", comment.body));
848 }
849 }
850 }
851
852 let mut new_comment_review_ts: Option<String> = None;
857 if comment_changes_requested {
858 let last_comment_ts = stored_task.last_comment_review_ts.clone();
859
860 let mut newest_match: Option<&crate::github::types::GitHubComment> = None;
864 for c in &batch_data.issue_comments {
865 if !c
866 .body
867 .starts_with("## Automated Review \u{2014} Changes Requested")
868 {
869 continue;
870 }
871 if !last_comment_ts.is_empty() && c.created_at <= last_comment_ts {
872 continue;
873 }
874 match newest_match {
875 None => newest_match = Some(c),
876 Some(prev) => {
877 if c.created_at > prev.created_at {
878 newest_match = Some(c);
879 }
880 }
881 }
882 }
883
884 if let Some(c) = newest_match {
885 let body: String = c.body.lines().skip(1).collect::<Vec<_>>().join("\n");
886 review_context.push_str("### Automated Review (Changes Requested)\n\n");
887 review_context.push_str(&body);
888 review_context.push('\n');
889
890 new_comment_review_ts = Some(c.created_at.clone());
892 }
893 }
894
895 const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
897 if review_context.len() > MAX_REVIEW_CONTEXT_BYTES {
898 let mut boundary = MAX_REVIEW_CONTEXT_BYTES;
899 while !review_context.is_char_boundary(boundary) {
900 boundary -= 1;
901 }
902 if let Some(pos) = review_context[..boundary].rfind('\n') {
903 review_context.truncate(pos);
904 } else {
905 review_context.truncate(boundary);
906 }
907 review_context.push_str("\n... (review context truncated)");
908 }
909
910 if !review_context.is_empty() {
912 let task_agent = stored_task
913 .agent
914 .clone()
915 .unwrap_or_else(|| "orch".to_string());
916 let task_model = stored_task
917 .model
918 .clone()
919 .unwrap_or_else(|| "unknown".to_string());
920 let review_cycles = stored_task.review_cycles.max(0) as u32;
921 let max_cycles: u32 = crate::config::get("workflow.max_review_cycles")
922 .ok()
923 .and_then(|v| v.parse().ok())
924 .unwrap_or(2);
925
926 if let Err(e) = handle_review_changes(
934 task,
935 &review_context,
936 &[],
937 backend,
938 repo,
939 pr_number,
940 &task_agent,
941 &task_model,
942 task_manager,
943 store,
944 router_config,
945 )
946 .await
947 {
948 tracing::warn!(task_id, err = %e, "failed to handle review feedback — will retry next tick");
949 continue;
950 }
951
952 let mut fields: Vec<(&str, serde_json::Value)> =
954 vec![("review_ts_map", serde_json::json!(updated_review_ts_map))];
955 if let Some(ref ts) = new_comment_review_ts {
956 fields.push(("last_comment_review_ts", serde_json::json!(ts)));
957 }
958 let mut save_ok = false;
960 for _ in 0..3 {
961 if store_set_result_by_id(&Some(Arc::clone(store)), task_info.store_id, &fields)
962 .await
963 .is_ok()
964 {
965 save_ok = true;
966 break;
967 }
968 tokio::time::sleep(Duration::from_millis(200)).await;
969 }
970 if !save_ok {
971 tracing::error!(
972 task_id,
973 "failed to persist review watermark after retries — next tick will re-dispatch same review"
974 );
975 continue;
976 }
977
978 if review_cycles < max_cycles {
979 tracing::info!(task_id, "re-dispatching task to address review feedback");
980 store_reset_failure_counters(&Some(Arc::clone(store)), repo, task_id).await;
981 }
982 }
983 }
984
985 Ok(())
986}
987
988fn automated_review_from_comments(
994 issue_comments: &[GitHubComment],
995 collab_cache: &HashMap<String, Option<bool>>,
996 pr_number: u64,
997) -> Option<String> {
998 let mut newest: Option<&GitHubComment> = None;
1000 for c in issue_comments {
1001 if !c.body.starts_with("## Automated Review") {
1002 continue;
1003 }
1004 match collab_cache.get(&c.user.login).copied().flatten() {
1005 None if collab_cache.contains_key(&c.user.login) => {
1006 tracing::warn!(
1007 user = %c.user.login,
1008 pr_number,
1009 "skipping automated review comment: collaborator check unavailable"
1010 );
1011 continue;
1012 }
1013 None | Some(false) => {
1014 tracing::warn!(
1015 user = %c.user.login,
1016 pr_number,
1017 "ignoring automated review comment from non-collaborator"
1018 );
1019 continue;
1020 }
1021 Some(true) => {}
1022 }
1023 match newest {
1024 None => newest = Some(c),
1025 Some(prev) => {
1026 if c.created_at > prev.created_at {
1027 newest = Some(c);
1028 }
1029 }
1030 }
1031 }
1032
1033 if let Some(c) = newest {
1034 let first_line = c.body.lines().next().unwrap_or("");
1035 if first_line.contains("Automated Review \u{2014} Approve") {
1036 Some("approve".to_string())
1037 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
1038 Some("changes_requested".to_string())
1039 } else {
1040 None
1041 }
1042 } else {
1043 None
1044 }
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050 use crate::backends::{ExternalId, ExternalTask, Mention};
1051 use crate::engine::EngineConfig;
1052 use crate::github::http::PrReviewBatchData;
1053 use crate::github::types::{GitHubComment, GitHubReview, GitHubUser};
1054 use crate::store::{NewTask, TaskStatus, TaskStore};
1055 use async_trait::async_trait;
1056 use dashmap::{DashMap, DashSet};
1057 use std::collections::HashMap;
1058 use std::sync::{Arc, Mutex};
1059 use tracing::Level;
1060 use tracing_subscriber::EnvFilter;
1061
1062 #[derive(Default)]
1065 struct MockGh {
1066 pr_numbers: HashMap<String, Option<u64>>,
1068 merged: HashMap<String, bool>,
1070 batch_data: HashMap<u64, PrReviewBatchData>,
1072 batch_error: bool,
1074 collaborators: HashMap<String, bool>,
1076 }
1077
1078 #[async_trait]
1079 impl GhReviewClient for MockGh {
1080 async fn get_pr_number(&self, _repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1081 Ok(self.pr_numbers.get(branch).copied().flatten())
1082 }
1083 async fn is_pr_merged(&self, _repo: &str, branch: &str) -> anyhow::Result<bool> {
1084 Ok(self.merged.get(branch).copied().unwrap_or(false))
1085 }
1086 async fn batch_fetch_pr_review_data(
1087 &self,
1088 _repo: &str,
1089 _pr_numbers: &[u64],
1090 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
1091 if self.batch_error {
1092 anyhow::bail!("simulated batch GraphQL error");
1093 }
1094 Ok(self.batch_data.clone())
1095 }
1096 async fn is_collaborator(&self, _repo: &str, username: &str) -> anyhow::Result<bool> {
1097 Ok(self.collaborators.get(username).copied().unwrap_or(false))
1098 }
1099 }
1100
1101 struct MockBackend;
1104
1105 #[async_trait]
1106 impl crate::backends::ExternalBackend for MockBackend {
1107 fn name(&self) -> &str {
1108 "mock"
1109 }
1110 async fn create_task(
1111 &self,
1112 _t: &str,
1113 _b: &str,
1114 _l: &[String],
1115 ) -> anyhow::Result<ExternalId> {
1116 Ok(ExternalId("1".to_string()))
1117 }
1118 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
1119 Ok(make_ext_task(&id.0))
1120 }
1121 async fn list_by_status(
1122 &self,
1123 _s: crate::backends::Status,
1124 ) -> anyhow::Result<Vec<ExternalTask>> {
1125 Ok(vec![])
1126 }
1127 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1128 Ok(vec![])
1129 }
1130 async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1131 Ok(())
1132 }
1133 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1134 Ok(())
1135 }
1136 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1137 Ok(())
1138 }
1139 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1140 Ok(vec![])
1141 }
1142 async fn create_sub_task(
1143 &self,
1144 _p: &ExternalId,
1145 _t: &str,
1146 _b: &str,
1147 _l: &[String],
1148 ) -> anyhow::Result<ExternalId> {
1149 Ok(ExternalId("child".to_string()))
1150 }
1151 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1152 Ok(())
1153 }
1154 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1155 Ok(false)
1156 }
1157 async fn health_check(&self) -> anyhow::Result<()> {
1158 Ok(())
1159 }
1160 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1161 Ok(false)
1162 }
1163 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1164 Ok(Some("bot".to_string()))
1165 }
1166 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1167 Ok(vec![])
1168 }
1169 async fn update_status(
1170 &self,
1171 _id: &ExternalId,
1172 _s: crate::backends::Status,
1173 ) -> anyhow::Result<()> {
1174 Ok(())
1175 }
1176 }
1177
1178 fn make_ext_task(id: &str) -> ExternalTask {
1181 ExternalTask {
1182 id: ExternalId(id.to_string()),
1183 title: "test task".to_string(),
1184 body: "".to_string(),
1185 state: "open".to_string(),
1186 labels: vec![],
1187 author: "user".to_string(),
1188 created_at: "2026-01-01T00:00:00Z".to_string(),
1189 updated_at: "2026-01-01T00:00:00Z".to_string(),
1190 url: "".to_string(),
1191 }
1192 }
1193
1194 fn make_comment(id: u64, login: &str, body: &str, created_at: &str) -> GitHubComment {
1195 GitHubComment {
1196 id,
1197 body: body.to_string(),
1198 user: GitHubUser {
1199 login: login.to_string(),
1200 },
1201 created_at: created_at.to_string(),
1202 updated_at: None,
1203 html_url: None,
1204 issue_url: None,
1205 author_association: None,
1206 }
1207 }
1208
1209 fn make_review(
1210 id: u64,
1211 login: &str,
1212 state: &str,
1213 submitted_at: &str,
1214 body: Option<&str>,
1215 ) -> GitHubReview {
1216 GitHubReview {
1217 id,
1218 user: GitHubUser {
1219 login: login.to_string(),
1220 },
1221 body: body.map(str::to_string),
1222 state: state.to_string(),
1223 html_url: None,
1224 submitted_at: submitted_at.to_string(),
1225 commit_id: None,
1226 }
1227 }
1228
1229 async fn make_store() -> Arc<TaskStore> {
1230 Arc::new(TaskStore::open_memory().await.unwrap())
1231 }
1232
1233 fn default_config() -> EngineConfig {
1234 EngineConfig::default()
1235 }
1236
1237 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1238 impl std::io::Write for CaptureWriter {
1239 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1240 self.0.lock().unwrap().extend_from_slice(buf);
1241 Ok(buf.len())
1242 }
1243
1244 fn flush(&mut self) -> std::io::Result<()> {
1245 Ok(())
1246 }
1247 }
1248
1249 impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
1250 type Writer = CaptureWriter;
1251 fn make_writer(&'a self) -> Self::Writer {
1252 CaptureWriter(self.0.clone())
1253 }
1254 }
1255
1256 fn with_captured_warn_logs<F>(f: F) -> String
1257 where
1258 F: FnOnce(),
1259 {
1260 let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1261 let subscriber = tracing_subscriber::fmt()
1262 .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
1263 .with_writer(CaptureWriter(Arc::clone(&output)))
1264 .with_ansi(false)
1265 .finish();
1266 let guard = tracing::subscriber::set_default(subscriber);
1267 f();
1268 drop(guard);
1269 let captured = String::from_utf8_lossy(&output.lock().unwrap()).to_string();
1270 captured
1271 }
1272
1273 #[derive(Clone)]
1274 struct MockBatchFailStore {
1275 delete_error: Option<String>,
1276 set_error: Option<String>,
1277 set_calls: Arc<Mutex<Vec<(String, String)>>>,
1278 }
1279
1280 #[async_trait]
1281 impl ReviewBatchFailCounterStore for MockBatchFailStore {
1282 async fn kv_delete(&self, _key: &str) -> anyhow::Result<()> {
1283 if let Some(msg) = &self.delete_error {
1284 anyhow::bail!("{msg}");
1285 }
1286 Ok(())
1287 }
1288
1289 async fn kv_set(&self, key: &str, value: &str) -> anyhow::Result<()> {
1290 self.set_calls
1291 .lock()
1292 .unwrap()
1293 .push((key.to_string(), value.to_string()));
1294 if let Some(msg) = &self.set_error {
1295 anyhow::bail!("{msg}");
1296 }
1297 Ok(())
1298 }
1299 }
1300
1301 #[test]
1304 fn arc_empty_input_returns_none() {
1305 let result = automated_review_from_comments(&[], &HashMap::new(), 1);
1306 assert_eq!(result, None);
1307 }
1308
1309 #[test]
1310 fn arc_non_automated_comment_ignored() {
1311 let comments = vec![make_comment(
1312 1,
1313 "alice",
1314 "Great work!",
1315 "2026-01-01T00:00:00Z",
1316 )];
1317 let cache = HashMap::from([("alice".to_string(), Some(true))]);
1318 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1319 }
1320
1321 #[test]
1322 fn arc_collaborator_approve_returns_approve() {
1323 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1324 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1325 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1326 assert_eq!(
1327 automated_review_from_comments(&comments, &cache, 1),
1328 Some("approve".to_string())
1329 );
1330 }
1331
1332 #[test]
1333 fn arc_collaborator_changes_requested_returns_changes_requested() {
1334 let body = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
1335 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1336 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1337 assert_eq!(
1338 automated_review_from_comments(&comments, &cache, 1),
1339 Some("changes_requested".to_string())
1340 );
1341 }
1342
1343 #[test]
1344 fn arc_non_collaborator_comment_ignored() {
1345 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1346 let comments = vec![make_comment(1, "outsider", body, "2026-01-01T00:00:00Z")];
1347 let cache = HashMap::from([("outsider".to_string(), Some(false))]);
1348 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1349 }
1350
1351 #[test]
1352 fn arc_unknown_user_not_in_cache_is_ignored() {
1353 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1355 let comments = vec![make_comment(1, "unknown", body, "2026-01-01T00:00:00Z")];
1356 let cache: HashMap<String, Option<bool>> = HashMap::new();
1357 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1358 }
1359
1360 #[test]
1361 fn arc_collab_check_failed_none_in_cache_skipped() {
1362 let body = "## Automated Review \u{2014} Approve\n\nLooks good.";
1364 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1365 let cache: HashMap<String, Option<bool>> = HashMap::from([("bot".to_string(), None)]);
1366 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1367 }
1368
1369 #[test]
1370 fn arc_newest_comment_wins() {
1371 let body_approve = "## Automated Review \u{2014} Approve\n\nLooks good.";
1372 let body_changes = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
1373 let comments = vec![
1374 make_comment(1, "bot", body_approve, "2026-01-01T00:00:00Z"),
1375 make_comment(2, "bot", body_changes, "2026-01-02T00:00:00Z"),
1376 ];
1377 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1378 assert_eq!(
1380 automated_review_from_comments(&comments, &cache, 1),
1381 Some("changes_requested".to_string())
1382 );
1383 }
1384
1385 #[test]
1386 fn arc_automated_review_header_without_known_verdict_returns_none() {
1387 let body = "## Automated Review — Unknown\n\nSome text.";
1389 let comments = vec![make_comment(1, "bot", body, "2026-01-01T00:00:00Z")];
1390 let cache = HashMap::from([("bot".to_string(), Some(true))]);
1391 assert_eq!(automated_review_from_comments(&comments, &cache, 1), None);
1392 }
1393
1394 #[tokio::test]
1397 async fn review_open_prs_empty_tasks_returns_ok_without_info_log() {
1398 let store = make_store().await;
1399 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1400 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1401 Arc::clone(&backend),
1402 Arc::clone(&store),
1403 "owner/repo".to_string(),
1404 ));
1405 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1406 let in_flight = Arc::new(DashSet::new());
1407 let gh = MockGh::default();
1408
1409 let result = review_open_prs(
1410 &backend,
1411 "owner/repo",
1412 &default_config(),
1413 &tm,
1414 &store,
1415 &dispatching,
1416 &in_flight,
1417 &[],
1418 &gh,
1419 &crate::engine::router::RouterConfig::default(),
1420 )
1421 .await;
1422
1423 assert!(result.is_ok());
1424 }
1425
1426 #[tokio::test]
1429 async fn review_open_prs_dispatching_guard_skips_task() {
1430 let store = make_store().await;
1431 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1432 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1433 Arc::clone(&backend),
1434 Arc::clone(&store),
1435 "owner/repo".to_string(),
1436 ));
1437
1438 let task_store_id = store
1440 .create(&NewTask {
1441 repo: "owner/repo".to_string(),
1442 origin: "github".to_string(),
1443 title: "task1".to_string(),
1444 ..Default::default()
1445 })
1446 .await
1447 .unwrap();
1448 let _ = crate::store::store_set_by_id(
1449 &Some(&store),
1450 task_store_id,
1451 &[("branch", serde_json::json!("my-branch"))],
1452 )
1453 .await;
1454 store
1455 .update_status(task_store_id, TaskStatus::InReview)
1456 .await
1457 .unwrap();
1458 let stored = store.get(task_store_id).await.unwrap();
1459 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1460 external: crate::engine::tasks::store_task_to_external(&stored),
1461 stored,
1462 };
1463
1464 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1466 dispatching.insert(
1467 format!("owner/repo/{}", snapshot.external.id.0),
1468 snapshot.external.id.0.clone(),
1469 );
1470
1471 let mut gh = MockGh::default();
1473 gh.pr_numbers.insert("my-branch".to_string(), Some(42));
1474 let in_flight = Arc::new(DashSet::new());
1479 let result = review_open_prs(
1480 &backend,
1481 "owner/repo",
1482 &default_config(),
1483 &tm,
1484 &store,
1485 &dispatching,
1486 &in_flight,
1487 &[snapshot],
1488 &gh,
1489 &crate::engine::router::RouterConfig::default(),
1490 )
1491 .await;
1492
1493 assert!(result.is_ok());
1494 let after = store.get(task_store_id).await.unwrap();
1496 assert_eq!(after.status, TaskStatus::InReview);
1497 }
1498
1499 #[tokio::test]
1502 async fn review_open_prs_no_pr_merged_marks_done() {
1503 let store = make_store().await;
1504 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1505 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1506 Arc::clone(&backend),
1507 Arc::clone(&store),
1508 "owner/repo".to_string(),
1509 ));
1510
1511 let task_id = store
1512 .create(&NewTask {
1513 repo: "owner/repo".to_string(),
1514 origin: "github".to_string(),
1515 title: "task1".to_string(),
1516 ..Default::default()
1517 })
1518 .await
1519 .unwrap();
1520 let _ = crate::store::store_set_by_id(
1521 &Some(&store),
1522 task_id,
1523 &[("branch", serde_json::json!("feat-branch"))],
1524 )
1525 .await;
1526 store
1527 .update_status(task_id, TaskStatus::InReview)
1528 .await
1529 .unwrap();
1530 let stored = store.get(task_id).await.unwrap();
1531 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1532 external: crate::engine::tasks::store_task_to_external(&stored),
1533 stored,
1534 };
1535
1536 let mut gh = MockGh::default();
1539 gh.merged.insert("feat-branch".to_string(), true);
1540
1541 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1542 let in_flight = Arc::new(DashSet::new());
1543 review_open_prs(
1544 &backend,
1545 "owner/repo",
1546 &default_config(),
1547 &tm,
1548 &store,
1549 &dispatching,
1550 &in_flight,
1551 &[snapshot],
1552 &gh,
1553 &crate::engine::router::RouterConfig::default(),
1554 )
1555 .await
1556 .unwrap();
1557
1558 let after = store.get(task_id).await.unwrap();
1559 assert_eq!(after.status, TaskStatus::Done);
1560 }
1561
1562 #[tokio::test]
1565 async fn review_open_prs_no_pr_not_merged_reroutes() {
1566 let store = make_store().await;
1567 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1568 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1569 Arc::clone(&backend),
1570 Arc::clone(&store),
1571 "owner/repo".to_string(),
1572 ));
1573
1574 let task_id = store
1575 .create(&NewTask {
1576 repo: "owner/repo".to_string(),
1577 origin: "github".to_string(),
1578 title: "task2".to_string(),
1579 ..Default::default()
1580 })
1581 .await
1582 .unwrap();
1583 let _ = crate::store::store_set_by_id(
1584 &Some(&store),
1585 task_id,
1586 &[("branch", serde_json::json!("feat-branch"))],
1587 )
1588 .await;
1589 store
1590 .update_status(task_id, TaskStatus::InReview)
1591 .await
1592 .unwrap();
1593 let stored = store.get(task_id).await.unwrap();
1594 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1595 external: crate::engine::tasks::store_task_to_external(&stored),
1596 stored,
1597 };
1598
1599 let gh = MockGh::default(); let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1603 let in_flight = Arc::new(DashSet::new());
1604 review_open_prs(
1605 &backend,
1606 "owner/repo",
1607 &default_config(),
1608 &tm,
1609 &store,
1610 &dispatching,
1611 &in_flight,
1612 &[snapshot],
1613 &gh,
1614 &crate::engine::router::RouterConfig::default(),
1615 )
1616 .await
1617 .unwrap();
1618
1619 let after = store.get(task_id).await.unwrap();
1620 assert_eq!(after.status, TaskStatus::Routed);
1621 assert_eq!(after.no_code_reroutes, 1);
1622 }
1623
1624 #[tokio::test]
1627 async fn review_open_prs_no_pr_max_reroutes_blocks_task() {
1628 let store = make_store().await;
1629 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1630 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1631 Arc::clone(&backend),
1632 Arc::clone(&store),
1633 "owner/repo".to_string(),
1634 ));
1635
1636 let task_id = store
1637 .create(&NewTask {
1638 repo: "owner/repo".to_string(),
1639 origin: "github".to_string(),
1640 title: "task3".to_string(),
1641 ..Default::default()
1642 })
1643 .await
1644 .unwrap();
1645 let _ = crate::store::store_set_by_id(
1646 &Some(&store),
1647 task_id,
1648 &[("branch", serde_json::json!("feat-branch"))],
1649 )
1650 .await;
1651 store
1652 .update_status(task_id, TaskStatus::InReview)
1653 .await
1654 .unwrap();
1655
1656 let _ = crate::store::store_set_by_id(
1659 &Some(&store),
1660 task_id,
1661 &[("no_code_reroutes", serde_json::json!(99i64))],
1662 )
1663 .await;
1664
1665 let stored = store.get(task_id).await.unwrap();
1666 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1667 external: crate::engine::tasks::store_task_to_external(&stored),
1668 stored,
1669 };
1670
1671 let gh = MockGh::default();
1672 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1673 let in_flight = Arc::new(DashSet::new());
1674 review_open_prs(
1675 &backend,
1676 "owner/repo",
1677 &default_config(),
1678 &tm,
1679 &store,
1680 &dispatching,
1681 &in_flight,
1682 &[snapshot],
1683 &gh,
1684 &crate::engine::router::RouterConfig::default(),
1685 )
1686 .await
1687 .unwrap();
1688
1689 let after = store.get(task_id).await.unwrap();
1690 assert_eq!(after.status, TaskStatus::Blocked);
1691 assert!(
1692 after.block_reason.is_some(),
1693 "block_reason must be set atomically with Blocked status"
1694 );
1695 }
1696
1697 #[tokio::test]
1700 async fn review_open_prs_transient_error_in_is_pr_merged_skips_task() {
1701 struct TransientGh;
1702
1703 #[async_trait]
1704 impl GhReviewClient for TransientGh {
1705 async fn get_pr_number(
1706 &self,
1707 _repo: &str,
1708 _branch: &str,
1709 ) -> anyhow::Result<Option<u64>> {
1710 Ok(None)
1711 }
1712 async fn is_pr_merged(&self, _repo: &str, _branch: &str) -> anyhow::Result<bool> {
1713 anyhow::bail!("500 Internal Server Error (transient GitHub 5xx)")
1714 }
1715 async fn batch_fetch_pr_review_data(
1716 &self,
1717 _repo: &str,
1718 _pr_numbers: &[u64],
1719 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
1720 Ok(HashMap::new())
1721 }
1722 async fn is_collaborator(&self, _repo: &str, _username: &str) -> anyhow::Result<bool> {
1723 Ok(false)
1724 }
1725 }
1726
1727 let store = make_store().await;
1728 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1729 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1730 Arc::clone(&backend),
1731 Arc::clone(&store),
1732 "owner/repo".to_string(),
1733 ));
1734
1735 let task_id = store
1736 .create(&NewTask {
1737 repo: "owner/repo".to_string(),
1738 origin: "github".to_string(),
1739 title: "task4".to_string(),
1740 ..Default::default()
1741 })
1742 .await
1743 .unwrap();
1744 let _ = crate::store::store_set_by_id(
1745 &Some(&store),
1746 task_id,
1747 &[("branch", serde_json::json!("feat-branch"))],
1748 )
1749 .await;
1750 store
1751 .update_status(task_id, TaskStatus::InReview)
1752 .await
1753 .unwrap();
1754 let stored = store.get(task_id).await.unwrap();
1755 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1756 external: crate::engine::tasks::store_task_to_external(&stored),
1757 stored,
1758 };
1759
1760 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1761 let in_flight = Arc::new(DashSet::new());
1762 review_open_prs(
1763 &backend,
1764 "owner/repo",
1765 &default_config(),
1766 &tm,
1767 &store,
1768 &dispatching,
1769 &in_flight,
1770 &[snapshot],
1771 &TransientGh,
1772 &crate::engine::router::RouterConfig::default(),
1773 )
1774 .await
1775 .unwrap();
1776
1777 let after = store.get(task_id).await.unwrap();
1779 assert_eq!(after.status, TaskStatus::InReview);
1780 assert_eq!(after.no_code_reroutes, 0);
1781 }
1782
1783 #[tokio::test]
1786 async fn review_open_prs_batch_fetch_failure_returns_ok() {
1787 let store = make_store().await;
1788 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1789 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1790 Arc::clone(&backend),
1791 Arc::clone(&store),
1792 "owner/repo".to_string(),
1793 ));
1794
1795 let task_id = store
1796 .create(&NewTask {
1797 repo: "owner/repo".to_string(),
1798 origin: "github".to_string(),
1799 title: "task5".to_string(),
1800 ..Default::default()
1801 })
1802 .await
1803 .unwrap();
1804 let _ = crate::store::store_set_by_id(
1805 &Some(&store),
1806 task_id,
1807 &[("branch", serde_json::json!("feat-branch"))],
1808 )
1809 .await;
1810 let _ = crate::store::store_set_by_id(
1812 &Some(&store),
1813 task_id,
1814 &[("pr_number", serde_json::json!(99i64))],
1815 )
1816 .await;
1817 store
1818 .update_status(task_id, TaskStatus::InReview)
1819 .await
1820 .unwrap();
1821 let stored = store.get(task_id).await.unwrap();
1822 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1823 external: crate::engine::tasks::store_task_to_external(&stored),
1824 stored,
1825 };
1826
1827 let gh = MockGh {
1828 batch_error: true,
1829 ..Default::default()
1830 };
1831 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1832 let in_flight = Arc::new(DashSet::new());
1833 let result = review_open_prs(
1834 &backend,
1835 "owner/repo",
1836 &default_config(),
1837 &tm,
1838 &store,
1839 &dispatching,
1840 &in_flight,
1841 &[snapshot],
1842 &gh,
1843 &crate::engine::router::RouterConfig::default(),
1844 )
1845 .await;
1846
1847 assert!(result.is_ok());
1849 let after = store.get(task_id).await.unwrap();
1851 assert_eq!(after.status, TaskStatus::InReview);
1852 }
1853
1854 #[tokio::test]
1855 async fn review_open_prs_conflict_with_max_attempts_blocks_instead_of_reroute() {
1856 let store = make_store().await;
1857 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
1858 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
1859 Arc::clone(&backend),
1860 Arc::clone(&store),
1861 "owner/repo".to_string(),
1862 ));
1863
1864 let task_id = store
1865 .create(&NewTask {
1866 repo: "owner/repo".to_string(),
1867 origin: "github".to_string(),
1868 title: "task-conflict".to_string(),
1869 ..Default::default()
1870 })
1871 .await
1872 .unwrap();
1873 let _ = crate::store::store_set_by_id(
1874 &Some(&store),
1875 task_id,
1876 &[
1877 ("branch", serde_json::json!("feat-conflict")),
1878 ("pr_number", serde_json::json!(77i64)),
1879 ("attempts", serde_json::json!(99i64)),
1880 ],
1881 )
1882 .await;
1883 store
1884 .update_status(task_id, TaskStatus::InReview)
1885 .await
1886 .unwrap();
1887 let stored = store.get(task_id).await.unwrap();
1888 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
1889 external: crate::engine::tasks::store_task_to_external(&stored),
1890 stored,
1891 };
1892
1893 let mut gh = MockGh::default();
1894 gh.batch_data.insert(
1895 77,
1896 PrReviewBatchData {
1897 merged: false,
1898 mergeable: Some(false),
1899 reviews: vec![make_review(
1900 1,
1901 "reviewer",
1902 "APPROVED",
1903 "2026-01-01T00:00:00Z",
1904 None,
1905 )],
1906 review_comments: vec![],
1907 issue_comments: vec![],
1908 },
1909 );
1910
1911 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
1912 let in_flight = Arc::new(DashSet::new());
1913 review_open_prs(
1914 &backend,
1915 "owner/repo",
1916 &default_config(),
1917 &tm,
1918 &store,
1919 &dispatching,
1920 &in_flight,
1921 &[snapshot],
1922 &gh,
1923 &crate::engine::router::RouterConfig::default(),
1924 )
1925 .await
1926 .unwrap();
1927
1928 let after = store.get(task_id).await.unwrap();
1929 assert_eq!(after.status, TaskStatus::Blocked);
1930 assert_eq!(
1931 after.merge_conflict_retries, 0,
1932 "review_poll must not increment merge_conflict_retries when reroute is impossible"
1933 );
1934 assert!(
1935 after
1936 .block_reason
1937 .as_deref()
1938 .unwrap_or("")
1939 .contains("max attempts"),
1940 "block_reason should explain that reroute was refused after max attempts"
1941 );
1942 }
1943
1944 #[test]
1945 fn reset_batch_fail_counter_delete_failure_falls_back_to_zero_and_logs_warning() {
1946 let key = "review_batch_fail:owner/repo";
1947 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1948 let store = MockBatchFailStore {
1949 delete_error: Some("simulated delete failure".to_string()),
1950 set_error: None,
1951 set_calls: Arc::clone(&calls),
1952 };
1953
1954 let logs = with_captured_warn_logs(|| {
1955 let rt = tokio::runtime::Builder::new_current_thread()
1956 .enable_all()
1957 .build()
1958 .unwrap();
1959 let ok = rt.block_on(reset_batch_fail_counter(&store, key));
1960 assert!(ok, "fallback zero-reset should succeed");
1961 });
1962
1963 let set_calls = calls.lock().unwrap();
1964 assert_eq!(set_calls.len(), 1);
1965 assert_eq!(set_calls[0], (key.to_string(), "0".to_string()));
1966 assert!(
1967 logs.contains("falling back to zero reset"),
1968 "expected warning log to mention fallback reset, got: {logs}"
1969 );
1970 }
1971
1972 #[test]
1973 fn reset_batch_fail_counter_delete_and_set_failure_logs_error() {
1974 let key = "review_batch_fail:owner/repo";
1975 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
1976 let store = MockBatchFailStore {
1977 delete_error: Some("simulated delete failure".to_string()),
1978 set_error: Some("simulated set failure".to_string()),
1979 set_calls: Arc::clone(&calls),
1980 };
1981
1982 let logs = with_captured_warn_logs(|| {
1983 let rt = tokio::runtime::Builder::new_current_thread()
1984 .enable_all()
1985 .build()
1986 .unwrap();
1987 let ok = rt.block_on(reset_batch_fail_counter(&store, key));
1988 assert!(
1989 !ok,
1990 "double failure should be surfaced as unsuccessful reset"
1991 );
1992 });
1993
1994 let set_calls = calls.lock().unwrap();
1995 assert_eq!(set_calls.len(), 1);
1996 assert!(
1997 logs.contains("failed to reset review batch failure counter"),
1998 "expected error log when both delete and set fail, got: {logs}"
1999 );
2000 }
2001
2002 #[tokio::test]
2005 async fn review_open_prs_watermark_dedup_skips_already_processed_review() {
2006 let store = make_store().await;
2007 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
2008 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
2009 Arc::clone(&backend),
2010 Arc::clone(&store),
2011 "owner/repo".to_string(),
2012 ));
2013
2014 let task_id = store
2015 .create(&NewTask {
2016 repo: "owner/repo".to_string(),
2017 origin: "github".to_string(),
2018 title: "task6".to_string(),
2019 ..Default::default()
2020 })
2021 .await
2022 .unwrap();
2023 let ts = "2026-01-05T00:00:00Z";
2025 let _ = crate::store::store_set_by_id(
2026 &Some(&store),
2027 task_id,
2028 &[
2029 ("branch", serde_json::json!("feat-branch")),
2030 ("pr_number", serde_json::json!(10i64)),
2031 (
2032 "review_ts_map",
2033 serde_json::json!(serde_json::json!({"reviewer1": ts}).to_string()),
2034 ),
2035 ],
2036 )
2037 .await;
2038 store
2039 .update_status(task_id, TaskStatus::InReview)
2040 .await
2041 .unwrap();
2042 let stored = store.get(task_id).await.unwrap();
2043 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
2044 external: crate::engine::tasks::store_task_to_external(&stored),
2045 stored,
2046 };
2047
2048 let review = make_review(1, "reviewer1", "CHANGES_REQUESTED", ts, Some("Fix this"));
2050 let batch_data = PrReviewBatchData {
2051 merged: false,
2052 mergeable: Some(true),
2053 reviews: vec![review],
2054 review_comments: vec![],
2055 issue_comments: vec![],
2056 };
2057 let mut gh = MockGh::default();
2058 gh.batch_data.insert(10, batch_data);
2059
2060 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
2061 let in_flight = Arc::new(DashSet::new());
2062 review_open_prs(
2063 &backend,
2064 "owner/repo",
2065 &default_config(),
2066 &tm,
2067 &store,
2068 &dispatching,
2069 &in_flight,
2070 &[snapshot],
2071 &gh,
2072 &crate::engine::router::RouterConfig::default(),
2073 )
2074 .await
2075 .unwrap();
2076
2077 let after = store.get(task_id).await.unwrap();
2079 assert_eq!(after.status, TaskStatus::InReview);
2080 }
2081
2082 #[tokio::test]
2083 async fn review_open_prs_does_not_rewrite_stored_pr_number() {
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: "task-pr-number".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 &[
2105 ("branch", serde_json::json!("feat-branch")),
2106 ("pr_number", serde_json::json!(42i64)),
2107 ],
2108 )
2109 .await;
2110 store
2111 .update_status(task_id, TaskStatus::InReview)
2112 .await
2113 .unwrap();
2114 let stored = store.get(task_id).await.unwrap();
2115 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
2116 external: crate::engine::tasks::store_task_to_external(&stored),
2117 stored,
2118 };
2119 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
2120 .bind(task_id)
2121 .execute(store.pool())
2122 .await
2123 .unwrap();
2124
2125 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
2126 let in_flight = Arc::new(DashSet::new());
2127 review_open_prs(
2128 &backend,
2129 "owner/repo",
2130 &default_config(),
2131 &tm,
2132 &store,
2133 &dispatching,
2134 &in_flight,
2135 &[snapshot],
2136 &MockGh::default(),
2137 &crate::engine::router::RouterConfig::default(),
2138 )
2139 .await
2140 .unwrap();
2141
2142 let after = store.get(task_id).await.unwrap();
2143 assert_eq!(after.pr_number, Some(42));
2144 assert_eq!(
2145 after.updated_at, "2020-01-01T00:00:00Z",
2146 "rewriting an already-stored pr_number must not bump updated_at"
2147 );
2148 }
2149
2150 #[tokio::test]
2153 async fn review_open_prs_last_comment_review_ts_dedup_skips_old_comment() {
2154 let store = make_store().await;
2155 let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(MockBackend);
2156 let tm = Arc::new(crate::engine::tasks::TaskManager::with_store(
2157 Arc::clone(&backend),
2158 Arc::clone(&store),
2159 "owner/repo".to_string(),
2160 ));
2161
2162 let task_id = store
2163 .create(&NewTask {
2164 repo: "owner/repo".to_string(),
2165 origin: "github".to_string(),
2166 title: "task7".to_string(),
2167 ..Default::default()
2168 })
2169 .await
2170 .unwrap();
2171 let _ = crate::store::store_set_by_id(
2172 &Some(&store),
2173 task_id,
2174 &[("branch", serde_json::json!("feat-branch"))],
2175 )
2176 .await;
2177 let _ = crate::store::store_set_by_id(
2178 &Some(&store),
2179 task_id,
2180 &[("pr_number", serde_json::json!(20i64))],
2181 )
2182 .await;
2183 let ts = "2026-01-05T00:00:00Z";
2185 let _ = crate::store::store_set_by_id(
2186 &Some(&store),
2187 task_id,
2188 &[("last_comment_review_ts", serde_json::json!(ts))],
2189 )
2190 .await;
2191 store
2192 .update_status(task_id, TaskStatus::InReview)
2193 .await
2194 .unwrap();
2195 let stored = store.get(task_id).await.unwrap();
2196 let snapshot = crate::engine::sync::ReviewTaskSnapshot {
2197 external: crate::engine::tasks::store_task_to_external(&stored),
2198 stored,
2199 };
2200
2201 let comment_body = "## Automated Review \u{2014} Changes Requested\n\nFix the bug.";
2202 let comment = make_comment(1, "bot", comment_body, ts);
2203 let batch_data = PrReviewBatchData {
2204 merged: false,
2205 mergeable: Some(true),
2206 reviews: vec![],
2207 review_comments: vec![],
2208 issue_comments: vec![comment],
2209 };
2210 let mut gh = MockGh::default();
2211 gh.batch_data.insert(20, batch_data);
2212 gh.collaborators.insert("bot".to_string(), true);
2213
2214 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
2215 let in_flight = Arc::new(DashSet::new());
2216 review_open_prs(
2217 &backend,
2218 "owner/repo",
2219 &default_config(),
2220 &tm,
2221 &store,
2222 &dispatching,
2223 &in_flight,
2224 &[snapshot],
2225 &gh,
2226 &crate::engine::router::RouterConfig::default(),
2227 )
2228 .await
2229 .unwrap();
2230
2231 let after = store.get(task_id).await.unwrap();
2233 assert_eq!(after.status, TaskStatus::InReview);
2234 }
2235
2236 #[test]
2239 fn review_context_truncation_at_16kb_boundary() {
2240 const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
2242
2243 let repeated = "a".repeat(200);
2245 let mut review_context = String::new();
2246 while review_context.len() <= MAX_REVIEW_CONTEXT_BYTES {
2247 review_context.push_str(&repeated);
2248 review_context.push('\n');
2249 }
2250
2251 assert!(review_context.len() > MAX_REVIEW_CONTEXT_BYTES);
2252
2253 if review_context.len() > MAX_REVIEW_CONTEXT_BYTES {
2255 let mut boundary = MAX_REVIEW_CONTEXT_BYTES;
2256 while !review_context.is_char_boundary(boundary) {
2257 boundary -= 1;
2258 }
2259 if let Some(pos) = review_context[..boundary].rfind('\n') {
2260 review_context.truncate(pos);
2261 } else {
2262 review_context.truncate(boundary);
2263 }
2264 review_context.push_str("\n... (review context truncated)");
2265 }
2266
2267 assert!(review_context.len() <= MAX_REVIEW_CONTEXT_BYTES + 40); assert!(review_context.ends_with("... (review context truncated)"));
2269 assert!(std::str::from_utf8(review_context.as_bytes()).is_ok());
2271 }
2272}