Skip to main content

orch/engine/
review_poll.rs

1//! PR review polling — `review_open_prs`.
2//!
3//! Extracted from `engine/review.rs`. Monitors tasks in `InReview` status,
4//! checks for human review feedback, and re-dispatches agents when changes
5//! are requested.
6//!
7//! ## Batching strategy
8//!
9//! Instead of making 4–5 individual REST calls per in-review task, the loop
10//! is split into phases:
11//!
12//! 1. Validate tasks and load stored data (no API calls).
13//! 2. Resolve missing PR numbers concurrently via REST (rare after first tick).
14//! 3. Handle tasks with no open PR (merged / reroute logic).
15//! 4. Fetch all PR review data in **one** GraphQL query for all tasks at once.
16//! 5. Build a collaborator cache from batch issue comments (unique users only).
17//! 6. Process each task using the pre-fetched batch data.
18//!
19//! With N in-review tasks the call count drops from ~5N REST calls to:
20//! - M `get_pr_number` REST calls (M ≈ 0 after first tick per task)
21//! - 1 GraphQL batch call
22//! - K `is_collaborator` REST calls (K = unique automated-review users ≪ N)
23
24use 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/// Abstraction over the GitHub HTTP calls used by `review_open_prs`.
42///
43/// Allows tests to inject a mock without needing a real GitHub token.
44#[allow(clippy::double_must_use)] // async_trait marks its boxed futures must_use twice
45#[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/// Action to take when a PR has merge conflicts after approval.
78#[derive(Debug, Clone, Copy)]
79enum ConflictAction {
80    /// Re-dispatch the task agent to resolve merge conflicts (set Routed status).
81    RerouteToAgent,
82    /// Block the task for human review (retry limit exceeded).
83    BlockForHuman,
84}
85
86/// Handle a merge conflict detected in a fully-approved PR.
87///
88/// Returns the action to take: either re-route the task agent (increment counter
89/// + set Routed) or block for human (write block_reason + set Blocked).
90async 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"; // will use store_id-based path below
166    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 marks its boxed futures must_use twice
205#[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
222/// Reset the review polling batch-failure counter after a successful batch fetch.
223///
224/// Prefer deleting the key to keep the KV namespace clean. If delete fails,
225/// write an explicit `0` to prevent stale counters from causing false degraded
226/// alerts on the next isolated failure.
227async 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/// Review open PRs - re-dispatch agent to address review feedback.
262///
263/// Lists tasks in review, fetches PR reviews, and re-dispatches the agent
264/// when a reviewer requests changes. The review context is stored in the
265/// store and injected into the agent prompt.
266#[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    // ─── Phase 1: Validate tasks and separate by PR number availability ───────
290
291    struct ReadyTask {
292        task: crate::backends::ExternalTask,
293        store_id: i64,
294        stored: crate::store::Task,
295        branch: String,
296        /// `Some` = already stored in DB, `None` = needs REST lookup.
297        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        // Skip tasks currently being processed by the main tick.
307        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    // ─── Phase 2: Resolve PR numbers for tasks that don't have one stored ─────
350    // Collect branch strings for tasks that need a PR number lookup so we can
351    // run the REST calls concurrently without borrowing `ready_tasks`.
352
353    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                    // No open PR — handled in phase 3.
386                }
387                Err(e) => {
388                    let e_str = format!("{e}");
389                    // If transient GitHub 5xx/transport error, skip incrementing counters
390                    // and let the task be retried on the next tick.
391                    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    // ─── Phase 3: Handle tasks with no open PR ────────────────────────────────
402    // Split ready_tasks into those with a PR number and those without, then
403    // run is_pr_merged concurrently for all no-PR tasks (mirrors Phase 2).
404
405    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                // Avoid incrementing persistent reroute counters when the last
459                // store error indicates a transient GitHub 5xx/transport failure.
460                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                    // Write block_reason atomically with the status transition to prevent
492                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
493                    // re-dispatching this task.
494                    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                        // Do not consume the persistent no_code_reroutes counter when the
527                        // status update fails (e.g. transient DB error). Retry on the
528                        // next tick so the counter only increments after a successful
529                        // re-dispatch.
530                        continue;
531                    }
532                }
533            }
534        }
535    }
536
537    if tasks_with_pr.is_empty() {
538        return Ok(());
539    }
540
541    // ─── Phase 4: Batch GraphQL fetch — one call for all in-review PRs ────────
542
543    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            // Track consecutive batch fetch failures in the KV store. If the
553            // KV increment itself fails (e.g. transient DB error), treat the
554            // situation as immediately degraded so operators receive an error
555            // level signal instead of silently staying at warn level.
556            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                    // Use a sentinel high value so the escalation path fires.
561                    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    // ─── Phase 5: Build collaborator cache from batch issue comments ──────────
584    // Collect unique user logins that appear in automated review comments so
585    // we can verify collaborator status with a single pass instead of one REST
586    // call per task.
587
588    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    // ─── Phase 6: Process each task using batch data ──────────────────────────
613
614    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            // tasks_with_pr is constructed to only include items with pr_number,
620            // but guard defensively against future refactors.
621            tracing::warn!(task_id, "task in tasks_with_pr has no pr_number — skipping");
622            continue;
623        };
624
625        // Persist PR number (idempotent if already stored).
626        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        // Deduplicate reviews: keep only the latest per reviewer.
658        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        // Determine automated review status from batch issue comments + collaborator cache.
667        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        // Handle fully-approved PRs: mark done only when actually merged,
673        // otherwise attempt auto-merge. Never mark done on approval alone.
674        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                // Check if auto-merge is already in flight for this task.
729                // This prevents double-spawn since review_open_prs runs every sync_tick (~10s)
730                // but CI polling can take up to 10 minutes.
731                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                    // Spawn auto-merge in background to avoid blocking sync_tick.
739                    // The CI polling loop can take up to 10 minutes with backoff.
740                    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        // Process reviews that request changes.
780        if !any_changes_requested && !comment_changes_requested {
781            continue;
782        }
783
784        // Build review context for re-dispatch.
785        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            // Skip if we've already processed this review for this reviewer.
799            if !reviewer_last_ts.is_empty() && review.submitted_at.as_str() <= reviewer_last_ts {
800                continue;
801            }
802
803            // Track the latest timestamp for this reviewer.
804            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        // Also include comment-based review feedback from batch issue comments.
851        // Collect the new comment timestamp but do NOT persist it yet — we only
852        // save it after handle_review_changes() succeeds so that a transient
853        // failure does not silently drop the review on the next poll.
854        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            // Defensive: ensure comments are processed in chronological order
859            // and find the newest matching automated changes-requested comment
860            // that is newer than the stored watermark.
861            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                // Capture the timestamp; persisted atomically with last_review_ts below.
889                new_comment_review_ts = Some(c.created_at.clone());
890            }
891        }
892
893        // Cap review context to avoid oversized values.
894        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 we have new review feedback, re-dispatch the task.
909        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            // Call handle_review_changes FIRST. If it fails, we do not advance the
925            // watermark, allowing the review to be re-processed on the next poll tick.
926            // If it succeeds, we advance the watermark to prevent duplicate re-dispatches.
927            //
928            // This trade-off (retry transient failures vs. prevent idempotent duplicates)
929            // is correct: it is better to retry a review once more than to silently drop
930            // reviewer feedback forever.
931            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            // Only after handle_review_changes succeeds, persist the watermark timestamps.
951            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            // Retry the watermark save a few times before giving up
957            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
986/// Determine automated review status from batch-fetched issue comments.
987///
988/// Mirrors the logic of `get_automated_review_status` but operates on
989/// pre-fetched comments and a pre-built collaborator cache instead of making
990/// additional REST calls.
991fn automated_review_from_comments(
992    issue_comments: &[GitHubComment],
993    collab_cache: &HashMap<String, Option<bool>>,
994    pr_number: u64,
995) -> Option<String> {
996    // Find the newest automated review comment authored by a collaborator.
997    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    // ─── Mock GhReviewClient ─────────────────────────────────────────────────
1061
1062    #[derive(Default)]
1063    struct MockGh {
1064        /// Responses for get_pr_number keyed by branch.
1065        pr_numbers: HashMap<String, Option<u64>>,
1066        /// Responses for is_pr_merged keyed by branch.
1067        merged: HashMap<String, bool>,
1068        /// Response for batch_fetch_pr_review_data.
1069        batch_data: HashMap<u64, PrReviewBatchData>,
1070        /// Whether batch_fetch_pr_review_data should return an error.
1071        batch_error: bool,
1072        /// Responses for is_collaborator keyed by username.
1073        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    // ─── Mock ExternalBackend ────────────────────────────────────────────────
1100
1101    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    // ─── Helpers ─────────────────────────────────────────────────────────────
1177
1178    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    // ─── automated_review_from_comments ──────────────────────────────────────
1300
1301    #[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        // User not present in cache at all — treated as non-collaborator.
1352        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        // Cache has the key but value is None — collaborator check failed.
1361        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        // Newer (changes_requested) wins.
1377        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        // Body starts with "## Automated Review" but first line doesn't match either verdict.
1386        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    // ─── review_open_prs: empty task list ────────────────────────────────────
1393
1394    #[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    // ─── review_open_prs: dispatching set guard ───────────────────────────────
1425
1426    #[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        // Create a task in-store so we can build a snapshot.
1437        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        // Lock the task in the dispatching set.
1463        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        // Mock returns pr_number = Some(42) so the task would normally proceed.
1470        let mut gh = MockGh::default();
1471        gh.pr_numbers.insert("my-branch".to_string(), Some(42));
1472        // If the guard is broken, batch_fetch would be called (we don't set batch_data, so it
1473        // returns empty — which would cause the task to silently skip, not panic).
1474        // The important invariant is that the store status remains InReview.
1475
1476        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        // Task status must remain in_review (guard prevented any state change).
1493        let after = store.get(task_store_id).await.unwrap();
1494        assert_eq!(after.status, TaskStatus::InReview);
1495    }
1496
1497    // ─── review_open_prs: Phase 3 — no open PR, PR merged ────────────────────
1498
1499    #[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        // No PR number stored, and no result from get_pr_number → pr_number stays None.
1535        // is_pr_merged returns true.
1536        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    // ─── review_open_prs: Phase 3 — no PR, not merged, reroute ──────────────
1561
1562    #[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        // No PR, not merged.
1598        let gh = MockGh::default(); // merged defaults to false, pr_numbers empty
1599
1600        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    // ─── review_open_prs: Phase 3 — no PR, max reroutes → blocked ────────────
1623
1624    #[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        // Pre-set no_code_reroutes to a high value that exceeds any configured max after increment.
1655        // The default max is 3; use 99 to be robust against any real config on the test machine.
1656        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    // ─── review_open_prs: Phase 3 — transient error skips without increment ──
1696
1697    #[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        // Status must remain InReview and counter must not have been incremented.
1776        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    // ─── review_open_prs: Phase 4 — batch fetch failure → graceful skip ──────
1782
1783    #[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        // Set pr_number so task reaches Phase 4.
1809        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        // Must return Ok (graceful skip) even though batch fetch failed.
1846        assert!(result.is_ok());
1847        // Task status must be unchanged.
1848        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    // ─── review_open_prs: watermark dedup — review_ts_map skips old reviews ──
2001
2002    #[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        // Pre-set review_ts_map so the reviewer's review is already watermarked.
2022        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        // Batch data has a CHANGES_REQUESTED review at the same timestamp as the watermark.
2047        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        // Status must remain InReview — review was skipped due to watermark.
2076        let after = store.get(task_id).await.unwrap();
2077        assert_eq!(after.status, TaskStatus::InReview);
2078    }
2079
2080    // ─── review_open_prs: last_comment_review_ts dedup ───────────────────────
2081
2082    #[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        // Set last_comment_review_ts to match the comment's created_at so it is deduplicated.
2114        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        // Status must remain InReview — comment was deduplicated.
2162        let after = store.get(task_id).await.unwrap();
2163        assert_eq!(after.status, TaskStatus::InReview);
2164    }
2165
2166    // ─── review_open_prs: review context truncation ──────────────────────────
2167
2168    #[test]
2169    fn review_context_truncation_at_16kb_boundary() {
2170        // Replicate the truncation logic from review_open_prs to verify it works correctly.
2171        const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
2172
2173        // Build a review_context that exceeds 16 KB.
2174        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        // Apply the same truncation logic as in review_open_prs.
2184        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); // suffix is short
2198        assert!(review_context.ends_with("... (review context truncated)"));
2199        // Must be valid UTF-8 after truncation (no split multi-byte chars).
2200        assert!(std::str::from_utf8(review_context.as_bytes()).is_ok());
2201    }
2202}