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        // Skip a rewrite of the same number, set_fields would bump updated_at
626        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        // Deduplicate reviews: keep only the latest per reviewer.
660        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        // Determine automated review status from batch issue comments + collaborator cache.
669        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        // Handle fully-approved PRs: mark done only when actually merged,
675        // otherwise attempt auto-merge. Never mark done on approval alone.
676        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                // Check if auto-merge is already in flight for this task.
731                // This prevents double-spawn since review_open_prs runs every sync_tick (~10s)
732                // but CI polling can take up to 10 minutes.
733                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                    // Spawn auto-merge in background to avoid blocking sync_tick.
741                    // The CI polling loop can take up to 10 minutes with backoff.
742                    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        // Process reviews that request changes.
782        if !any_changes_requested && !comment_changes_requested {
783            continue;
784        }
785
786        // Build review context for re-dispatch.
787        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            // Skip if we've already processed this review for this reviewer.
801            if !reviewer_last_ts.is_empty() && review.submitted_at.as_str() <= reviewer_last_ts {
802                continue;
803            }
804
805            // Track the latest timestamp for this reviewer.
806            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        // Also include comment-based review feedback from batch issue comments.
853        // Collect the new comment timestamp but do NOT persist it yet — we only
854        // save it after handle_review_changes() succeeds so that a transient
855        // failure does not silently drop the review on the next poll.
856        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            // Defensive: ensure comments are processed in chronological order
861            // and find the newest matching automated changes-requested comment
862            // that is newer than the stored watermark.
863            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                // Capture the timestamp; persisted atomically with last_review_ts below.
891                new_comment_review_ts = Some(c.created_at.clone());
892            }
893        }
894
895        // Cap review context to avoid oversized values.
896        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 we have new review feedback, re-dispatch the task.
911        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            // Call handle_review_changes FIRST. If it fails, we do not advance the
927            // watermark, allowing the review to be re-processed on the next poll tick.
928            // If it succeeds, we advance the watermark to prevent duplicate re-dispatches.
929            //
930            // This trade-off (retry transient failures vs. prevent idempotent duplicates)
931            // is correct: it is better to retry a review once more than to silently drop
932            // reviewer feedback forever.
933            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            // Only after handle_review_changes succeeds, persist the watermark timestamps.
953            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            // Retry the watermark save a few times before giving up
959            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
988/// Determine automated review status from batch-fetched issue comments.
989///
990/// Mirrors the logic of `get_automated_review_status` but operates on
991/// pre-fetched comments and a pre-built collaborator cache instead of making
992/// additional REST calls.
993fn automated_review_from_comments(
994    issue_comments: &[GitHubComment],
995    collab_cache: &HashMap<String, Option<bool>>,
996    pr_number: u64,
997) -> Option<String> {
998    // Find the newest automated review comment authored by a collaborator.
999    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    // ─── Mock GhReviewClient ─────────────────────────────────────────────────
1063
1064    #[derive(Default)]
1065    struct MockGh {
1066        /// Responses for get_pr_number keyed by branch.
1067        pr_numbers: HashMap<String, Option<u64>>,
1068        /// Responses for is_pr_merged keyed by branch.
1069        merged: HashMap<String, bool>,
1070        /// Response for batch_fetch_pr_review_data.
1071        batch_data: HashMap<u64, PrReviewBatchData>,
1072        /// Whether batch_fetch_pr_review_data should return an error.
1073        batch_error: bool,
1074        /// Responses for is_collaborator keyed by username.
1075        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    // ─── Mock ExternalBackend ────────────────────────────────────────────────
1102
1103    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    // ─── Helpers ─────────────────────────────────────────────────────────────
1179
1180    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    // ─── automated_review_from_comments ──────────────────────────────────────
1302
1303    #[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        // User not present in cache at all — treated as non-collaborator.
1354        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        // Cache has the key but value is None — collaborator check failed.
1363        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        // Newer (changes_requested) wins.
1379        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        // Body starts with "## Automated Review" but first line doesn't match either verdict.
1388        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    // ─── review_open_prs: empty task list ────────────────────────────────────
1395
1396    #[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    // ─── review_open_prs: dispatching set guard ───────────────────────────────
1427
1428    #[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        // Create a task in-store so we can build a snapshot.
1439        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        // Lock the task in the dispatching set.
1465        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        // Mock returns pr_number = Some(42) so the task would normally proceed.
1472        let mut gh = MockGh::default();
1473        gh.pr_numbers.insert("my-branch".to_string(), Some(42));
1474        // If the guard is broken, batch_fetch would be called (we don't set batch_data, so it
1475        // returns empty — which would cause the task to silently skip, not panic).
1476        // The important invariant is that the store status remains InReview.
1477
1478        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        // Task status must remain in_review (guard prevented any state change).
1495        let after = store.get(task_store_id).await.unwrap();
1496        assert_eq!(after.status, TaskStatus::InReview);
1497    }
1498
1499    // ─── review_open_prs: Phase 3 — no open PR, PR merged ────────────────────
1500
1501    #[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        // No PR number stored, and no result from get_pr_number → pr_number stays None.
1537        // is_pr_merged returns true.
1538        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    // ─── review_open_prs: Phase 3 — no PR, not merged, reroute ──────────────
1563
1564    #[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        // No PR, not merged.
1600        let gh = MockGh::default(); // merged defaults to false, pr_numbers empty
1601
1602        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    // ─── review_open_prs: Phase 3 — no PR, max reroutes → blocked ────────────
1625
1626    #[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        // Pre-set no_code_reroutes to a high value that exceeds any configured max after increment.
1657        // The default max is 3; use 99 to be robust against any real config on the test machine.
1658        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    // ─── review_open_prs: Phase 3 — transient error skips without increment ──
1698
1699    #[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        // Status must remain InReview and counter must not have been incremented.
1778        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    // ─── review_open_prs: Phase 4 — batch fetch failure → graceful skip ──────
1784
1785    #[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        // Set pr_number so task reaches Phase 4.
1811        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        // Must return Ok (graceful skip) even though batch fetch failed.
1848        assert!(result.is_ok());
1849        // Task status must be unchanged.
1850        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    // ─── review_open_prs: watermark dedup — review_ts_map skips old reviews ──
2003
2004    #[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        // Pre-set review_ts_map so the reviewer's review is already watermarked.
2024        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        // Batch data has a CHANGES_REQUESTED review at the same timestamp as the watermark.
2049        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        // Status must remain InReview — review was skipped due to watermark.
2078        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    // ─── review_open_prs: last_comment_review_ts dedup ───────────────────────
2151
2152    #[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        // Set last_comment_review_ts to match the comment's created_at so it is deduplicated.
2184        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        // Status must remain InReview — comment was deduplicated.
2232        let after = store.get(task_id).await.unwrap();
2233        assert_eq!(after.status, TaskStatus::InReview);
2234    }
2235
2236    // ─── review_open_prs: review context truncation ──────────────────────────
2237
2238    #[test]
2239    fn review_context_truncation_at_16kb_boundary() {
2240        // Replicate the truncation logic from review_open_prs to verify it works correctly.
2241        const MAX_REVIEW_CONTEXT_BYTES: usize = 16 * 1024;
2242
2243        // Build a review_context that exceeds 16 KB.
2244        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        // Apply the same truncation logic as in review_open_prs.
2254        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); // suffix is short
2268        assert!(review_context.ends_with("... (review context truncated)"));
2269        // Must be valid UTF-8 after truncation (no split multi-byte chars).
2270        assert!(std::str::from_utf8(review_context.as_bytes()).is_ok());
2271    }
2272}